mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge development into staging (keep staging CI config)
This commit is contained in:
+21
-27
@@ -2,6 +2,17 @@ stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
# ==========================================================
|
||||
# ✅ Global defaults
|
||||
# ==========================================================
|
||||
default:
|
||||
tags:
|
||||
- self-hosted
|
||||
interruptible: true
|
||||
|
||||
# ==========================================================
|
||||
# 🏗️ Build Template
|
||||
# ==========================================================
|
||||
.build_template: &build_template
|
||||
stage: build
|
||||
image: node:20-alpine
|
||||
@@ -39,6 +50,9 @@ stages:
|
||||
- out/
|
||||
expire_in: 1 week
|
||||
|
||||
# ==========================================================
|
||||
# 🚀 Deploy Template
|
||||
# ==========================================================
|
||||
.deploy_template: &deploy_template
|
||||
stage: deploy
|
||||
image:
|
||||
@@ -82,11 +96,11 @@ stages:
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
COLOR=3066993
|
||||
TITLE="✅ Deployment ${ENVIRONMENT_NAME} Succeeded"
|
||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."
|
||||
DESC="Deployment job on branch \${CI_COMMIT_REF_NAME}\ completed successfully."
|
||||
else
|
||||
COLOR=15158332
|
||||
TITLE="❌ Deployment ${ENVIRONMENT_NAME} Failed"
|
||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` encountered issues."
|
||||
DESC="Deployment job on branch \${CI_COMMIT_REF_NAME}\ encountered issues."
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
@@ -114,7 +128,9 @@ stages:
|
||||
|
||||
curl -sS -H "Content-Type: application/json" -d @payload.json "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
# ====== DEVELOPMENT (Branch development) ======
|
||||
# ==========================================================
|
||||
# ==== DEVELOPMENT (Branch development) ======
|
||||
# ==========================================================
|
||||
build:dev:
|
||||
<<: *build_template
|
||||
rules:
|
||||
@@ -140,7 +156,9 @@ deploy:dev:
|
||||
name: development
|
||||
url: https://dev-lti-erp.mbugroup.id
|
||||
|
||||
# ==========================================================
|
||||
# ====== STAGING (Branch staging) ======
|
||||
# ==========================================================
|
||||
build:staging:
|
||||
<<: *build_template
|
||||
rules:
|
||||
@@ -165,27 +183,3 @@ deploy:staging:
|
||||
environment:
|
||||
name: staging
|
||||
url: https://stg-lti-erp.mbugroup.id
|
||||
|
||||
|
||||
# ====== PRODUCTION ======
|
||||
# build:production:
|
||||
# <<: *build_template
|
||||
# rules:
|
||||
# # pilih salah satu: pakai branch master ATAU pakai tags rilis
|
||||
# - if: '$CI_COMMIT_BRANCH == "master"'
|
||||
# # - if: '$CI_COMMIT_TAG' # kalau mau rilis via tag, uncomment ini dan hapus baris di atas
|
||||
# environment:
|
||||
# name: production
|
||||
|
||||
# deploy:production:
|
||||
# <<: *deploy_template
|
||||
# needs: ["build:production"]
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_BRANCH == "master"'
|
||||
# # - if: '$CI_COMMIT_TAG' # selaras dengan rule di build:production
|
||||
# variables:
|
||||
# S3_BUCKET: "lti-erp.mbugroup.id"
|
||||
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
|
||||
# environment:
|
||||
# name: production
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
npm run format
|
||||
npm run lint
|
||||
npm run build
|
||||
npx tsc --noEmit
|
||||
Generated
+630
-8
@@ -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"
|
||||
},
|
||||
@@ -1082,9 +1086,9 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -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,12 +3239,143 @@
|
||||
"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.5.8",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.8.tgz",
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
+5
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,11 @@ const ClosingDetailPage = () => {
|
||||
() => ClosingApi.getPenjualan(Number(closingId))
|
||||
);
|
||||
|
||||
const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR(
|
||||
closingId ? `hpp-ekspedisi-${closingId}` : null,
|
||||
() => ClosingApi.getHppEkspedisi(Number(closingId))
|
||||
);
|
||||
|
||||
if (!closingId) {
|
||||
router.back();
|
||||
|
||||
@@ -39,7 +44,7 @@ const ClosingDetailPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoading = isLoadingClosing || isLoadingSales;
|
||||
const isLoading = isLoadingClosing || isLoadingSales || isLoadingHppEkspedisi;
|
||||
|
||||
return (
|
||||
<div className='w-full p-4 flex flex-row justify-center'>
|
||||
@@ -50,6 +55,11 @@ const ClosingDetailPage = () => {
|
||||
id={Number(closingId)}
|
||||
initialValue={closing.data}
|
||||
salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
|
||||
hppExpeditionData={
|
||||
isResponseSuccess(hppEkspedisiData)
|
||||
? hppEkspedisiData.data
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'>
|
||||
<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'>
|
||||
<ChickinTable />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
export default Chickin;
|
||||
@@ -0,0 +1,7 @@
|
||||
import UniformityForm from '@/components/pages/production/uniformity/form/UniformityForm';
|
||||
|
||||
const AddUniformity = () => {
|
||||
return <UniformityForm formType='add' />;
|
||||
};
|
||||
|
||||
export default AddUniformity;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import UniformityDetail from '@/components/pages/production/uniformity/detail/UniformityDetail';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { UniformityApi } from '@/services/api/uniformity';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const UniformityDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const uniformityId = searchParams.get('uniformityId');
|
||||
|
||||
const { data: uniformity, isLoading: isLoadingUniformity } = useSWR(
|
||||
uniformityId,
|
||||
(id: string) => UniformityApi.getUniformityDetail(parseInt(id))
|
||||
);
|
||||
|
||||
if (!uniformityId) {
|
||||
router.back();
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoadingUniformity && (!uniformity || isResponseError(uniformity))) {
|
||||
router.replace('/404');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col justify-center'>
|
||||
{isLoadingUniformity && (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4 min-h-screen'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
)}
|
||||
{isResponseSuccess(uniformity) && (
|
||||
<UniformityDetail initialValues={uniformity.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UniformityDetailPage;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import UniformityPageWrapper from '@/components/pages/production/uniformity/UniformityPageWrapper';
|
||||
|
||||
export default function UniformityLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <UniformityPageWrapper>{children}</UniformityPageWrapper>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import UniformityTable from '@/components/pages/production/uniformity/UniformityTable';
|
||||
|
||||
const Uniformity = () => {
|
||||
return <UniformityTable />;
|
||||
};
|
||||
|
||||
export default Uniformity;
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,5 @@
|
||||
const ReportExpenseDetail = () => {
|
||||
return <div>ReportExpenseDetail</div>;
|
||||
};
|
||||
|
||||
export default ReportExpenseDetail;
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable';
|
||||
|
||||
const ReportExpense = () => {
|
||||
return (
|
||||
<div className='w-full p-4'>
|
||||
<ReportExpenseTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportExpense;
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,7 @@
|
||||
import LogisticStockTabs from '@/components/pages/report/logistic-stock/LogisticStockTabs';
|
||||
|
||||
const LogisticStock = () => {
|
||||
return <LogisticStockTabs />;
|
||||
};
|
||||
|
||||
export default LogisticStock;
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,11 @@
|
||||
import MarketingReportContent from '@/components/pages/report/MarketingReportContent';
|
||||
|
||||
const MarketingReportPage = () => {
|
||||
return (
|
||||
<section className='w-full p-4'>
|
||||
<MarketingReportContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketingReportPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent';
|
||||
|
||||
const ProductionResultReportPage = () => {
|
||||
return (
|
||||
<section className='w-full max-w-7xl pb-16'>
|
||||
<ProductionResultContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionResultReportPage;
|
||||
+34
-14
@@ -3,29 +3,25 @@
|
||||
import { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
import type { Color, Variant, Size } from '@/types/theme';
|
||||
|
||||
export interface BadgeProps
|
||||
extends Omit<HTMLAttributes<HTMLSpanElement>, 'className'> {
|
||||
children?: ReactNode;
|
||||
className?: {
|
||||
badge?: string;
|
||||
status?: string;
|
||||
};
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'soft' | 'dash';
|
||||
color?:
|
||||
| 'neutral'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'accent'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
statusIndicator?: boolean;
|
||||
variant?: Variant;
|
||||
color?: Color;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const Badge = ({
|
||||
children,
|
||||
className,
|
||||
statusIndicator = false,
|
||||
variant = 'default',
|
||||
color,
|
||||
size = 'md',
|
||||
@@ -34,7 +30,7 @@ const Badge = ({
|
||||
const getBadgeClasses = () => {
|
||||
const baseClasses = 'badge';
|
||||
|
||||
const variantClasses = {
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: '',
|
||||
outline: 'badge-outline',
|
||||
ghost: 'badge-ghost',
|
||||
@@ -42,7 +38,7 @@ const Badge = ({
|
||||
dash: 'badge-dash',
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
const colorClasses: Record<Color, string> = {
|
||||
neutral: 'badge-neutral',
|
||||
primary: 'badge-primary',
|
||||
secondary: 'badge-secondary',
|
||||
@@ -51,9 +47,10 @@ const Badge = ({
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
error: 'badge-error',
|
||||
none: '',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
xs: 'badge-xs',
|
||||
sm: 'badge-sm',
|
||||
md: 'badge-md',
|
||||
@@ -70,8 +67,31 @@ const Badge = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusClasses = () => {
|
||||
if (!statusIndicator) return '';
|
||||
|
||||
const statusIndicatorClasses: Record<Color, string> = {
|
||||
neutral: 'bg-neutral',
|
||||
primary: 'bg-primary',
|
||||
secondary: 'bg-secondary',
|
||||
accent: 'bg-accent',
|
||||
info: 'bg-info',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
none: '',
|
||||
};
|
||||
|
||||
return cn(
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
color && statusIndicatorClasses[color],
|
||||
className?.status
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={getBadgeClasses()} {...props}>
|
||||
{statusIndicator && <span className={getStatusClasses()} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ interface DrawerProps {
|
||||
className?: DrawerClassName;
|
||||
onBackdropClick?: () => void;
|
||||
closeOnBackdropClick?: boolean;
|
||||
expandedContent?: ReactNode;
|
||||
expandedWidth?: string;
|
||||
}
|
||||
|
||||
type DrawerClassName = {
|
||||
@@ -36,6 +38,8 @@ const Drawer = ({
|
||||
className,
|
||||
onBackdropClick,
|
||||
closeOnBackdropClick = true,
|
||||
expandedContent,
|
||||
expandedWidth = 'w-[400px]',
|
||||
}: DrawerProps) => {
|
||||
const getDrawerClassNames = (): DrawerClassName => {
|
||||
const baseClassNames = {
|
||||
@@ -46,12 +50,21 @@ const Drawer = ({
|
||||
drawerSidebarContent: 'min-h-full bg-base-100',
|
||||
};
|
||||
|
||||
const getSidebarWidth = () => {
|
||||
if (variant === 'sidebar') {
|
||||
return expandedContent
|
||||
? 'w-full lg:min-w-[600px] lg:max-w-[600px]'
|
||||
: 'w-full max-w-[300px] lg:w-[300px]';
|
||||
}
|
||||
return 'w-full sm:min-w-120 sm:w-fit';
|
||||
};
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return {
|
||||
...baseClassNames,
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
'w-full max-w-[300px] lg:w-[300px]'
|
||||
getSidebarWidth()
|
||||
),
|
||||
};
|
||||
} else if (variant === 'right') {
|
||||
@@ -60,11 +73,11 @@ const Drawer = ({
|
||||
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'
|
||||
'border-l border-solid border-gray-200 sm:drawer-side w-screen top-0 right-0 fixed z-21'
|
||||
),
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
'w-full min-w-120 sm:w-fit'
|
||||
getSidebarWidth()
|
||||
),
|
||||
};
|
||||
} else if (variant === 'left') {
|
||||
@@ -76,7 +89,7 @@ const Drawer = ({
|
||||
),
|
||||
drawerSidebarContent: cn(
|
||||
baseClassNames.drawerSidebarContent,
|
||||
'w-full min-w-120 sm:w-fit'
|
||||
getSidebarWidth()
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -138,14 +151,37 @@ const Drawer = ({
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
{/* Sidebar Content - Full height container */}
|
||||
<div
|
||||
className={cn(
|
||||
varianClassName?.drawerSidebarContent,
|
||||
className?.drawerContent
|
||||
'flex h-screen bg-base-100 overflow-hidden',
|
||||
variant === 'right' && 'flex-row'
|
||||
)}
|
||||
>
|
||||
{sidebarContent}
|
||||
{/* Primary Sidebar Content */}
|
||||
<div
|
||||
className={cn(
|
||||
varianClassName?.drawerSidebarContent,
|
||||
className?.drawerContent,
|
||||
'overflow-y-auto'
|
||||
)}
|
||||
>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Expanded Drawer (Right side, side-by-side) */}
|
||||
{expandedContent && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-l border-gray-200 bg-white flex flex-col h-full',
|
||||
expandedWidth
|
||||
)}
|
||||
>
|
||||
<div className='overflow-y-auto flex-1 h-full'>
|
||||
{expandedContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { ReactNode, useState, useRef } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
trigger?: string;
|
||||
content?: string;
|
||||
};
|
||||
align?: 'start' | 'center' | 'end';
|
||||
direction?: 'top' | 'bottom' | 'left' | 'right';
|
||||
hover?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
close?: boolean;
|
||||
controlled?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown = ({
|
||||
trigger,
|
||||
children,
|
||||
className,
|
||||
align,
|
||||
direction,
|
||||
hover,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
close,
|
||||
controlled = false,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!controlled) {
|
||||
const newState = !isOpen;
|
||||
setIsOpen(newState);
|
||||
}
|
||||
};
|
||||
|
||||
const getWrapperClasses = () => {
|
||||
const openState = controlled ? open : isOpen;
|
||||
|
||||
return cn(
|
||||
'dropdown',
|
||||
{
|
||||
'dropdown-start': align === 'start',
|
||||
'dropdown-center': align === 'center',
|
||||
'dropdown-end': align === 'end',
|
||||
'dropdown-top': direction === 'top',
|
||||
'dropdown-bottom': direction === 'bottom',
|
||||
'dropdown-left': direction === 'left',
|
||||
'dropdown-right': direction === 'right',
|
||||
'dropdown-hover': hover,
|
||||
'dropdown-open': openState && !close,
|
||||
'dropdown-close': close,
|
||||
},
|
||||
className?.wrapper
|
||||
);
|
||||
};
|
||||
|
||||
const getTriggerClasses = () => {
|
||||
return cn(className?.trigger);
|
||||
};
|
||||
|
||||
const getContentClasses = () => {
|
||||
return cn(
|
||||
'dropdown-content z-[9999] shadow-sm bg-base-100 rounded-box',
|
||||
className?.content
|
||||
);
|
||||
};
|
||||
|
||||
if (controlled) {
|
||||
return (
|
||||
<div className={getWrapperClasses()}>
|
||||
{trigger}
|
||||
{open && !close && (
|
||||
<div tabIndex={-1} className={getContentClasses()}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={getWrapperClasses()}>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
className={getTriggerClasses()}
|
||||
onClick={toggleDropdown}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
{!close && (
|
||||
<div tabIndex={-1} className={getContentClasses()}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
@@ -5,6 +5,8 @@ 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';
|
||||
@@ -13,6 +15,7 @@ type FloatingActionsButtonProps = {
|
||||
onClick?: () => void;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
permissions?: string | string[];
|
||||
}[];
|
||||
approvals: {
|
||||
action: 'APPROVED' | 'REJECTED';
|
||||
@@ -20,6 +23,7 @@ type FloatingActionsButtonProps = {
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
permissions?: string | string[];
|
||||
}[];
|
||||
selectedRowIds: number[];
|
||||
onClose: () => void;
|
||||
@@ -31,9 +35,12 @@ const FloatingActionsButton = ({
|
||||
selectedRowIds,
|
||||
onClose,
|
||||
}: FloatingActionsButtonProps) => {
|
||||
const { permissionCheck } = useAuth();
|
||||
// Jika tidak ada baris yang dipilih, jangan tampilkan FAB
|
||||
const positionStyles =
|
||||
selectedRowIds.length > 0 ? 'bottom-[10%]' : 'bottom-[-100%]';
|
||||
selectedRowIds.length > 0
|
||||
? 'bottom-[10%] opacity-100'
|
||||
: 'bottom-[-10%] opacity-0';
|
||||
|
||||
// Helper untuk menentukan gaya warna tombol approval
|
||||
const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => {
|
||||
@@ -69,7 +76,18 @@ const FloatingActionsButton = ({
|
||||
<div className='flex gap-4 items-center'>
|
||||
{/* Render Aksi dari props.actions */}
|
||||
{actions
|
||||
.filter((action) => !action.hidden)
|
||||
.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
|
||||
@@ -109,29 +127,41 @@ const FloatingActionsButton = ({
|
||||
|
||||
{/* === BARIS BAWAH: Approval Buttons (Approve/Reject) === */}
|
||||
<div className={`grid grid-cols-${approvals.length} gap-3`}>
|
||||
{approvals.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>
|
||||
))}
|
||||
{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>
|
||||
|
||||
@@ -9,10 +9,13 @@ import Drawer from '@/components/Drawer';
|
||||
import Navbar from '@/components/Navbar';
|
||||
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 { isPathActive } from '@/lib/helper';
|
||||
import { ROUTE_PERMISSIONS } from '@/config/route-permission';
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
|
||||
const MainDrawerContent = () => {
|
||||
const pathname = usePathname();
|
||||
@@ -62,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 = '';
|
||||
@@ -101,6 +109,10 @@ const MainDrawer = ({
|
||||
setMainDrawerOpen(!mainDrawerOpen);
|
||||
};
|
||||
|
||||
if (!isPermitted) {
|
||||
return <PermissionNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={mainDrawerOpen}
|
||||
|
||||
@@ -7,7 +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/Dropdown';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { AuthApi } from '@/services/api/auth';
|
||||
@@ -54,7 +54,8 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Dropdown
|
||||
position='bottom-end'
|
||||
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'>
|
||||
@@ -62,9 +63,11 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
contentClassName='w-52 mt-3'
|
||||
className={{
|
||||
content: 'w-52 mt-3',
|
||||
}}
|
||||
>
|
||||
<Menu className='p-2 bg-base-100 shadow rounded-box menu-sm'>
|
||||
<Menu>
|
||||
<MenuItem title='Logout' onClick={logoutClickHandler} />
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
|
||||
+35
-17
@@ -60,6 +60,12 @@ export interface TableProps<TData extends object> {
|
||||
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 = [{}, {}, {}, {}, {}];
|
||||
@@ -112,6 +118,7 @@ const Table = <TData extends object>({
|
||||
renderFooter = false,
|
||||
withCheckbox = false,
|
||||
rowOptions = [10, 20, 50, 100],
|
||||
renderCustomRow,
|
||||
}: TableProps<TData>) => {
|
||||
const isServerSideTable =
|
||||
totalItems !== undefined &&
|
||||
@@ -305,24 +312,35 @@ const Table = <TData extends object>({
|
||||
</thead>
|
||||
|
||||
<tbody className={tableClassNames.tableBodyClassName}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<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())}
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const customRowContent = renderCustomRow?.(row);
|
||||
|
||||
{isLoading && <div className='skeleton w-full h-4' />}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
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 && (
|
||||
|
||||
+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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,111 +1,109 @@
|
||||
'use client';
|
||||
import React, { ReactNode, useState, useRef } from 'react';
|
||||
|
||||
import { ReactNode, useRef, useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
interface DropdownProps {
|
||||
export interface DropdownProps {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
position?:
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
| 'right-start'
|
||||
| 'right-end';
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
trigger?: string;
|
||||
content?: string;
|
||||
};
|
||||
align?: 'start' | 'center' | 'end';
|
||||
direction?: 'top' | 'bottom' | 'left' | 'right';
|
||||
hover?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
close?: boolean;
|
||||
controlled?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown = ({
|
||||
trigger,
|
||||
children,
|
||||
position = 'bottom',
|
||||
align = 'start',
|
||||
hover = false,
|
||||
className,
|
||||
contentClassName,
|
||||
align,
|
||||
direction,
|
||||
hover,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
close,
|
||||
controlled = false,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
const toggleDropdown = () => {
|
||||
if (!controlled) {
|
||||
const newState = !isOpen;
|
||||
setIsOpen(newState);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Build position classes
|
||||
const getPositionClasses = () => {
|
||||
const classes: string[] = [];
|
||||
|
||||
// Handle combined positions like 'top-start'
|
||||
if (position.includes('-')) {
|
||||
const [pos, al] = position.split('-');
|
||||
classes.push(`dropdown-${pos}`);
|
||||
classes.push(`dropdown-${al}`);
|
||||
} else {
|
||||
classes.push(`dropdown-${position}`);
|
||||
if (align !== 'start') {
|
||||
classes.push(`dropdown-${align}`);
|
||||
}
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// alert('clicked');
|
||||
setIsOpen(!isOpen);
|
||||
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={cn(
|
||||
'dropdown',
|
||||
getPositionClasses(),
|
||||
hover && 'dropdown-hover',
|
||||
isOpen && 'dropdown-open',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Trigger Button */}
|
||||
<div onClick={handleToggle} className='cursor-pointer'>
|
||||
<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>
|
||||
|
||||
{/* Dropdown Content - Only render when open */}
|
||||
{isOpen && (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={cn('dropdown-content z-[10]', contentClassName)}
|
||||
onClick={() => setIsOpen(false)} // Close on item click
|
||||
>
|
||||
{!close && (
|
||||
<div tabIndex={-1} className={getContentClasses()}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -5,6 +5,7 @@ import useSWR from 'swr';
|
||||
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||
import { AuthApi } from '@/services/api/auth';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
|
||||
import { AxiosError } from 'axios';
|
||||
@@ -27,6 +28,9 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
SWRHttpKey
|
||||
>('/sso/userinfo', httpClientFetcher, {
|
||||
shouldRetryOnError: false,
|
||||
|
||||
// refresh every 12 minutes
|
||||
refreshInterval: 12 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,6 +56,25 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
setIsLoadingUser(isLoadingUserResponse);
|
||||
}, [isLoadingUserResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(
|
||||
async () => {
|
||||
await AuthApi.refresh();
|
||||
},
|
||||
12 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshUserSession = async () => {
|
||||
await AuthApi.refresh();
|
||||
};
|
||||
|
||||
refreshUserSession();
|
||||
}, []);
|
||||
|
||||
if (
|
||||
(isLoadingUserResponse && !userResponse && !userErrorResponse) ||
|
||||
(!userResponse && !userErrorResponse)
|
||||
@@ -63,7 +86,7 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (userErrorResponse) {
|
||||
if (!isLoadingUserResponse && userErrorResponse) {
|
||||
return (
|
||||
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
|
||||
<h2 className='text-2xl font-bold text-error'>Authentication Failed</h2>
|
||||
@@ -71,10 +94,7 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
||||
Please try refreshing the page or contact support if the problem
|
||||
persists.
|
||||
</p>
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<button className='btn btn-primary' onClick={() => redirectToSSO()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -8,6 +8,7 @@ interface MenuItemProps {
|
||||
href?: string;
|
||||
icon?: string;
|
||||
active?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -17,6 +18,7 @@ const MenuItem = ({
|
||||
href,
|
||||
icon,
|
||||
active = false,
|
||||
isLoading = false,
|
||||
className,
|
||||
onClick,
|
||||
}: MenuItemProps) => {
|
||||
@@ -50,17 +52,28 @@ const MenuItem = ({
|
||||
|
||||
return (
|
||||
<li>
|
||||
{href && (
|
||||
{!isLoading && href && (
|
||||
<Link href={href} className={menuItemBaseClassName}>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!href && (
|
||||
{!isLoading && !href && (
|
||||
<button className={menuItemBaseClassName} onClick={onClick}>
|
||||
{menuItemContent}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<button className={menuItemBaseClassName}>
|
||||
<span
|
||||
className={cn('loading loading-dots loading-md mx-auto', {
|
||||
'text-gray-400': !active,
|
||||
'text-black': active,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,13 @@ import Button, { ButtonProps } from '@/components/Button';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export type IconPosition = 'left' | 'center' | 'right';
|
||||
|
||||
export interface ConfirmationModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
type?: 'info' | 'success' | 'error';
|
||||
text?: string;
|
||||
subtitleText?: string;
|
||||
closeOnBackdrop?: boolean;
|
||||
primaryButton?: ButtonProps & {
|
||||
text?: string;
|
||||
@@ -24,17 +27,84 @@ export interface ConfirmationModalProps {
|
||||
modalBox?: string;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
iconSize?: number;
|
||||
iconPosition?: IconPosition;
|
||||
}
|
||||
|
||||
const iconConfig = {
|
||||
info: {
|
||||
icon: 'material-symbols:info-outline-rounded',
|
||||
iconClassName: 'text-info-content',
|
||||
bgClassName: 'bg-info',
|
||||
outerRingClassName: 'bg-info/20',
|
||||
borderClassName: 'border-info',
|
||||
},
|
||||
success: {
|
||||
icon: 'heroicons:check',
|
||||
iconClassName: 'text-white',
|
||||
bgClassName: 'bg-[#00D390]',
|
||||
outerRingClassName: 'bg-[#00D3901F]',
|
||||
borderClassName: 'border-[#CCF7EB]',
|
||||
},
|
||||
error: {
|
||||
icon: 'solar:danger-triangle-linear',
|
||||
iconClassName: 'text-error-content',
|
||||
bgClassName: 'bg-[#f03338]',
|
||||
outerRingClassName: 'bg-[#f3cdcd]',
|
||||
borderClassName: 'border-[#fff0ef]',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ConfirmationModalIcon = ({
|
||||
type,
|
||||
size = 24,
|
||||
}: {
|
||||
type: 'info' | 'success' | 'error';
|
||||
size?: number;
|
||||
}) => {
|
||||
const config = iconConfig[type];
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center p-2'>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full border-4 p-1',
|
||||
config.outerRingClassName,
|
||||
config.borderClassName
|
||||
)}
|
||||
>
|
||||
<div className={cn('rounded-full p-1', config.outerRingClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full p-3 flex items-center justify-center',
|
||||
config.bgClassName
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={config.icon}
|
||||
width={size}
|
||||
height={size}
|
||||
className={config.iconClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmationModal = ({
|
||||
ref,
|
||||
type = 'info',
|
||||
text,
|
||||
subtitleText,
|
||||
closeOnBackdrop,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
className,
|
||||
children,
|
||||
iconSize = 32,
|
||||
iconPosition = 'center',
|
||||
}: ConfirmationModalProps) => {
|
||||
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
|
||||
|
||||
@@ -55,47 +125,44 @@ const ConfirmationModal = ({
|
||||
return (
|
||||
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
<div
|
||||
className={cn(
|
||||
'w-fit p-4 mx-auto flex flex-row justify-center items-center rounded-full',
|
||||
{
|
||||
'bg-error': type === 'error',
|
||||
'bg-info': type === 'info',
|
||||
'bg-success': type === 'success',
|
||||
}
|
||||
)}
|
||||
>
|
||||
{type === 'info' && (
|
||||
<Icon
|
||||
icon='material-symbols:info-outline-rounded'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-info-content'
|
||||
/>
|
||||
)}
|
||||
{iconPosition === 'center' ? (
|
||||
<>
|
||||
<div className='w-fit mx-auto'>
|
||||
<ConfirmationModalIcon type={type} size={iconSize} />
|
||||
</div>
|
||||
|
||||
{type === 'success' && (
|
||||
<Icon
|
||||
icon='qlementine-icons:success-12'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-success-content'
|
||||
/>
|
||||
)}
|
||||
<p className='text-center font-medium'>
|
||||
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||
</p>
|
||||
|
||||
{type === 'error' && (
|
||||
<Icon
|
||||
icon='solar:danger-triangle-linear'
|
||||
width={64}
|
||||
height={64}
|
||||
className='text-error-content'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{subtitleText && (
|
||||
<p className='text-center text-sm text-gray-400'>
|
||||
{subtitleText}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn('flex flex-row items-center gap-4', {
|
||||
'flex-row': iconPosition === 'left',
|
||||
'flex-row-reverse': iconPosition === 'right',
|
||||
})}
|
||||
>
|
||||
<div className='w-fit'>
|
||||
<ConfirmationModalIcon type={type} size={iconSize} />
|
||||
</div>
|
||||
|
||||
<p className='text-center font-medium'>
|
||||
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||
</p>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<p className='font-medium'>
|
||||
{text ?? 'Apakah anda yakin ingin melakukan hal ini?'}
|
||||
</p>
|
||||
|
||||
{subtitleText && (
|
||||
<p className='text-sm text-gray-400'>{subtitleText}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className='w-full'>{children}</div>}
|
||||
|
||||
@@ -103,7 +170,7 @@ const ConfirmationModal = ({
|
||||
{secondaryButton && secondaryButton.text && (
|
||||
<Button
|
||||
{...secondaryButton}
|
||||
variant='ghost'
|
||||
variant='outline'
|
||||
color={secondaryButton?.color}
|
||||
isLoading={secondaryButton?.isLoading}
|
||||
disabled={
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -9,6 +10,7 @@ export interface SidebarMenuItem {
|
||||
link: string;
|
||||
icon?: string;
|
||||
submenu?: SidebarMenuItem[];
|
||||
permission?: string[];
|
||||
}
|
||||
|
||||
interface SidebarMenuItemProps {
|
||||
@@ -22,8 +24,17 @@ interface SidebarMenuProps {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -78,13 +89,15 @@ const SidebarMenuItem = ({ item, activeLink }: SidebarMenuItemProps) => {
|
||||
const SidebarMenu = ({ menu, activeLink }: SidebarMenuProps) => {
|
||||
return (
|
||||
<Menu>
|
||||
{menu.map((menuItem, menuIdx) => (
|
||||
<SidebarMenuItem
|
||||
key={menuIdx}
|
||||
item={menuItem}
|
||||
activeLink={activeLink}
|
||||
/>
|
||||
))}
|
||||
{menu.map((menuItem, menuIdx) => {
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={menuIdx}
|
||||
item={menuItem}
|
||||
activeLink={activeLink}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,26 +6,32 @@ 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 ClosingSapronakTabContent from './ClosingSapronakTabContent';
|
||||
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
|
||||
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
|
||||
import SalesReportTable from './sale/SalesReportTable';
|
||||
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');
|
||||
|
||||
@@ -54,17 +60,17 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||
{
|
||||
id: 'hppEkspedisi',
|
||||
label: 'HPP Ekspedisi',
|
||||
content: 'HPP Ekspedisi',
|
||||
content: <HppExpeditionReportTable initialValues={hppExpeditionData} />,
|
||||
},
|
||||
{
|
||||
id: 'dataProduksi',
|
||||
label: 'Data Produksi',
|
||||
content: 'Data Produksi',
|
||||
content: <ClosingProductionDataTabContent projectFlockId={id} />,
|
||||
},
|
||||
{
|
||||
id: 'keuangan',
|
||||
label: 'Keuangan',
|
||||
content: 'Keuangan',
|
||||
content: <ClosingFinanceTabContent projectFlockId={id} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -25,9 +25,9 @@ const ClosingGeneralInformationTable = ({
|
||||
<td>{initialValue?.period}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kategori</td>
|
||||
<td>Project Flock</td>
|
||||
<td>:</td>
|
||||
<td>{initialValue?.project_category}</td>
|
||||
<td>{initialValue?.project_flock?.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Populasi</td>
|
||||
|
||||
@@ -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;
|
||||
@@ -3,7 +3,7 @@
|
||||
import Card from '@/components/Card';
|
||||
|
||||
import Table from '@/components/Table';
|
||||
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import {
|
||||
RowSapronakCalculation,
|
||||
TotalSapronakCalculation,
|
||||
@@ -37,101 +37,121 @@ const ClosingSapronakCalculationTable = ({
|
||||
): ColumnDef<RowSapronakCalculation>[] => [
|
||||
{
|
||||
header: 'Tanggal',
|
||||
accessorKey: 'tanggal',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
accessorKey: 'date',
|
||||
cell: (props) =>
|
||||
props.row.original.date
|
||||
? formatDate(props.row.original.date, 'DD MMM YYYY')
|
||||
: '-',
|
||||
footer: 'Total',
|
||||
},
|
||||
{
|
||||
header: 'No. Referensi',
|
||||
accessorKey: 'no_referensi',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
accessorKey: 'reference_number',
|
||||
cell: (props) => (props.row.original.reference_number as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
header: 'QTY Masuk',
|
||||
accessorKey: 'qty_masuk',
|
||||
cell: (props) => formatNumber(props.getValue() as number),
|
||||
accessorKey: 'qty_in',
|
||||
cell: (props) =>
|
||||
props.row.original.qty_in
|
||||
? formatNumber(props.row.original.qty_in as number)
|
||||
: '-',
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatNumber(total.qty_masuk)}
|
||||
{total?.qty_in ? formatNumber(total?.qty_in) : '-'}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'QTY Keluar',
|
||||
accessorKey: 'qty_keluar',
|
||||
cell: (props) => formatNumber(props.getValue() as number),
|
||||
accessorKey: 'qty_out',
|
||||
cell: (props) =>
|
||||
props.row.original.qty_out
|
||||
? formatNumber(props.row.original.qty_out as number)
|
||||
: '-',
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatNumber(total.qty_keluar)}
|
||||
{total?.qty_out ? formatNumber(total?.qty_out) : '-'}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'QTY Pakai',
|
||||
accessorKey: 'qty_pakai',
|
||||
cell: (props) => formatNumber(props.getValue() as number),
|
||||
accessorKey: 'qty_used',
|
||||
cell: (props) =>
|
||||
props.row.original.qty_used
|
||||
? formatNumber(props.row.original.qty_used as number)
|
||||
: '-',
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatNumber(total.qty_pakai)}
|
||||
{total?.qty_used ? formatNumber(total?.qty_used) : '-'}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'Uraian',
|
||||
accessorKey: 'uraian',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
accessorKey: 'description',
|
||||
cell: (props) => (props.row.original.description as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
header: 'Kategori Produk',
|
||||
accessorKey: 'kategori_produk',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
accessorKey: 'product_category',
|
||||
cell: (props) => (props.row.original.product_category as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
header: 'Harga Beli/Qty (Rp)',
|
||||
accessorKey: 'harga_beli_per_qty',
|
||||
cell: (props) => formatCurrency(props.getValue() as number),
|
||||
accessorKey: 'unit_price',
|
||||
cell: (props) =>
|
||||
props.row.original.unit_price
|
||||
? formatCurrency(props.row.original.unit_price as number)
|
||||
: '-',
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatCurrency(total.harga_beli_per_qty)}
|
||||
{total?.avg_unit_price
|
||||
? formatCurrency(total?.avg_unit_price)
|
||||
: '-'}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'Total Harga (Rp)',
|
||||
accessorKey: 'total_harga',
|
||||
cell: (props) => formatCurrency(props.getValue() as number),
|
||||
accessorKey: 'total_amount',
|
||||
cell: (props) =>
|
||||
props.row.original.total_amount
|
||||
? formatCurrency(props.row.original.total_amount as number)
|
||||
: '-',
|
||||
footer: total
|
||||
? () => (
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{formatCurrency(total.total_harga)}
|
||||
{total?.total_amount ? formatCurrency(total?.total_amount) : '-'}
|
||||
</div>
|
||||
)
|
||||
: '',
|
||||
},
|
||||
{
|
||||
header: 'Keterangan',
|
||||
accessorKey: 'keterangan',
|
||||
cell: (props) => (props.getValue() as string) || '-',
|
||||
accessorKey: 'notes',
|
||||
cell: (props) => (props.row.original.notes as string) || '-',
|
||||
footer: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Memoize columns untuk setiap kategori
|
||||
const docBroilerColumns = useMemo(
|
||||
const docColumns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? createColumns(sapronakCalculation.data?.doc_broiler.total)
|
||||
? createColumns(sapronakCalculation.data?.doc?.total)
|
||||
: createColumns(),
|
||||
[sapronakCalculation]
|
||||
);
|
||||
@@ -139,7 +159,7 @@ const ClosingSapronakCalculationTable = ({
|
||||
const ovkColumns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? createColumns(sapronakCalculation.data?.ovk.total)
|
||||
? createColumns(sapronakCalculation.data?.ovk?.total)
|
||||
: createColumns(),
|
||||
[sapronakCalculation]
|
||||
);
|
||||
@@ -147,73 +167,112 @@ const ClosingSapronakCalculationTable = ({
|
||||
const pakanColumns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? createColumns(sapronakCalculation.data?.pakan.total)
|
||||
? createColumns(sapronakCalculation.data?.pakan?.total)
|
||||
: createColumns(),
|
||||
[sapronakCalculation]
|
||||
);
|
||||
|
||||
const pulletColumns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? createColumns(sapronakCalculation.data?.pullet?.total)
|
||||
: createColumns(),
|
||||
[sapronakCalculation]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{isResponseSuccess(sapronakCalculation) && (
|
||||
<>
|
||||
<Card
|
||||
title='DOC Broiler'
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Table<RowSapronakCalculation>
|
||||
data={sapronakCalculation.data?.doc_broiler.rows ?? []}
|
||||
columns={docBroilerColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter
|
||||
/>
|
||||
</Card>
|
||||
<Card
|
||||
title='DOC'
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Table<RowSapronakCalculation>
|
||||
data={
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? (sapronakCalculation.data?.doc?.rows ?? [])
|
||||
: []
|
||||
}
|
||||
columns={docColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter={isResponseSuccess(sapronakCalculation)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title='OVK'
|
||||
variant='bordered'
|
||||
collapsible
|
||||
defaultCollapsed={true}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<Table<RowSapronakCalculation>
|
||||
data={sapronakCalculation.data?.ovk.rows ?? []}
|
||||
columns={ovkColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter
|
||||
/>
|
||||
</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={sapronakCalculation.data?.pakan.rows ?? []}
|
||||
columns={pakanColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Card
|
||||
title='Pullet'
|
||||
variant='bordered'
|
||||
collapsible
|
||||
defaultCollapsed={true}
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<Table<RowSapronakCalculation>
|
||||
data={
|
||||
isResponseSuccess(sapronakCalculation)
|
||||
? (sapronakCalculation.data?.pullet?.rows ?? [])
|
||||
: []
|
||||
}
|
||||
columns={pulletColumns}
|
||||
className={{
|
||||
containerClassName: 'my-4',
|
||||
}}
|
||||
renderFooter={isResponseSuccess(sapronakCalculation)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import 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';
|
||||
@@ -43,17 +45,18 @@ const RowOptionsMenu = ({
|
||||
}) => {
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
{/* TODO: apply RBAC */}
|
||||
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
|
||||
<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 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>
|
||||
);
|
||||
@@ -123,28 +126,6 @@ const ClosingsTable = () => {
|
||||
accessorKey: 'shed_label',
|
||||
header: 'Jumlah Kandang',
|
||||
},
|
||||
{
|
||||
accessorKey: 'sales_paid_amount',
|
||||
header: 'Jumlah Sudah Bayar',
|
||||
cell: (props) => (
|
||||
<span className='text-success'>
|
||||
{formatCurrency(props.row.original.sales_paid_amount)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'sales_remaining_amount',
|
||||
header: 'Jumlah Sisa Bayar',
|
||||
cell: (props) => (
|
||||
<span className='text-error'>
|
||||
{formatCurrency(props.row.original.sales_remaining_amount)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'sales_payment_status',
|
||||
header: 'Status Pembayaran',
|
||||
},
|
||||
{
|
||||
accessorKey: 'project_status',
|
||||
header: 'Status',
|
||||
|
||||
@@ -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,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.price)
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -269,7 +281,7 @@ const ExpenseRealizationContent = ({
|
||||
let expenseGrandTotal = 0;
|
||||
|
||||
kandangExpense.realisasi?.forEach(
|
||||
(item) => (expenseGrandTotal += item.price)
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
UploadRequestDocumentsFormSchema,
|
||||
UploadRequestDocumentsFormValues,
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
|
||||
@@ -255,100 +256,119 @@ const ExpenseRequestContent = ({
|
||||
|
||||
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
{isCurrentApprovalOnManager && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
onClick={approveClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
||||
Approve Manager
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.approve.manager'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
onClick={approveClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
||||
Approve Manager
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{isCurrentApprovalOnFinance && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={approveClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='tdesign:money' width={24} height={24} />
|
||||
Approve Finance
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.approve.finance'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={approveClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='tdesign:money' width={24} height={24} />
|
||||
Approve Finance
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{isCurrentApprovalOnRealization && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={completeExpenseClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:done-all-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Selesai
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.complete.expense'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={completeExpenseClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:done-all-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Selesai
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{showRejectButton && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='error'
|
||||
onClick={rejectClickHandler}
|
||||
className='w-full:w-fit'
|
||||
<RequirePermission
|
||||
permissions={[
|
||||
'lti.expense.approve.manager',
|
||||
'lti.expense.approve.finance',
|
||||
]}
|
||||
>
|
||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='error'
|
||||
onClick={rejectClickHandler}
|
||||
className='w-full:w-fit'
|
||||
>
|
||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{isExpenseCanBeRealized && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
href={`/expense/realization/?expenseId=${initialValues?.id}`}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:money-bag-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Realisasi
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
href={`/expense/realization/?expenseId=${initialValues?.id}`}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:money-bag-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Realisasi
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
||||
{showEditButton && (
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
Edit
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={deleteExpenseClickHandler}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.delete'>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={deleteExpenseClickHandler}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,9 +408,13 @@ const ExpenseRequestContent = ({
|
||||
<th>Kandang</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
{initialValues?.kandangs
|
||||
.map((item) => item.name)
|
||||
.join(', ')}
|
||||
{initialValues?.kandangs &&
|
||||
initialValues?.kandangs.some((k) => k.name)
|
||||
? initialValues?.kandangs
|
||||
.filter((item) => item.name)
|
||||
.map((item) => item.name)
|
||||
.join(', ')
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -428,7 +452,14 @@ const ExpenseRequestContent = ({
|
||||
<tr>
|
||||
<th>Nominal Biaya</th>
|
||||
<th>:</th>
|
||||
<td>{formatCurrency(initialValues?.grand_total ?? 0)}</td>
|
||||
<td>
|
||||
{formatCurrency(
|
||||
initialValues?.latest_approval.step_number === 4 ||
|
||||
initialValues?.latest_approval.step_number === 5
|
||||
? (initialValues?.total_realisasi ?? 0)
|
||||
: (initialValues?.total_pengajuan ?? 0)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status Pencairan</th>
|
||||
@@ -462,59 +493,73 @@ const ExpenseRequestContent = ({
|
||||
initialValues?.documents.length > 0 && (
|
||||
<ul className='list-disc'>
|
||||
{initialValues?.documents.map(
|
||||
(requestDocument, requestDocumentIdx) => (
|
||||
<li key={requestDocumentIdx}>
|
||||
<Link
|
||||
href={requestDocument.path}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{requestDocument.path}{' '}
|
||||
<Icon
|
||||
icon='cuida:open-in-new-tab-outline'
|
||||
width={12}
|
||||
height={12}
|
||||
className='inline'
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
(requestDocument, requestDocumentIdx) => {
|
||||
const path = requestDocument.path.startsWith(
|
||||
'/'
|
||||
)
|
||||
? requestDocument.path.slice(1)
|
||||
: requestDocument.path;
|
||||
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
|
||||
return (
|
||||
<li key={requestDocumentIdx}>
|
||||
<Link
|
||||
href={documentUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{requestDocument.path}{' '}
|
||||
<Icon
|
||||
icon='cuida:open-in-new-tab-outline'
|
||||
width={12}
|
||||
height={12}
|
||||
className='inline'
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<DropFileInput
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={requestDocumentsChangeHandler}
|
||||
onDelete={requestDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
maxFiles={10}
|
||||
className={{
|
||||
wrapper: 'mt-2',
|
||||
inputWrapper: 'flex items-center',
|
||||
}}
|
||||
/>
|
||||
<RequirePermission permissions='lti.expense.document'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<DropFileInput
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={requestDocumentsChangeHandler}
|
||||
onDelete={requestDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
maxFiles={10}
|
||||
className={{
|
||||
wrapper: 'mt-2',
|
||||
inputWrapper: 'flex items-center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{formik.values.documents &&
|
||||
formik.values.documents.length > 0 && (
|
||||
<Button
|
||||
onClick={formik.submitForm}
|
||||
disabled={formik.isSubmitting}
|
||||
isLoading={formik.isSubmitting}
|
||||
className='w-fit self-end'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
@@ -532,7 +577,7 @@ const ExpenseRequestContent = ({
|
||||
let expenseGrandTotal = 0;
|
||||
|
||||
kandangExpense.pengajuans?.forEach(
|
||||
(item) => (expenseGrandTotal += item.price)
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -547,7 +592,9 @@ const ExpenseRequestContent = ({
|
||||
colSpan={5}
|
||||
className='font-bold text-center text-base-content text-lg'
|
||||
>
|
||||
Biaya {kandangExpense.name}
|
||||
{kandangExpense.kandang_id && kandangExpense.name
|
||||
? `Biaya ${kandangExpense.name}`
|
||||
: `Biaya ${initialValues?.location.name || 'Umum'}`}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -28,6 +28,7 @@ import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
@@ -67,58 +68,70 @@ const RowOptionsMenu = ({
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
|
||||
<Button
|
||||
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.detail'>
|
||||
<Button
|
||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{showRealizationButton && (
|
||||
<Button
|
||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='info'
|
||||
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:money-bag-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Realisasi
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
<Button
|
||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='info'
|
||||
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:money-bag-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Realisasi
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
@@ -559,57 +572,70 @@ const ExpensesTable = () => {
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'>
|
||||
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
|
||||
<Button
|
||||
href='/expense/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.create'>
|
||||
<Button
|
||||
href='/expense/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{selectedRowIds.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnManager}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
||||
Approve Manager
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.approve.manager'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnManager}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
||||
Approve Manager
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='tdesign:money' width={24} height={24} />
|
||||
Approve Finance
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.approve.finance'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={bulkApproveClickHandler}
|
||||
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='tdesign:money' width={24} height={24} />
|
||||
Approve Finance
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
color='error'
|
||||
onClick={bulkRejectClickHandler}
|
||||
disabled={
|
||||
!isAllSelectedRowLatestApprovalOnManager &&
|
||||
!isAllSelectedRowLatestApprovalOnFinance
|
||||
}
|
||||
className='w-full sm:w-fit'
|
||||
<RequirePermission
|
||||
permissions={[
|
||||
'lti.expense.approve.manager',
|
||||
'lti.expense.approve.finance',
|
||||
]}
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:close'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='error'
|
||||
onClick={bulkRejectClickHandler}
|
||||
disabled={
|
||||
!isAllSelectedRowLatestApprovalOnManager &&
|
||||
!isAllSelectedRowLatestApprovalOnFinance
|
||||
}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:close'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,10 +20,10 @@ interface ExpenseKandangsTableProps {
|
||||
locationId?: number;
|
||||
type: 'add' | 'edit' | 'detail';
|
||||
selectedKandangs: {
|
||||
id: number;
|
||||
name: string;
|
||||
id?: number;
|
||||
name?: string;
|
||||
}[];
|
||||
onChange: (kandangs: { id: number; name: string }[]) => void;
|
||||
onChange: (kandangs: { id?: number; name?: string }[]) => void;
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
};
|
||||
@@ -67,7 +67,11 @@ const ExpenseKandangsTable = ({
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
|
||||
convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id))
|
||||
convertRowSelectionArrToObj(
|
||||
selectedKandangs
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is number => id !== undefined)
|
||||
)
|
||||
);
|
||||
|
||||
const kandangsColumns: ColumnDef<Kandang>[] = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Yup from 'yup';
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
|
||||
type ExpenseRealizationFormSchemaType = {
|
||||
category?: {
|
||||
@@ -12,7 +13,7 @@ type ExpenseRealizationFormSchemaType = {
|
||||
label: string;
|
||||
};
|
||||
realization_date?: string;
|
||||
kandangs?: { id: number; name: string }[];
|
||||
kandangs?: { id?: number; name?: string }[];
|
||||
supplier?: {
|
||||
value: number;
|
||||
label: string;
|
||||
@@ -20,7 +21,7 @@ type ExpenseRealizationFormSchemaType = {
|
||||
existing_documents?: { name: string; url: string }[];
|
||||
documents?: File[];
|
||||
realizations: {
|
||||
kandang_id: number;
|
||||
kandang_id?: number;
|
||||
cost_items: {
|
||||
nonstock?: {
|
||||
value: number;
|
||||
@@ -49,12 +50,11 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
|
||||
kandangs: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
id: Yup.number().required('Kandang wajib dipilih!'),
|
||||
name: Yup.string().required('Kandang wajib dipilih!'),
|
||||
id: Yup.number().optional(),
|
||||
name: Yup.string().optional(),
|
||||
})
|
||||
)
|
||||
.min(1, 'Kandang wajib dipilih!')
|
||||
.required('Kandang wajib dipilih!'),
|
||||
.optional(),
|
||||
|
||||
supplier: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
@@ -73,7 +73,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
|
||||
realizations: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
|
||||
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(),
|
||||
cost_items: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
@@ -86,12 +86,12 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
|
||||
notes: Yup.string(),
|
||||
})
|
||||
)
|
||||
.min(1, 'Kandang harus memiliki setidaknya 1 biaya!')
|
||||
.required('Biaya kandang wajib diisi!'),
|
||||
.min(1, 'Harus memiliki setidaknya 1 biaya!')
|
||||
.required('Biaya wajib diisi!'),
|
||||
})
|
||||
)
|
||||
.min(1, 'Biaya kandang wajib diisi!')
|
||||
.required('Biaya kandang wajib diisi!'),
|
||||
.min(1, 'Biaya wajib diisi!')
|
||||
.required('Biaya wajib diisi!'),
|
||||
});
|
||||
|
||||
export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema;
|
||||
@@ -139,10 +139,13 @@ export const getExpenseRealizationFormInitialValues = (
|
||||
label: initialValues.supplier.name,
|
||||
}
|
||||
: undefined,
|
||||
existing_documents: initialValues?.realization_docs?.map((doc) => ({
|
||||
name: doc.path,
|
||||
url: doc.path,
|
||||
})),
|
||||
existing_documents: initialValues?.realization_docs?.map((doc) => {
|
||||
const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
|
||||
return {
|
||||
name: doc.path,
|
||||
url: `${S3_PUBLIC_BASE_URL}/${path}`,
|
||||
};
|
||||
}),
|
||||
documents: [],
|
||||
realizations: initialValues?.kandangs
|
||||
? initialValues.kandangs.map((kandangExpense) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import DateInput from '@/components/input/DateInput';
|
||||
import DropFileInput from '@/components/input/DropFileInput';
|
||||
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
|
||||
import ExpenseRealizationKandangDetailExpense from '@/components/pages/expense/form/ExpenseRealizationKandangDetailExpense';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
import {
|
||||
CreateExpenseRealizationPayload,
|
||||
@@ -149,25 +150,10 @@ const ExpenseRealizationForm = ({
|
||||
formik.setFieldValue('location', val);
|
||||
|
||||
formik.setFieldValue('kandangs', []);
|
||||
formik.setFieldValue('realizations', []);
|
||||
};
|
||||
|
||||
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
|
||||
formik.setFieldTouched('kandangs', true);
|
||||
formik.setFieldValue('kandangs', kandangs);
|
||||
|
||||
const newRealizations = [...(formik.values.realizations ?? [])];
|
||||
|
||||
// add new realizations
|
||||
kandangs.forEach((kandangItem) => {
|
||||
const isKandangExistInRealization = newRealizations.find(
|
||||
(realizationItem) => realizationItem.kandang_id === kandangItem.id
|
||||
);
|
||||
|
||||
if (isKandangExistInRealization) return;
|
||||
|
||||
newRealizations.push({
|
||||
kandang_id: kandangItem.id,
|
||||
// Auto-create realization item for location (without kandang)
|
||||
formik.setFieldValue('realizations', [
|
||||
{
|
||||
cost_items: [
|
||||
{
|
||||
nonstock: undefined,
|
||||
@@ -176,25 +162,57 @@ const ExpenseRealizationForm = ({
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const kandangsChangeHandler = (
|
||||
kandangs: { id?: number; name?: string }[]
|
||||
) => {
|
||||
formik.setFieldTouched('kandangs', true);
|
||||
formik.setFieldValue('kandangs', kandangs);
|
||||
|
||||
// If no kandangs selected, create realization item for location
|
||||
if (kandangs.length === 0) {
|
||||
formik.setFieldValue('realizations', [
|
||||
{
|
||||
cost_items: [
|
||||
{
|
||||
nonstock: undefined,
|
||||
quantity: undefined,
|
||||
price: undefined,
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start with empty array when kandangs are selected
|
||||
const newRealizations: typeof formik.values.realizations = [];
|
||||
|
||||
// add new realizations for each kandang
|
||||
kandangs.forEach((kandangItem) => {
|
||||
if (!kandangItem.id) return;
|
||||
|
||||
const existingRealization = formik.values.realizations?.find(
|
||||
(realizationItem) => realizationItem.kandang_id === kandangItem.id
|
||||
);
|
||||
|
||||
newRealizations.push({
|
||||
kandang_id: kandangItem.id,
|
||||
cost_items: existingRealization?.cost_items || [
|
||||
{
|
||||
nonstock: undefined,
|
||||
quantity: undefined,
|
||||
price: undefined,
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// prune realizations
|
||||
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
|
||||
const deletedRealizationsIdx: number[] = [];
|
||||
|
||||
newRealizations.forEach((realization, idx) => {
|
||||
const isRealizationValid = kandangIds.has(realization.kandang_id);
|
||||
|
||||
if (!isRealizationValid) {
|
||||
deletedRealizationsIdx.push(idx);
|
||||
}
|
||||
});
|
||||
|
||||
deletedRealizationsIdx.forEach((deletedRealizationIdx) => {
|
||||
newRealizations.splice(deletedRealizationIdx, 1);
|
||||
});
|
||||
|
||||
formik.setFieldValue('realizations', newRealizations);
|
||||
};
|
||||
|
||||
@@ -290,21 +308,23 @@ const ExpenseRealizationForm = ({
|
||||
className={{ wrapper: 'col-span-12' }}
|
||||
/>
|
||||
|
||||
<DropFileInput
|
||||
label='Dokumen Realisasi'
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={realizationDocumentsChangeHandler}
|
||||
onDelete={realizationDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
className={{
|
||||
wrapper: 'col-span-12',
|
||||
inputWrapper: 'h-12 flex items-center',
|
||||
}}
|
||||
/>
|
||||
<RequirePermission permissions='lti.expense.document.realization'>
|
||||
<DropFileInput
|
||||
label='Dokumen Realisasi'
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={realizationDocumentsChangeHandler}
|
||||
onDelete={realizationDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
className={{
|
||||
wrapper: 'col-span-12',
|
||||
inputWrapper: 'h-12 flex items-center',
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
|
||||
{formik.values.existing_documents &&
|
||||
formik.values.existing_documents.length > 0 && (
|
||||
@@ -335,7 +355,10 @@ const ExpenseRealizationForm = ({
|
||||
)}
|
||||
|
||||
<ExpenseRealizationKandangDetailExpense
|
||||
type={type}
|
||||
formik={formik}
|
||||
supplierId={formik.values.supplier?.value as number}
|
||||
location={formik.values.location}
|
||||
className={{
|
||||
wrapper: 'col-span-12',
|
||||
}}
|
||||
@@ -357,20 +380,22 @@ const ExpenseRealizationForm = ({
|
||||
{type !== 'add' && (
|
||||
<div className='flex flex-row justify-start gap-2'>
|
||||
{type !== 'edit' && (
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,11 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
interface ExpenseRealizationKandangDetailExpenseProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
formik: FormikContextType<ExpenseRealizationFormValues>;
|
||||
supplierId?: number;
|
||||
location?: {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
};
|
||||
@@ -25,12 +30,18 @@ interface ExpenseRealizationKandangDetailExpenseProps {
|
||||
|
||||
const ExpenseRealizationKandangDetailExpense: React.FC<
|
||||
ExpenseRealizationKandangDetailExpenseProps
|
||||
> = ({ type, formik, className }) => {
|
||||
> = ({ type, formik, supplierId, location, className }) => {
|
||||
const {
|
||||
setInputValue: setNonstockInputValue,
|
||||
options: nonstockOptions,
|
||||
isLoadingOptions: isLoadingNonstockOptions,
|
||||
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
||||
} = useSelect<Nonstock>(
|
||||
NonstockApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
supplierId ? { supplier_id: String(supplierId) } : undefined
|
||||
);
|
||||
|
||||
const nonstockChangeHandler = (
|
||||
kandangExpenseIdx: number,
|
||||
@@ -82,140 +93,159 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
|
||||
</div>
|
||||
|
||||
<div className='w-full flex flex-col gap-6'>
|
||||
{formik.values.realizations.length === 0 && (
|
||||
{!formik.values.supplier?.value && (
|
||||
<div>
|
||||
<p className='text-sm text-gray-400 text-center'>
|
||||
Pilih kandang terlebih dahulu!
|
||||
Pilih supplier terlebih dahulu!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formik.values.realizations.map((kandangExpense, kandangExpenseIdx) => {
|
||||
const kandangName = formik.values.kandangs?.find(
|
||||
(kandang) => kandang.id === kandangExpense.kandang_id
|
||||
);
|
||||
{formik.values.realizations.length === 0 &&
|
||||
formik.values.supplier?.value && (
|
||||
<div>
|
||||
<p className='text-sm text-gray-400 text-center'>
|
||||
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
kandangName?.name && (
|
||||
<div
|
||||
key={`kandangExpense-${kandangExpenseIdx}`}
|
||||
className='w-full flex flex-col gap-4'
|
||||
>
|
||||
<div>
|
||||
<h5 className='mb-2 text-lg font-bold text-center'>
|
||||
Biaya {kandangName?.name}
|
||||
</h5>
|
||||
{formik.values.realizations.length > 0 &&
|
||||
formik.values.supplier?.value &&
|
||||
formik.values.realizations.map(
|
||||
(kandangExpense, kandangExpenseIdx) => {
|
||||
const kandangName = kandangExpense.kandang_id
|
||||
? formik.values.kandangs?.find(
|
||||
(kandang) => kandang.id === kandangExpense.kandang_id
|
||||
)
|
||||
: null;
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Harga Satuan</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
return (
|
||||
(kandangName?.name || !kandangExpense.kandang_id) && (
|
||||
<div
|
||||
key={`kandangExpense-${kandangExpenseIdx}`}
|
||||
className='w-full flex flex-col gap-4'
|
||||
>
|
||||
<div>
|
||||
<h5 className='mb-2 text-lg font-bold text-center'>
|
||||
{kandangName?.name
|
||||
? `Biaya ${kandangName.name}`
|
||||
: location?.label
|
||||
? `Biaya ${location.label}`
|
||||
: 'Biaya Umum'}
|
||||
</h5>
|
||||
|
||||
<tbody>
|
||||
{kandangExpense.cost_items.map(
|
||||
(expenseItem, expenseIdx) => (
|
||||
<tr key={`expense-${expenseIdx}`}>
|
||||
<td className='p-2'>
|
||||
<SelectInput
|
||||
placeholder='Pilih Nonstock'
|
||||
value={expenseItem.nonstock}
|
||||
onChange={(val) => {
|
||||
nonstockChangeHandler(
|
||||
kandangExpenseIdx,
|
||||
expenseIdx,
|
||||
val
|
||||
);
|
||||
}}
|
||||
options={nonstockOptions}
|
||||
isLoading={isLoadingNonstockOptions}
|
||||
onInputChange={setNonstockInputValue}
|
||||
className={{ wrapper: 'min-w-48' }}
|
||||
isDisabled
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className='p-2'>
|
||||
<NumberInput
|
||||
required
|
||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
|
||||
placeholder='Masukkan Total Kuantitas'
|
||||
value={
|
||||
formik.values.realizations[
|
||||
kandangExpenseIdx
|
||||
].cost_items[expenseIdx].quantity ?? ''
|
||||
}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={isExpenseRepeaterInputError(
|
||||
'quantity',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className='p-2'>
|
||||
<NumberInput
|
||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
|
||||
placeholder='Masukkan Harga Satuan'
|
||||
value={
|
||||
formik.values.realizations[
|
||||
kandangExpenseIdx
|
||||
].cost_items[expenseIdx].price ?? ''
|
||||
}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={isExpenseRepeaterInputError(
|
||||
'price',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
inputPrefix={
|
||||
<span className='text-gray-600 font-medium'>
|
||||
Rp
|
||||
</span>
|
||||
}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className='p-2'>
|
||||
<TextInput
|
||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
|
||||
placeholder='Tuliskan catatan'
|
||||
value={
|
||||
formik.values.realizations[
|
||||
kandangExpenseIdx
|
||||
].cost_items[expenseIdx].notes ?? ''
|
||||
}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={isExpenseRepeaterInputError(
|
||||
'notes',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Harga Satuan</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{kandangExpense.cost_items.map(
|
||||
(expenseItem, expenseIdx) => (
|
||||
<tr key={`expense-${expenseIdx}`}>
|
||||
<td className='p-2'>
|
||||
<SelectInput
|
||||
placeholder='Pilih Nonstock'
|
||||
value={expenseItem.nonstock}
|
||||
onChange={(val) => {
|
||||
nonstockChangeHandler(
|
||||
kandangExpenseIdx,
|
||||
expenseIdx,
|
||||
val
|
||||
);
|
||||
}}
|
||||
options={nonstockOptions}
|
||||
isLoading={isLoadingNonstockOptions}
|
||||
onInputChange={setNonstockInputValue}
|
||||
className={{ wrapper: 'min-w-48' }}
|
||||
isDisabled
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className='p-2'>
|
||||
<NumberInput
|
||||
required
|
||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
|
||||
placeholder='Masukkan Total Kuantitas'
|
||||
value={
|
||||
formik.values.realizations[
|
||||
kandangExpenseIdx
|
||||
].cost_items[expenseIdx].quantity ?? ''
|
||||
}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={isExpenseRepeaterInputError(
|
||||
'quantity',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className='p-2'>
|
||||
<NumberInput
|
||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
|
||||
placeholder='Masukkan Harga Satuan'
|
||||
value={
|
||||
formik.values.realizations[
|
||||
kandangExpenseIdx
|
||||
].cost_items[expenseIdx].price ?? ''
|
||||
}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={isExpenseRepeaterInputError(
|
||||
'price',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
inputPrefix={
|
||||
<span className='text-gray-600 font-medium'>
|
||||
Rp
|
||||
</span>
|
||||
}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className='p-2'>
|
||||
<TextInput
|
||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
|
||||
placeholder='Tuliskan catatan'
|
||||
value={
|
||||
formik.values.realizations[
|
||||
kandangExpenseIdx
|
||||
].cost_items[expenseIdx].notes ?? ''
|
||||
}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={isExpenseRepeaterInputError(
|
||||
'notes',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
)
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Yup from 'yup';
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
|
||||
type ExpenseFormSchemaType = {
|
||||
category?: {
|
||||
@@ -11,8 +12,9 @@ type ExpenseFormSchemaType = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
location_id: number;
|
||||
transaction_date?: string;
|
||||
kandangs?: { id: number; name: string }[];
|
||||
kandangs?: { id?: number; name?: string }[];
|
||||
supplier?: {
|
||||
value: number;
|
||||
label: string;
|
||||
@@ -21,7 +23,7 @@ type ExpenseFormSchemaType = {
|
||||
deleted_documents?: number[];
|
||||
documents?: File[];
|
||||
expense_nonstocks: {
|
||||
kandang_id: number;
|
||||
kandang_id?: number;
|
||||
cost_items: {
|
||||
nonstock?: {
|
||||
value: number;
|
||||
@@ -46,16 +48,17 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||
label: Yup.string().required(),
|
||||
}).required('Lokasi wajib diisi!'),
|
||||
|
||||
location_id: Yup.number().min(1).required('Lokasi wajib diisi!'),
|
||||
|
||||
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
|
||||
kandangs: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
id: Yup.number().required('Kandang wajib dipilih!'),
|
||||
name: Yup.string().required('Kandang wajib dipilih!'),
|
||||
id: Yup.number().optional(),
|
||||
name: Yup.string().optional(),
|
||||
})
|
||||
)
|
||||
.min(1, 'Kandang wajib dipilih!')
|
||||
.required('Kandang wajib dipilih!'),
|
||||
.optional(),
|
||||
|
||||
supplier: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
@@ -77,7 +80,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||
expense_nonstocks: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
|
||||
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(),
|
||||
cost_items: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
@@ -128,6 +131,7 @@ export const getExpenseFormInitialValues = (
|
||||
label: initialValues.location.name,
|
||||
}
|
||||
: undefined,
|
||||
location_id: Number(initialValues?.location.id || 0),
|
||||
transaction_date: initialValues?.transaction_date
|
||||
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
|
||||
: undefined,
|
||||
@@ -141,11 +145,14 @@ export const getExpenseFormInitialValues = (
|
||||
label: initialValues.supplier.name,
|
||||
}
|
||||
: undefined,
|
||||
existing_documents: initialValues?.documents?.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.path,
|
||||
url: doc.path,
|
||||
})),
|
||||
existing_documents: initialValues?.documents?.map((doc) => {
|
||||
const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
|
||||
return {
|
||||
id: doc.id,
|
||||
name: doc.path,
|
||||
url: `${S3_PUBLIC_BASE_URL}/${path}`,
|
||||
};
|
||||
}),
|
||||
deleted_documents: [],
|
||||
documents: [],
|
||||
expense_nonstocks: initialValues?.kandangs
|
||||
|
||||
@@ -18,6 +18,7 @@ import DateInput from '@/components/input/DateInput';
|
||||
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
|
||||
import DropFileInput from '@/components/input/DropFileInput';
|
||||
import ExpenseRequestKandangDetailExpense from '@/components/pages/expense/form/ExpenseRequestKandangDetailExpense';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
import {
|
||||
ExpenseRequestFormSchema,
|
||||
@@ -107,18 +108,24 @@ const ExpenseRequestForm = ({
|
||||
|
||||
const expensePayload: CreateExpensePayload = {
|
||||
category: formik.values.category?.value as 'BOP' | 'NON-BOP',
|
||||
location_id: values.location_id as number,
|
||||
transaction_date: values?.transaction_date as string,
|
||||
supplier_id: values.supplier?.value as number,
|
||||
documents: values.documents as File[],
|
||||
expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({
|
||||
kandang_id: expenseNonstock.kandang_id,
|
||||
cost_items: expenseNonstock.cost_items.map((costItem) => ({
|
||||
nonstock_id: costItem.nonstock?.value as number,
|
||||
quantity: parseFloat(String(costItem.quantity)) as number,
|
||||
price: parseFloat(String(costItem.price)) as number,
|
||||
notes: costItem.notes ?? '',
|
||||
})),
|
||||
})),
|
||||
expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => {
|
||||
const basePayload = {
|
||||
cost_items: expenseNonstock.cost_items.map((costItem) => ({
|
||||
nonstock_id: costItem.nonstock?.value as number,
|
||||
quantity: parseFloat(String(costItem.quantity)) as number,
|
||||
price: parseFloat(String(costItem.price)) as number,
|
||||
notes: costItem.notes ?? '',
|
||||
})),
|
||||
};
|
||||
|
||||
return expenseNonstock.kandang_id
|
||||
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
|
||||
: basePayload;
|
||||
}),
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
@@ -129,19 +136,25 @@ const ExpenseRequestForm = ({
|
||||
case 'edit':
|
||||
const expenseUpdatePayload: UpdateExpensePayload = {
|
||||
category: formik.values.category?.value as 'BOP' | 'NON-BOP',
|
||||
location_id: values.location_id as number,
|
||||
transaction_date: values?.transaction_date as string,
|
||||
supplier_id: values.supplier?.value as number,
|
||||
documents: values.documents as File[],
|
||||
expense_nonstocks: values.expense_nonstocks.map(
|
||||
(expenseNonstock) => ({
|
||||
kandang_id: expenseNonstock.kandang_id,
|
||||
cost_items: expenseNonstock.cost_items.map((costItem) => ({
|
||||
nonstock_id: costItem.nonstock?.value as number,
|
||||
quantity: parseFloat(String(costItem.quantity)) as number,
|
||||
price: parseFloat(String(costItem.price)) as number,
|
||||
notes: costItem.notes ?? '',
|
||||
})),
|
||||
})
|
||||
(expenseNonstock) => {
|
||||
const basePayload = {
|
||||
cost_items: expenseNonstock.cost_items.map((costItem) => ({
|
||||
nonstock_id: costItem.nonstock?.value as number,
|
||||
quantity: parseFloat(String(costItem.quantity)) as number,
|
||||
price: parseFloat(String(costItem.price)) as number,
|
||||
notes: costItem.notes ?? '',
|
||||
})),
|
||||
};
|
||||
|
||||
return expenseNonstock.kandang_id
|
||||
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
|
||||
: basePayload;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
@@ -178,27 +191,14 @@ const ExpenseRequestForm = ({
|
||||
formik.setFieldTouched('location', true);
|
||||
formik.setFieldValue('location', val);
|
||||
|
||||
const locationId = Array.isArray(val) ? val[0]?.value : val?.value;
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
|
||||
formik.setFieldValue('kandangs', []);
|
||||
formik.setFieldValue('expense_nonstocks', []);
|
||||
};
|
||||
|
||||
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
|
||||
formik.setFieldTouched('kandangs', true);
|
||||
formik.setFieldValue('kandangs', kandangs);
|
||||
|
||||
const newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])];
|
||||
|
||||
// add new expense_nonstocks
|
||||
kandangs.forEach((kandangItem) => {
|
||||
const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find(
|
||||
(expenseNonstockItem) =>
|
||||
expenseNonstockItem.kandang_id === kandangItem.id
|
||||
);
|
||||
|
||||
if (isKandangExistInExpenseNonstocks) return;
|
||||
|
||||
newExpenseNonstocks.push({
|
||||
kandang_id: kandangItem.id,
|
||||
// Auto-create expense item for location (without kandang)
|
||||
formik.setFieldValue('expense_nonstocks', [
|
||||
{
|
||||
cost_items: [
|
||||
{
|
||||
nonstock: undefined,
|
||||
@@ -207,25 +207,56 @@ const ExpenseRequestForm = ({
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const kandangsChangeHandler = (
|
||||
kandangs: { id?: number; name?: string }[]
|
||||
) => {
|
||||
formik.setFieldTouched('kandangs', true);
|
||||
formik.setFieldValue('kandangs', kandangs);
|
||||
|
||||
// If no kandangs selected, create expense item for location
|
||||
if (kandangs.length === 0) {
|
||||
formik.setFieldValue('expense_nonstocks', [
|
||||
{
|
||||
cost_items: [
|
||||
{
|
||||
nonstock: undefined,
|
||||
quantity: undefined,
|
||||
price: undefined,
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const newExpenseNonstocks: typeof formik.values.expense_nonstocks = [];
|
||||
|
||||
kandangs.forEach((kandangItem) => {
|
||||
if (!kandangItem.id) return;
|
||||
|
||||
const existingExpenseNonstock = formik.values.expense_nonstocks?.find(
|
||||
(expenseNonstockItem) =>
|
||||
expenseNonstockItem.kandang_id === kandangItem.id
|
||||
);
|
||||
|
||||
newExpenseNonstocks.push({
|
||||
kandang_id: kandangItem.id,
|
||||
cost_items: existingExpenseNonstock?.cost_items || [
|
||||
{
|
||||
nonstock: undefined,
|
||||
quantity: undefined,
|
||||
price: undefined,
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// prune expense_nonstocks
|
||||
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
|
||||
const deletedExpenseNonstocksIdx: number[] = [];
|
||||
|
||||
newExpenseNonstocks.forEach((expenseNonstock, idx) => {
|
||||
const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id);
|
||||
|
||||
if (!isExpenseNonstockValid) {
|
||||
deletedExpenseNonstocksIdx.push(idx);
|
||||
}
|
||||
});
|
||||
|
||||
deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => {
|
||||
newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1);
|
||||
});
|
||||
|
||||
formik.setFieldValue('expense_nonstocks', newExpenseNonstocks);
|
||||
};
|
||||
|
||||
@@ -385,21 +416,23 @@ const ExpenseRequestForm = ({
|
||||
className={{ wrapper: 'col-span-12' }}
|
||||
/>
|
||||
|
||||
<DropFileInput
|
||||
label='Dokumen Pengajuan'
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={requestDocumentsChangeHandler}
|
||||
onDelete={requestDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
className={{
|
||||
wrapper: 'col-span-12',
|
||||
inputWrapper: 'h-12 flex items-center',
|
||||
}}
|
||||
/>
|
||||
<RequirePermission permissions='lti.expense.document'>
|
||||
<DropFileInput
|
||||
label='Dokumen Pengajuan'
|
||||
name='documents'
|
||||
values={formik.values.documents}
|
||||
onChange={requestDocumentsChangeHandler}
|
||||
onDelete={requestDocumentsDeleteHandler}
|
||||
accept={{
|
||||
...ACCEPTED_FILE_TYPE.PDF,
|
||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||
}}
|
||||
className={{
|
||||
wrapper: 'col-span-12',
|
||||
inputWrapper: 'h-12 flex items-center',
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
|
||||
{formik.values.existing_documents &&
|
||||
formik.values.existing_documents.length > 0 && (
|
||||
@@ -451,7 +484,10 @@ const ExpenseRequestForm = ({
|
||||
)}
|
||||
|
||||
<ExpenseRequestKandangDetailExpense
|
||||
type={type}
|
||||
formik={formik}
|
||||
supplierId={formik.values.supplier?.value as number}
|
||||
location={formik.values.location}
|
||||
className={{
|
||||
wrapper: 'col-span-12',
|
||||
}}
|
||||
@@ -461,36 +497,40 @@ const ExpenseRequestForm = ({
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
{type !== 'add' && (
|
||||
<div className='flex flex-row justify-start gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={deleteExpenseClickHandler}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{type !== 'edit' && (
|
||||
<RequirePermission permissions='lti.expense.delete'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
color='error'
|
||||
onClick={deleteExpenseClickHandler}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{type !== 'edit' && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,11 @@ import { removeArrayItemAndSync } from '@/lib/utils/formik';
|
||||
interface ExpenseRequestKandangDetailExpenseProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
formik: FormikContextType<ExpenseRequestFormValues>;
|
||||
supplierId?: number;
|
||||
location?: {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
className?: {
|
||||
wrapper?: string;
|
||||
};
|
||||
@@ -28,12 +33,18 @@ interface ExpenseRequestKandangDetailExpenseProps {
|
||||
|
||||
const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
ExpenseRequestKandangDetailExpenseProps
|
||||
> = ({ type, formik, className }) => {
|
||||
> = ({ type, formik, supplierId, location, className }) => {
|
||||
const {
|
||||
setInputValue: setNonstockInputValue,
|
||||
options: nonstockOptions,
|
||||
isLoadingOptions: isLoadingNonstockOptions,
|
||||
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
||||
} = useSelect<Nonstock>(
|
||||
NonstockApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
supplierId ? { supplier_id: String(supplierId) } : undefined
|
||||
);
|
||||
|
||||
const nonstockChangeHandler = (
|
||||
kandangExpenseIdx: number,
|
||||
@@ -113,41 +124,57 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
</div>
|
||||
|
||||
<div className='w-full flex flex-col gap-6'>
|
||||
{(formik.values.expense_nonstocks.length === 0 ||
|
||||
!formik.values.supplier?.value) && (
|
||||
{!formik.values.supplier?.value && (
|
||||
<div>
|
||||
<p className='text-sm text-gray-400 text-center'>
|
||||
Pilih kandang terlebih dahulu!
|
||||
Pilih supplier terlebih dahulu!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formik.values.expense_nonstocks.length === 0 &&
|
||||
formik.values.supplier?.value && (
|
||||
<div>
|
||||
<p className='text-sm text-gray-400 text-center'>
|
||||
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formik.values.expense_nonstocks.length > 0 &&
|
||||
formik.values.supplier?.value &&
|
||||
formik.values.expense_nonstocks.map(
|
||||
(kandangExpense, kandangExpenseIdx) => {
|
||||
const kandangName = formik.values.kandangs?.find(
|
||||
(kandang) => kandang.id === kandangExpense.kandang_id
|
||||
);
|
||||
const kandangName = kandangExpense.kandang_id
|
||||
? formik.values.kandangs?.find(
|
||||
(kandang) => kandang.id === kandangExpense.kandang_id
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
kandangName?.name && (
|
||||
(kandangName?.name || !kandangExpense.kandang_id) && (
|
||||
<div
|
||||
key={`kandangExpense-${kandangExpenseIdx}`}
|
||||
className='w-full flex flex-col gap-4'
|
||||
>
|
||||
<div>
|
||||
<h5 className='mb-2 text-lg font-bold text-center'>
|
||||
Biaya {kandangName?.name}
|
||||
Biaya {kandangName?.name || location?.label || 'Umum'}
|
||||
</h5>
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='table'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Harga Satuan</th>
|
||||
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
|
||||
Nonstock
|
||||
</th>
|
||||
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
|
||||
Total Kuantitas
|
||||
</th>
|
||||
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
|
||||
Harga Satuan
|
||||
</th>
|
||||
<th>Catatan</th>
|
||||
{type !== 'detail' && <th>Aksi</th>}
|
||||
</tr>
|
||||
|
||||
@@ -219,7 +219,13 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
{ label: 'Lokasi', value: expense?.location.name },
|
||||
{
|
||||
label: 'Kandang',
|
||||
value: expense?.kandangs.map((item) => item.name).join(', '),
|
||||
value:
|
||||
expense?.kandangs && expense?.kandangs.some((k) => k.name)
|
||||
? expense?.kandangs
|
||||
.filter((item) => item.name)
|
||||
.map((item) => item.name)
|
||||
.join(', ')
|
||||
: '-',
|
||||
},
|
||||
{ label: 'Vendor', value: expense?.supplier.name },
|
||||
{
|
||||
@@ -235,7 +241,12 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
{ label: 'Nama Pengaju', value: expense?.created_user.name },
|
||||
{
|
||||
label: 'Nominal Biaya',
|
||||
value: formatCurrency(expense?.grand_total ?? 0),
|
||||
value: formatCurrency(
|
||||
expense?.latest_approval.step_number === 4 ||
|
||||
expense?.latest_approval.step_number === 5
|
||||
? (expense?.total_realisasi ?? 0)
|
||||
: (expense?.total_pengajuan ?? 0)
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Nominal Pengajuan',
|
||||
@@ -326,7 +337,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
let expenseRequestTotal = 0;
|
||||
|
||||
kandangExpense.pengajuans?.forEach(
|
||||
(item) => (expenseRequestTotal += item.price)
|
||||
(item) => (expenseRequestTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -335,7 +346,9 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
style={ExpensePDFStyle.kandangExpenseContainer}
|
||||
>
|
||||
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
|
||||
{kandangExpense.name}
|
||||
{kandangExpense.kandang_id && kandangExpense.name
|
||||
? `Biaya ${kandangExpense.name}`
|
||||
: `Biaya ${expense?.location.name || 'Umum'}`}
|
||||
</Text>
|
||||
|
||||
<View style={ExpensePDFStyle.kandangExpenseTable}>
|
||||
@@ -484,7 +497,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
let expenseRealizationTotal = 0;
|
||||
|
||||
kandangExpense.realisasi?.forEach(
|
||||
(item) => (expenseRealizationTotal += item.price)
|
||||
(item) => (expenseRealizationTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -493,7 +506,9 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
style={ExpensePDFStyle.kandangExpenseContainer}
|
||||
>
|
||||
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
|
||||
{kandangExpense.name}
|
||||
{kandangExpense.kandang_id && kandangExpense.name
|
||||
? `Biaya ${kandangExpense.name}`
|
||||
: `Biaya ${expense?.location.name || 'Umum'}`}
|
||||
</Text>
|
||||
|
||||
<View style={ExpensePDFStyle.kandangExpenseTable}>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import Table from '@/components/Table';
|
||||
import {
|
||||
FINANCE_INITIAL_BALANCE_STATUS,
|
||||
FINANCE_TRANSACTION_STATUS,
|
||||
} from '@/config/constant';
|
||||
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { Finance } from '@/types/api/finance/finance';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
const router = useRouter();
|
||||
const deleteModal = useModal();
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const informasiUmum = [
|
||||
{
|
||||
label: 'ID',
|
||||
value: finance.payment_code,
|
||||
},
|
||||
{
|
||||
label: 'Jenis Transaksi',
|
||||
value: finance.transaction_type,
|
||||
},
|
||||
{
|
||||
label: 'Pihak',
|
||||
value: finance.party.name,
|
||||
},
|
||||
{
|
||||
label: 'Tanggal',
|
||||
value: formatDate(finance.payment_date, 'DD MMM yyyy'),
|
||||
},
|
||||
{
|
||||
label: 'Metode Pembayaran',
|
||||
value: finance.payment_method,
|
||||
},
|
||||
{
|
||||
label: 'Catatan',
|
||||
value: finance.notes || '-',
|
||||
},
|
||||
];
|
||||
const informasiTransfer = [
|
||||
{
|
||||
label: 'No. Referensi',
|
||||
value: finance.reference_number,
|
||||
},
|
||||
{
|
||||
label: 'Nomor Rekening',
|
||||
value: `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`,
|
||||
},
|
||||
{
|
||||
label: `Rekening ${formatTitleCase(finance.party.type)}`,
|
||||
value: finance.party.account_number,
|
||||
},
|
||||
{
|
||||
label: 'Nominal',
|
||||
value: formatCurrency(finance.expense_amount),
|
||||
},
|
||||
{
|
||||
label: 'Sisa',
|
||||
value: formatCurrency(finance.income_amount),
|
||||
},
|
||||
];
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await FinanceApi.delete(finance.id as number);
|
||||
router.back();
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Finance!');
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-6 p-6'>
|
||||
<FormHeader title='' backUrl='/finance' />
|
||||
|
||||
<Card
|
||||
title='Detail Keuangan'
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
variant='bordered'
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-4 mb-6'>
|
||||
<Table
|
||||
data={informasiUmum}
|
||||
columns={[
|
||||
{
|
||||
header: '',
|
||||
id: 'label',
|
||||
accessorKey: 'label',
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'value',
|
||||
accessorKey: 'value',
|
||||
},
|
||||
]}
|
||||
className={{
|
||||
headerRowClassName: 'hidden',
|
||||
paginationClassName: 'hidden',
|
||||
containerClassName: 'mb-0',
|
||||
}}
|
||||
/>
|
||||
<Table
|
||||
data={informasiTransfer}
|
||||
columns={[
|
||||
{
|
||||
header: '',
|
||||
id: 'label',
|
||||
accessorKey: 'label',
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'value',
|
||||
accessorKey: 'value',
|
||||
},
|
||||
]}
|
||||
className={{
|
||||
headerRowClassName: 'hidden',
|
||||
paginationClassName: 'hidden',
|
||||
containerClassName: 'mb-0',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className='flex flex-row gap-2 justify-end'>
|
||||
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
href={`/finance/detail/edit?financeId=${finance.id}`}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
|
||||
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
href={`/finance/detail/edit/initial-balance?financeId=${finance.id}`}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission permissions='lti.finance.transaction.delete'>
|
||||
<Button
|
||||
color='error'
|
||||
className='min-w-24'
|
||||
onClick={() => deleteModal.openModal()}
|
||||
>
|
||||
<Icon icon='mdi:delete-outline' />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Finance ini (${finance?.payment_code})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceDetail;
|
||||
@@ -0,0 +1,564 @@
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import { CellContext, Row } from '@tanstack/react-table';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import Dropdown from '@/components/dropdown/Dropdown';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import Table from '@/components/Table';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { Finance } from '@/types/api/finance/finance';
|
||||
import {
|
||||
FINANCE_INITIAL_BALANCE_STATUS,
|
||||
FINANCE_INJECTION_STATUS,
|
||||
FINANCE_TRANSACTION_STATUS,
|
||||
ROWS_OPTIONS,
|
||||
} from '@/config/constant';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import toast from 'react-hot-toast';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Icon } from '@iconify/react';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
props,
|
||||
deleteClickHandler,
|
||||
}: {
|
||||
type: 'dropdown' | 'collapse';
|
||||
props: CellContext<Finance, unknown>;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.finance.transaction.detail'>
|
||||
<Button
|
||||
href={`/finance/detail?financeId=${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>
|
||||
|
||||
{FINANCE_TRANSACTION_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit/initial-balance?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{FINANCE_INJECTION_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.injections.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit/injection?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<RequirePermission permissions='lti.finance.transaction.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='text-error hover:text-inherit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const FinanceTable = () => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
transactionType: '',
|
||||
bankId: '',
|
||||
partyType: '',
|
||||
sortBy: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
transactionType: 'transaction_type',
|
||||
bankId: 'bank_id',
|
||||
partyType: 'party_type',
|
||||
sortBy: 'sort_date',
|
||||
startDate: 'start_date',
|
||||
endDate: 'end_date',
|
||||
},
|
||||
});
|
||||
|
||||
// ===== State =====
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deleteModal = useModal();
|
||||
const [pendingFilters, setPendingFilters] = useState({
|
||||
search: '',
|
||||
transactionType: '',
|
||||
bankId: '',
|
||||
partyType: '',
|
||||
sortBy: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
});
|
||||
const [selectedTransactionType, setSelectedTransactionType] =
|
||||
useState<OptionType | null>(null);
|
||||
const [selectedBank, setSelectedBank] = useState<OptionType | null>(null);
|
||||
const [selectedPartyType, setSelectedPartyType] = useState<OptionType | null>(
|
||||
null
|
||||
);
|
||||
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
|
||||
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
// ===== Fetch Data =====
|
||||
const {
|
||||
data: finances,
|
||||
isLoading,
|
||||
mutate: refreshFinances,
|
||||
} = useSWR(
|
||||
`${FinanceApi.basePath}/transactions${getTableFilterQueryString()}`,
|
||||
FinanceApi.getAllFetcher
|
||||
);
|
||||
|
||||
// ===== Options =====
|
||||
const transactionTypeOptions = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Transfer', value: 'TRANSFER' },
|
||||
{ label: 'Cash', value: 'CASH' },
|
||||
{ label: 'Card', value: 'CARD' },
|
||||
{ label: 'Cheque', value: 'CHEQUE' },
|
||||
{ label: 'Saldo', value: 'SALDO' },
|
||||
];
|
||||
}, []);
|
||||
const partyTypeOptions = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Customer', value: 'CUSTOMER' },
|
||||
{ label: 'Supplier', value: 'SUPPLIER' },
|
||||
];
|
||||
}, []);
|
||||
const sortByOptions = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Tanggal Pembayaran', value: 'payment_date' },
|
||||
{ label: 'Tanggal Dibuat', value: 'created_at' },
|
||||
];
|
||||
}, []);
|
||||
const { options: bankOptions, rawData: bankRawData } = useSelect<Bank>(
|
||||
BankApi.basePath,
|
||||
'id',
|
||||
'alias',
|
||||
'',
|
||||
{
|
||||
limit: 'limit',
|
||||
}
|
||||
);
|
||||
|
||||
// ===== Handler =====
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setPendingFilters((prev) => ({ ...prev, search: e.target.value }));
|
||||
};
|
||||
const transactionTypeChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setSelectedTransactionType(val as OptionType);
|
||||
setPendingFilters((prev) => ({
|
||||
...prev,
|
||||
transactionType: val ? ((val as OptionType).value as string) : '',
|
||||
}));
|
||||
};
|
||||
const bankChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
setSelectedBank(val as OptionType);
|
||||
setPendingFilters((prev) => ({
|
||||
...prev,
|
||||
bankId: val ? ((val as OptionType).value as string) : '',
|
||||
}));
|
||||
};
|
||||
const partyTypeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
setSelectedPartyType(val as OptionType);
|
||||
setPendingFilters((prev) => ({
|
||||
...prev,
|
||||
partyType: val ? ((val as OptionType).value as string) : '',
|
||||
}));
|
||||
};
|
||||
const sortByChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
setSelectedSortBy(val as OptionType);
|
||||
setPendingFilters((prev) => ({
|
||||
...prev,
|
||||
sortBy: val ? ((val as OptionType).value as string) : '',
|
||||
}));
|
||||
};
|
||||
const startDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setPendingFilters((prev) => ({ ...prev, startDate: e.target.value }));
|
||||
};
|
||||
const endDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setPendingFilters((prev) => ({ ...prev, endDate: e.target.value }));
|
||||
};
|
||||
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
};
|
||||
const submitFilterHandler = () => {
|
||||
updateFilter('search', pendingFilters.search);
|
||||
updateFilter('transactionType', pendingFilters.transactionType);
|
||||
updateFilter('bankId', pendingFilters.bankId);
|
||||
updateFilter('partyType', pendingFilters.partyType);
|
||||
updateFilter('sortBy', pendingFilters.sortBy);
|
||||
updateFilter('startDate', pendingFilters.startDate);
|
||||
updateFilter('endDate', pendingFilters.endDate);
|
||||
};
|
||||
const resetFilterHandler = () => {
|
||||
setSelectedTransactionType(null);
|
||||
setSelectedBank(null);
|
||||
setSelectedPartyType(null);
|
||||
setSelectedSortBy(null);
|
||||
|
||||
const emptyFilters = {
|
||||
search: '',
|
||||
transactionType: '',
|
||||
bankId: '',
|
||||
partyType: '',
|
||||
sortBy: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
};
|
||||
setPendingFilters(emptyFilters);
|
||||
|
||||
updateFilter('search', '');
|
||||
updateFilter('transactionType', '');
|
||||
updateFilter('bankId', '');
|
||||
updateFilter('partyType', '');
|
||||
updateFilter('sortBy', '');
|
||||
updateFilter('startDate', '');
|
||||
updateFilter('endDate', '');
|
||||
};
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await FinanceApi.delete(selectedFinance?.id as number);
|
||||
refreshFinances();
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Finance!');
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'payment_code',
|
||||
},
|
||||
{
|
||||
header: 'References Number',
|
||||
accessorKey: 'reference_number',
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
const value = props.row.original.reference_number;
|
||||
return <span>{value ?? '-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Jenis Transaksi',
|
||||
accessorKey: 'transaction_type',
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
const value = props.row.original.transaction_type
|
||||
.split('_')
|
||||
.join(' ');
|
||||
return <span>{formatTitleCase(value)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Pihak',
|
||||
accessorFn: (finance: Finance) => finance.party.name,
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
if (props.row.original.party.id) {
|
||||
return <span>{props.row.original.party.name}</span>;
|
||||
}
|
||||
return <span>{'-'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Tanggal',
|
||||
accessorFn: (finance: Finance) =>
|
||||
formatDate(finance.payment_date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
header: 'Metode Pembayaran',
|
||||
accessorKey: 'payment_method',
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
const value = props.row.original.payment_method.split('_').join(' ');
|
||||
return <span>{formatTitleCase(value)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Bank',
|
||||
accessorFn: (finance: Finance) =>
|
||||
`${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`,
|
||||
},
|
||||
{
|
||||
header: 'Pengeluaran (Rp)',
|
||||
accessorFn: (finance: Finance) =>
|
||||
formatCurrency(finance.expense_amount),
|
||||
},
|
||||
{
|
||||
header: 'Pemasukan (Rp)',
|
||||
accessorFn: (finance: Finance) => formatCurrency(finance.income_amount),
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
const currentPageSize =
|
||||
props.table.getPaginationRowModel().rows.length;
|
||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||
const currentRowRelativeIndex =
|
||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||
|
||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||
|
||||
const deleteClickHandler = () => {
|
||||
setSelectedFinance(props.row.original);
|
||||
deleteModal.openModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentPageSize > 2 && (
|
||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||
<RowOptionsMenu
|
||||
type='dropdown'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
)}
|
||||
|
||||
{currentPageSize <= 2 && (
|
||||
<RowCollapseOptions>
|
||||
<RowOptionsMenu
|
||||
type='collapse'
|
||||
props={props}
|
||||
deleteClickHandler={deleteClickHandler}
|
||||
/>
|
||||
</RowCollapseOptions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
return (
|
||||
<section className='size-full p-6 flex flex-col gap-6'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<RequirePermission permissions='lti.finance.injections.create'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
href='/finance/add/injection'
|
||||
>
|
||||
Injection Saldo Bank
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.finance.initial_balances.create'>
|
||||
<Button
|
||||
color='info'
|
||||
className='text-white min-w-24'
|
||||
href='/finance/add/initial-balance'
|
||||
>
|
||||
Saldo Awal
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.finance.payments.create'>
|
||||
<Button color='primary' className='min-w-24' href='/finance/add'>
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
<Card
|
||||
variant='bordered'
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
footer={
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
onClick={resetFilterHandler}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
className='min-w-24'
|
||||
onClick={submitFilterHandler}
|
||||
>
|
||||
Cari
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-4 gap-6'>
|
||||
<SelectInput
|
||||
options={transactionTypeOptions}
|
||||
label='Jenis Transaksi'
|
||||
value={selectedTransactionType}
|
||||
onChange={transactionTypeChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
options={
|
||||
isResponseSuccess(bankRawData)
|
||||
? bankOptions.map((bank) => ({
|
||||
label:
|
||||
bankRawData.data.find((data) => data.id === bank.value)
|
||||
?.alias +
|
||||
' - ' +
|
||||
bankRawData.data.find((data) => data.id === bank.value)
|
||||
?.account_number +
|
||||
' - ' +
|
||||
bankRawData.data.find((data) => data.id === bank.value)
|
||||
?.owner,
|
||||
value: bank.value,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
label='Bank'
|
||||
value={selectedBank}
|
||||
onChange={bankChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
options={partyTypeOptions}
|
||||
label='Pihak'
|
||||
value={selectedPartyType}
|
||||
onChange={partyTypeChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
label='Cari'
|
||||
placeholder='Cari'
|
||||
value={pendingFilters.search}
|
||||
onChange={searchChangeHandler}
|
||||
/>
|
||||
<SelectInput
|
||||
options={sortByOptions}
|
||||
label='Urutkan Berdasarkan'
|
||||
value={selectedSortBy}
|
||||
onChange={sortByChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<DateInput
|
||||
name='startDate'
|
||||
label='Periode Tanggal (Mulai)'
|
||||
value={pendingFilters.startDate}
|
||||
onChange={startDateChangeHandler}
|
||||
/>
|
||||
<DateInput
|
||||
name='endDate'
|
||||
label='Periode Tanggal (Akhir)'
|
||||
value={pendingFilters.endDate}
|
||||
onChange={endDateChangeHandler}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Table<Finance>
|
||||
data={isResponseSuccess(finances) ? finances.data : []}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={tableFilterState.page}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
totalItems={
|
||||
isResponseSuccess(finances) ? finances.meta?.total_results : 0
|
||||
}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
text={`Apakah anda yakin ingin menghapus data Finance ini (${selectedFinance?.payment_code})?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'error',
|
||||
isLoading: isDeleteLoading,
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinanceTable;
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as Yup from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
/**
|
||||
* API Payload format:
|
||||
* {
|
||||
"party_id": 1,
|
||||
"party_type": "CUSTOMER",
|
||||
"payment_date": "2025-11-21",
|
||||
"payment_method": "Transfer",
|
||||
"bank_id": 1,
|
||||
"reference_number": "DO.MBU.123",
|
||||
"nominal": 25000000,
|
||||
"notes": "Pembayaran piutang penjualan telur"
|
||||
}
|
||||
*/
|
||||
|
||||
// Type for form values (includes option objects for SelectInput)
|
||||
export type FinanceFormValues = {
|
||||
party_type_option: OptionType | null;
|
||||
party_id_option: OptionType | null;
|
||||
party_account_number: string;
|
||||
payment_date: string;
|
||||
payment_method_option: OptionType | null;
|
||||
bank_id_option: OptionType | null;
|
||||
reference_number: string;
|
||||
nominal: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export const FinanceFormSchema = Yup.object().shape({
|
||||
party_type_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Jenis transaksi wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
party_id_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Pihak wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
party_account_number: Yup.string().required('Nomor rekening wajib diisi'),
|
||||
payment_date: Yup.string().required('Tanggal pembayaran wajib diisi'),
|
||||
payment_method_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Metode pembayaran wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
bank_id_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Bank wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
reference_number: Yup.string().required('Nomor referensi wajib diisi'),
|
||||
nominal: Yup.string().required('Nominal wajib diisi'),
|
||||
notes: Yup.string().required('Catatan wajib diisi'),
|
||||
});
|
||||
|
||||
export const UpdateFinanceFormSchema = FinanceFormSchema;
|
||||
@@ -0,0 +1,412 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import TextInput from '@/components/input/TextInput';
|
||||
import {
|
||||
FinanceFormSchema,
|
||||
FinanceFormValues,
|
||||
} from '@/components/pages/finance/add/FormFinanceAdd.schema';
|
||||
import {
|
||||
FINANCE_PARTY_TYPE_OPTIONS,
|
||||
FINANCE_PAYMENT_METHOD_OPTIONS,
|
||||
} from '@/config/constant';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
|
||||
import {
|
||||
CreateFinancePayment,
|
||||
Finance,
|
||||
UpdateFinancePayment,
|
||||
} from '@/types/api/finance/finance';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface FormFinanceAddProps {
|
||||
type?: 'add' | 'edit';
|
||||
initialValues?: Finance;
|
||||
}
|
||||
|
||||
interface PartyCommonProps {
|
||||
id: number;
|
||||
name: string;
|
||||
account_number: string;
|
||||
}
|
||||
|
||||
const FormFinanceAdd = ({
|
||||
type = 'add',
|
||||
initialValues,
|
||||
}: FormFinanceAddProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== Formik =====
|
||||
const formikInitialValues = useMemo((): FinanceFormValues => {
|
||||
return {
|
||||
party_type_option:
|
||||
FINANCE_PARTY_TYPE_OPTIONS.find(
|
||||
(option) => option.value === initialValues?.party.type
|
||||
) || null,
|
||||
party_id_option: initialValues?.party
|
||||
? {
|
||||
label: initialValues?.party.name || '',
|
||||
value: initialValues?.party.id || 0,
|
||||
}
|
||||
: null,
|
||||
payment_date: initialValues?.payment_date || '',
|
||||
payment_method_option:
|
||||
FINANCE_PAYMENT_METHOD_OPTIONS.find(
|
||||
(option) => option.value === initialValues?.payment_method
|
||||
) || null,
|
||||
bank_id_option: initialValues?.bank
|
||||
? {
|
||||
label: initialValues.bank.name,
|
||||
value: initialValues.bank.id,
|
||||
}
|
||||
: null,
|
||||
party_account_number: initialValues?.party.account_number || '',
|
||||
reference_number: initialValues?.reference_number || '',
|
||||
nominal: initialValues?.nominal.toString() || '',
|
||||
notes: initialValues?.notes || '',
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
const formik = useFormik<FinanceFormValues>({
|
||||
initialValues: formikInitialValues,
|
||||
validationSchema: FinanceFormSchema,
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
onSubmit: async (values) => {
|
||||
const payload = transformFormValuesToPayload(values);
|
||||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
await createFinance(payload);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
if (initialValues?.id) {
|
||||
await updateFinance(initialValues.id, payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ===== Options =====
|
||||
const {
|
||||
options: partyOptions,
|
||||
isLoadingOptions: isLoadingPartyOptions,
|
||||
rawData: partyRawData,
|
||||
} = useSelect<PartyCommonProps>(
|
||||
formik.values.party_type_option?.value === 'CUSTOMER'
|
||||
? CustomerApi.basePath
|
||||
: SupplierApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'',
|
||||
{ limit: 'limit' }
|
||||
);
|
||||
const {
|
||||
options: bankOptions,
|
||||
rawData: bankRawData,
|
||||
isLoadingOptions: isLoadingBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
|
||||
|
||||
// ===== Helper Functions =====
|
||||
const transformFormValuesToPayload = (
|
||||
values: FinanceFormValues
|
||||
): CreateFinancePayment => {
|
||||
return {
|
||||
party_id: Number(values.party_id_option?.value) || 0,
|
||||
party_type: (values.party_type_option?.value as string) || '',
|
||||
payment_date: formatDate(values.payment_date, 'YYYY-MM-DD'),
|
||||
payment_method: (values.payment_method_option?.value as string) || '',
|
||||
bank_id: Number(values.bank_id_option?.value) || 0,
|
||||
reference_number: values.reference_number,
|
||||
nominal: Number(values.nominal.replace(/\D/g, '')) || 0,
|
||||
notes: values.notes,
|
||||
};
|
||||
};
|
||||
|
||||
// ===== Handler =====
|
||||
const createFinance = useCallback(
|
||||
async (payload: CreateFinancePayment) => {
|
||||
const response = await FinanceApi.create(payload);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Data berhasil ditambahkan');
|
||||
router.refresh();
|
||||
router.push('/finance');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const updateFinance = useCallback(
|
||||
async (financeId: number, payload: UpdateFinancePayment) => {
|
||||
const response = await FinanceApi.update(financeId, payload);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Data berhasil diperbarui');
|
||||
router.refresh();
|
||||
router.push('/finance');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-xl mx-auto'>
|
||||
<div className='flex flex-col gap-6 p-6'>
|
||||
<FormHeader
|
||||
title={`${type === 'add' ? 'Tambah' : 'Ubah'} Data Keuangan`}
|
||||
backUrl='/finance'
|
||||
/>
|
||||
<form className='flex flex-col gap-4' onSubmit={formik.handleSubmit}>
|
||||
<SelectInput
|
||||
label='Jenis Transaksi'
|
||||
placeholder='Pilih jenis transaksi'
|
||||
options={FINANCE_PARTY_TYPE_OPTIONS}
|
||||
value={formik.values.party_type_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('party_type_option', value);
|
||||
formik.setFieldValue('party_id_option', null);
|
||||
formik.setFieldValue('party_account_number', '');
|
||||
}}
|
||||
isError={Boolean(
|
||||
formik.touched.party_type_option &&
|
||||
formik.errors.party_type_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.party_type_option &&
|
||||
formik.errors.party_type_option
|
||||
? formik.errors.party_type_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
label={
|
||||
formik.values.party_type_option?.value
|
||||
? formatTitleCase(
|
||||
formik.values.party_type_option.value as string
|
||||
)
|
||||
: 'Pilih Jenis Transaksi Dahulu'
|
||||
}
|
||||
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`}
|
||||
options={partyOptions}
|
||||
value={formik.values.party_id_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('party_id_option', value);
|
||||
if (isResponseSuccess(partyRawData) && value) {
|
||||
formik.setFieldValue(
|
||||
'party_account_number',
|
||||
partyRawData.data?.find(
|
||||
(item) => item.id === (value as OptionType)?.value
|
||||
)?.account_number || ''
|
||||
);
|
||||
}
|
||||
}}
|
||||
isLoading={isLoadingPartyOptions}
|
||||
isError={Boolean(
|
||||
formik.touched.party_id_option && formik.errors.party_id_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.party_id_option && formik.errors.party_id_option
|
||||
? formik.errors.party_id_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
isDisabled={!formik.values.party_type_option?.value}
|
||||
/>
|
||||
<DateInput
|
||||
label='Tanggal'
|
||||
placeholder='Pilih tanggal'
|
||||
name='payment_date'
|
||||
value={formik.values.payment_date}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(
|
||||
formik.touched.payment_date && formik.errors.payment_date
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.payment_date && formik.errors.payment_date
|
||||
? formik.errors.payment_date
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<SelectInput
|
||||
label='Metode Pembayaran'
|
||||
placeholder='Pilih metode pembayaran'
|
||||
options={FINANCE_PAYMENT_METHOD_OPTIONS}
|
||||
value={formik.values.payment_method_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('payment_method_option', value);
|
||||
}}
|
||||
isError={Boolean(
|
||||
formik.touched.payment_method_option &&
|
||||
formik.errors.payment_method_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.payment_method_option &&
|
||||
formik.errors.payment_method_option
|
||||
? formik.errors.payment_method_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
label='Bank'
|
||||
placeholder='Pilih bank'
|
||||
options={
|
||||
isResponseSuccess(bankRawData)
|
||||
? bankOptions.map((option) => ({
|
||||
label:
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.alias +
|
||||
' - ' +
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.account_number +
|
||||
' - ' +
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.owner,
|
||||
value: option.value,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
value={formik.values.bank_id_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('bank_id_option', value);
|
||||
}}
|
||||
isLoading={isLoadingBankOptions}
|
||||
isError={Boolean(
|
||||
formik.touched.bank_id_option && formik.errors.bank_id_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.bank_id_option && formik.errors.bank_id_option
|
||||
? formik.errors.bank_id_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
label={`Nomor Rekening ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'Pihak'}`}
|
||||
placeholder={`Masukkan nomor rekening ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'pihak'}`}
|
||||
name='party_account_number'
|
||||
value={formik.values.party_account_number}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(
|
||||
formik.touched.party_account_number &&
|
||||
formik.errors.party_account_number
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.party_account_number &&
|
||||
formik.errors.party_account_number
|
||||
? formik.errors.party_account_number
|
||||
: ''
|
||||
}
|
||||
required
|
||||
readOnly
|
||||
/>
|
||||
<TextInput
|
||||
label='Nomor Referensi'
|
||||
placeholder='Masukkan nomor referensi'
|
||||
name='reference_number'
|
||||
value={formik.values.reference_number}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(
|
||||
formik.touched.reference_number &&
|
||||
formik.errors.reference_number
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.reference_number &&
|
||||
formik.errors.reference_number
|
||||
? formik.errors.reference_number
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label='Nominal'
|
||||
placeholder='Masukkan nominal'
|
||||
name='nominal'
|
||||
value={formik.values.nominal}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(formik.touched.nominal && formik.errors.nominal)}
|
||||
errorMessage={
|
||||
formik.touched.nominal && formik.errors.nominal
|
||||
? formik.errors.nominal
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<TextArea
|
||||
label='Catatan'
|
||||
placeholder='Masukkan catatan'
|
||||
name='notes'
|
||||
value={formik.values.notes}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(formik.touched.notes && formik.errors.notes)}
|
||||
errorMessage={
|
||||
formik.touched.notes && formik.errors.notes
|
||||
? formik.errors.notes
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
type='reset'
|
||||
color='warning'
|
||||
className='w-min-24'
|
||||
onClick={() => formik.resetForm()}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='w-min-24'
|
||||
disabled={formik.isSubmitting || !formik.isValid}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormFinanceAdd;
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import * as Yup from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
// Type for form values (includes option objects for SelectInput)
|
||||
export type InitialBalanceFormValues = {
|
||||
party_type_option: OptionType | null;
|
||||
party_id_option: OptionType | null;
|
||||
bank_id_option: OptionType | null;
|
||||
reference_number: string;
|
||||
initial_balance_type_option: OptionType | null;
|
||||
nominal: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export const InitialBalanceFormSchema = Yup.object().shape({
|
||||
party_type_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Jenis pihak wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
party_id_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Pihak wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
bank_id_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Bank wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
reference_number: Yup.string().required('Nomor referensi wajib diisi'),
|
||||
initial_balance_type_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Tipe saldo awal wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
nominal: Yup.string().required('Nominal wajib diisi'),
|
||||
note: Yup.string().required('Catatan wajib diisi'),
|
||||
});
|
||||
|
||||
export const UpdateInitialBalanceFormSchema = InitialBalanceFormSchema;
|
||||
@@ -0,0 +1,380 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import TextInput from '@/components/input/TextInput';
|
||||
import {
|
||||
InitialBalanceFormSchema,
|
||||
InitialBalanceFormValues,
|
||||
} from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance.schema';
|
||||
import {
|
||||
FINANCE_INITIAL_BALANCE_TYPE_OPTIONS,
|
||||
FINANCE_PARTY_TYPE_OPTIONS,
|
||||
} from '@/config/constant';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatTitleCase } from '@/lib/helper';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
|
||||
import {
|
||||
CreateInitialBalance,
|
||||
Finance,
|
||||
UpdateInitialBalance,
|
||||
} from '@/types/api/finance/finance';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface FormFinanceAddInitialBalanceProps {
|
||||
type?: 'add' | 'edit';
|
||||
initialValues?: Finance;
|
||||
}
|
||||
|
||||
const FormFinanceAddInitialBalance = ({
|
||||
type = 'add',
|
||||
initialValues,
|
||||
}: FormFinanceAddInitialBalanceProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== Formik =====
|
||||
const formikInitialValues = useMemo((): InitialBalanceFormValues => {
|
||||
// Type assertion to handle potential initial_balance_type field
|
||||
const extendedInitialValues = initialValues as Finance & {
|
||||
initial_balance_type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
party_type_option:
|
||||
FINANCE_PARTY_TYPE_OPTIONS.find(
|
||||
(option) => option.value === initialValues?.party.type
|
||||
) || null,
|
||||
party_id_option: initialValues?.party
|
||||
? {
|
||||
label: initialValues.party.name,
|
||||
value: initialValues.party.id,
|
||||
}
|
||||
: null,
|
||||
bank_id_option: initialValues?.bank
|
||||
? {
|
||||
label: initialValues.bank.name,
|
||||
value: initialValues.bank.id,
|
||||
}
|
||||
: null,
|
||||
reference_number: initialValues?.reference_number || '',
|
||||
initial_balance_type_option:
|
||||
(initialValues?.nominal ?? 0) < 0
|
||||
? FINANCE_INITIAL_BALANCE_TYPE_OPTIONS.find(
|
||||
(option) => option.value === 'NEGATIVE'
|
||||
) || null
|
||||
: FINANCE_INITIAL_BALANCE_TYPE_OPTIONS.find(
|
||||
(option) => option.value === 'POSITIVE'
|
||||
) || null,
|
||||
nominal: initialValues?.nominal?.toString() || '',
|
||||
note: initialValues?.notes || '',
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
const formik = useFormik<InitialBalanceFormValues>({
|
||||
initialValues: formikInitialValues,
|
||||
validationSchema: InitialBalanceFormSchema,
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
onSubmit: async (values) => {
|
||||
const payload = transformFormValuesToPayload(values);
|
||||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
await createInitialBalance(payload);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
if (initialValues?.id) {
|
||||
await updateInitialBalance(initialValues.id, payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ===== Options =====
|
||||
const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } =
|
||||
useSelect(
|
||||
formik.values.party_type_option?.value === 'CUSTOMER'
|
||||
? CustomerApi.basePath
|
||||
: SupplierApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'',
|
||||
{ limit: 'limit' }
|
||||
);
|
||||
const {
|
||||
options: bankOptions,
|
||||
rawData: bankRawData,
|
||||
isLoadingOptions: isLoadingBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
|
||||
|
||||
// ===== Helper Functions =====
|
||||
const transformFormValuesToPayload = (
|
||||
values: InitialBalanceFormValues
|
||||
): CreateInitialBalance => {
|
||||
return {
|
||||
party_type: (values.party_type_option?.value as string) || '',
|
||||
party_id: Number(values.party_id_option?.value) || 0,
|
||||
bank_id: Number(values.bank_id_option?.value) || 0,
|
||||
reference_number: values.reference_number,
|
||||
initial_balance_type:
|
||||
(values.initial_balance_type_option?.value as string) || '',
|
||||
nominal: Number(values.nominal.replace(/\D/g, '')) || 0,
|
||||
note: values.note,
|
||||
};
|
||||
};
|
||||
|
||||
// ===== Handler =====
|
||||
const createInitialBalance = useCallback(
|
||||
async (payload: CreateInitialBalance) => {
|
||||
const response = await FinanceApi.createInitialBalances(payload);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Saldo awal berhasil ditambahkan');
|
||||
router.refresh();
|
||||
router.push('/finance');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const updateInitialBalance = useCallback(
|
||||
async (financeId: number, payload: UpdateInitialBalance) => {
|
||||
const response = await FinanceApi.updateInitialBalances(
|
||||
financeId,
|
||||
payload
|
||||
);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Saldo awal berhasil diperbarui');
|
||||
router.refresh();
|
||||
router.push('/finance');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-xl mx-auto'>
|
||||
<div className='flex flex-col gap-6 p-6'>
|
||||
<FormHeader
|
||||
title={`${type === 'add' ? 'Tambah' : 'Ubah'} Saldo Awal`}
|
||||
backUrl='/finance'
|
||||
/>
|
||||
<form className='flex flex-col gap-4' onSubmit={formik.handleSubmit}>
|
||||
<SelectInput
|
||||
label='Jenis Pihak'
|
||||
placeholder='Pilih jenis pihak'
|
||||
options={FINANCE_PARTY_TYPE_OPTIONS}
|
||||
value={formik.values.party_type_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('party_type_option', value);
|
||||
formik.setFieldValue('party_id_option', null);
|
||||
formik.setFieldValue('party_account_number', '');
|
||||
}}
|
||||
isError={Boolean(
|
||||
formik.touched.party_type_option &&
|
||||
formik.errors.party_type_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.party_type_option &&
|
||||
formik.errors.party_type_option
|
||||
? formik.errors.party_type_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
label={
|
||||
formik.values.party_type_option?.value
|
||||
? formatTitleCase(
|
||||
formik.values.party_type_option.value as string
|
||||
)
|
||||
: 'Pilih Jenis Pihak Dahulu'
|
||||
}
|
||||
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`}
|
||||
options={partyOptions}
|
||||
value={formik.values.party_id_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('party_id_option', value);
|
||||
}}
|
||||
isLoading={isLoadingPartyOptions}
|
||||
isError={Boolean(
|
||||
formik.touched.party_id_option && formik.errors.party_id_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.party_id_option && formik.errors.party_id_option
|
||||
? formik.errors.party_id_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
isDisabled={!formik.values.party_type_option?.value}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Bank'
|
||||
placeholder='Pilih bank'
|
||||
options={
|
||||
isResponseSuccess(bankRawData)
|
||||
? bankOptions.map((option) => ({
|
||||
label:
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.alias +
|
||||
' - ' +
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.account_number +
|
||||
' - ' +
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.owner,
|
||||
value: option.value,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
value={formik.values.bank_id_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('bank_id_option', value);
|
||||
}}
|
||||
isLoading={isLoadingBankOptions}
|
||||
isError={Boolean(
|
||||
formik.touched.bank_id_option && formik.errors.bank_id_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.bank_id_option && formik.errors.bank_id_option
|
||||
? formik.errors.bank_id_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
label='Nomor Referensi'
|
||||
placeholder='Masukkan nomor referensi'
|
||||
name='reference_number'
|
||||
value={formik.values.reference_number}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(
|
||||
formik.touched.reference_number &&
|
||||
formik.errors.reference_number
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.reference_number &&
|
||||
formik.errors.reference_number
|
||||
? formik.errors.reference_number
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<SelectInput
|
||||
label='Tipe Saldo Awal'
|
||||
placeholder='Pilih tipe saldo awal'
|
||||
options={FINANCE_INITIAL_BALANCE_TYPE_OPTIONS}
|
||||
value={formik.values.initial_balance_type_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('initial_balance_type_option', value);
|
||||
}}
|
||||
isError={Boolean(
|
||||
formik.touched.initial_balance_type_option &&
|
||||
formik.errors.initial_balance_type_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.initial_balance_type_option &&
|
||||
formik.errors.initial_balance_type_option
|
||||
? formik.errors.initial_balance_type_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<NumberInput
|
||||
label='Nominal'
|
||||
placeholder='Masukkan nominal'
|
||||
name='nominal'
|
||||
value={formik.values.nominal}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(formik.touched.nominal && formik.errors.nominal)}
|
||||
errorMessage={
|
||||
formik.touched.nominal && formik.errors.nominal
|
||||
? formik.errors.nominal
|
||||
: ''
|
||||
}
|
||||
allowNegative={false}
|
||||
startAdornment={
|
||||
formik.values.initial_balance_type_option?.value ===
|
||||
'POSITIVE' ? (
|
||||
<Icon icon='mdi:plus' />
|
||||
) : formik.values.initial_balance_type_option?.value ===
|
||||
'NEGATIVE' ? (
|
||||
<Icon icon='mdi:minus' />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
required
|
||||
/>
|
||||
<TextArea
|
||||
label='Catatan'
|
||||
placeholder='Masukkan catatan'
|
||||
name='note'
|
||||
value={formik.values.note}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(formik.touched.note && formik.errors.note)}
|
||||
errorMessage={
|
||||
formik.touched.note && formik.errors.note
|
||||
? formik.errors.note
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
type='reset'
|
||||
color='warning'
|
||||
className='w-min-24'
|
||||
onClick={() => formik.resetForm()}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='w-min-24'
|
||||
disabled={formik.isSubmitting || !formik.isValid}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormFinanceAddInitialBalance;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
// Type for form values (includes option objects for SelectInput)
|
||||
export type InjectionFormValues = {
|
||||
bank_id_option: OptionType | null;
|
||||
adjustment_date: string;
|
||||
nominal: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export const InjectionFormSchema = Yup.object<InjectionFormValues>({
|
||||
bank_id_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Bank wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
adjustment_date: Yup.string().required('Tanggal penyesuaian wajib diisi'),
|
||||
nominal: Yup.string().required('Nominal wajib diisi'),
|
||||
note: Yup.string().required('Catatan wajib diisi'),
|
||||
});
|
||||
|
||||
export const UpdateInjectionFormSchema = InjectionFormSchema;
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import {
|
||||
InjectionFormSchema,
|
||||
InjectionFormValues,
|
||||
} from '@/components/pages/finance/add/injection/FormFinanceInjection.schema';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
import {
|
||||
CreateInjection,
|
||||
Finance,
|
||||
UpdateInjection,
|
||||
} from '@/types/api/finance/finance';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface FormFinanceInjectionProps {
|
||||
type?: 'add' | 'edit';
|
||||
initialValues?: Finance;
|
||||
}
|
||||
|
||||
const FormFinanceInjection = ({
|
||||
type = 'add',
|
||||
initialValues,
|
||||
}: FormFinanceInjectionProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== Formik =====
|
||||
const formikInitialValues = useMemo((): InjectionFormValues => {
|
||||
return {
|
||||
bank_id_option: initialValues?.bank
|
||||
? {
|
||||
label: initialValues.bank.name,
|
||||
value: initialValues.bank.id,
|
||||
}
|
||||
: null,
|
||||
adjustment_date: initialValues?.payment_date || '',
|
||||
nominal: initialValues?.nominal?.toString() || '',
|
||||
note: initialValues?.notes || '',
|
||||
};
|
||||
}, [initialValues]);
|
||||
|
||||
const formik = useFormik<InjectionFormValues>({
|
||||
initialValues: formikInitialValues,
|
||||
validationSchema: InjectionFormSchema,
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
onSubmit: async (values) => {
|
||||
const payload = transformFormValuesToPayload(values);
|
||||
|
||||
switch (type) {
|
||||
case 'add':
|
||||
await createInjection(payload);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
if (initialValues?.id) {
|
||||
await updateInjection(initialValues.id, payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ===== Options =====
|
||||
const {
|
||||
options: bankOptions,
|
||||
rawData: bankRawData,
|
||||
isLoadingOptions: isLoadingBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
|
||||
|
||||
// ===== Helper Functions =====
|
||||
const transformFormValuesToPayload = (
|
||||
values: InjectionFormValues
|
||||
): CreateInjection => {
|
||||
return {
|
||||
bank_id: Number(values.bank_id_option?.value) || 0,
|
||||
adjustment_date: formatDate(values.adjustment_date, 'YYYY-MM-DD'),
|
||||
nominal: Number(values.nominal.replace(/\D/g, '')) || 0,
|
||||
notes: values.note,
|
||||
};
|
||||
};
|
||||
|
||||
// ===== Handler =====
|
||||
const createInjection = useCallback(
|
||||
async (payload: CreateInjection) => {
|
||||
const response = await FinanceApi.createInjections(payload);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Injeksi dana berhasil ditambahkan');
|
||||
router.refresh();
|
||||
router.push('/finance');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const updateInjection = useCallback(
|
||||
async (financeId: number, payload: UpdateInjection) => {
|
||||
const response = await FinanceApi.updateInjections(financeId, payload);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Injeksi dana berhasil diperbarui');
|
||||
router.refresh();
|
||||
router.push('/finance');
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-xl mx-auto'>
|
||||
<div className='flex flex-col gap-6 p-6'>
|
||||
<FormHeader
|
||||
title={`${type === 'add' ? 'Tambah' : 'Ubah'} Injeksi Dana`}
|
||||
backUrl='/finance'
|
||||
/>
|
||||
<form className='flex flex-col gap-4' onSubmit={formik.handleSubmit}>
|
||||
<SelectInput
|
||||
label='Bank'
|
||||
placeholder='Pilih bank'
|
||||
options={
|
||||
isResponseSuccess(bankRawData)
|
||||
? bankOptions.map((option) => ({
|
||||
label:
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.alias +
|
||||
' - ' +
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.account_number +
|
||||
' - ' +
|
||||
bankRawData.data?.find(
|
||||
(item) => item.id === option.value
|
||||
)?.owner,
|
||||
value: option.value,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
value={formik.values.bank_id_option}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('bank_id_option', value);
|
||||
}}
|
||||
isLoading={isLoadingBankOptions}
|
||||
isError={Boolean(
|
||||
formik.touched.bank_id_option && formik.errors.bank_id_option
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.bank_id_option && formik.errors.bank_id_option
|
||||
? formik.errors.bank_id_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<DateInput
|
||||
label='Tanggal Penyesuaian'
|
||||
placeholder='Pilih tanggal penyesuaian'
|
||||
name='adjustment_date'
|
||||
value={formik.values.adjustment_date}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(
|
||||
formik.touched.adjustment_date && formik.errors.adjustment_date
|
||||
)}
|
||||
errorMessage={
|
||||
formik.touched.adjustment_date && formik.errors.adjustment_date
|
||||
? formik.errors.adjustment_date
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label='Nominal'
|
||||
placeholder='Masukkan nominal'
|
||||
name='nominal'
|
||||
value={formik.values.nominal}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(formik.touched.nominal && formik.errors.nominal)}
|
||||
errorMessage={
|
||||
formik.touched.nominal && formik.errors.nominal
|
||||
? formik.errors.nominal
|
||||
: ''
|
||||
}
|
||||
allowNegative={true}
|
||||
required
|
||||
/>
|
||||
<TextArea
|
||||
label='Catatan'
|
||||
placeholder='Masukkan catatan'
|
||||
name='note'
|
||||
value={formik.values.note}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={Boolean(formik.touched.note && formik.errors.note)}
|
||||
errorMessage={
|
||||
formik.touched.note && formik.errors.note
|
||||
? formik.errors.note
|
||||
: ''
|
||||
}
|
||||
required
|
||||
/>
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
type='reset'
|
||||
color='warning'
|
||||
className='w-min-24'
|
||||
onClick={() => formik.resetForm()}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='w-min-24'
|
||||
disabled={formik.isSubmitting || !formik.isValid}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormFinanceInjection;
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import Badge from '@/components/Badge';
|
||||
import Button from '@/components/Button';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import Table from '@/components/Table';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
@@ -77,46 +79,27 @@ const InventoryAdjustmentTable = () => {
|
||||
year: 'numeric',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'before_quantity',
|
||||
header: 'Stok Sebelum',
|
||||
accessorFn: (row) => formatNumber(String(row.before_quantity)),
|
||||
},
|
||||
{
|
||||
id: 'after_quantity',
|
||||
header: 'Stok Sesudah',
|
||||
accessorFn: (row) => formatNumber(String(row.after_quantity)),
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
header: 'Kuantitas',
|
||||
accessorFn: (row) => formatNumber(String(row.quantity)),
|
||||
accessorFn: (row) => formatNumber(String(row.increase + row.decrease)),
|
||||
},
|
||||
{
|
||||
id: 'transaction_type',
|
||||
header: 'Tipe Transaksi',
|
||||
accessorFn: (row) => {
|
||||
if (row.transaction_type === 'INCREASE') return 'Peningkatan';
|
||||
if (row.transaction_type === 'DECREASE') return 'Penurunan';
|
||||
if (row.increase > 0) return 'Peningkatan';
|
||||
if (row.decrease > 0) return 'Penurunan';
|
||||
return '-';
|
||||
},
|
||||
cell: (props) => {
|
||||
const type = props.row.original.transaction_type;
|
||||
const label =
|
||||
type === 'INCREASE'
|
||||
? 'Peningkatan'
|
||||
: type === 'DECREASE'
|
||||
? 'Penurunan'
|
||||
: '-';
|
||||
const type = props.row.original.increase;
|
||||
const label = type > 0 ? 'Peningkatan' : type <= 0 ? 'Penurunan' : '-';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`small mx-auto badge badge-soft ${
|
||||
type === 'INCREASE' ? 'badge-success' : 'badge-error'
|
||||
}`}
|
||||
>
|
||||
<Badge variant='soft' color={type > 0 ? 'success' : 'error'}>
|
||||
{label}
|
||||
</div>
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -181,23 +164,17 @@ const InventoryAdjustmentTable = () => {
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||
<div className='w-full flex flex-row'>
|
||||
<Button
|
||||
href='/inventory/adjustment/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
|
||||
{/* <DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Stock Adjustment'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/> */}
|
||||
<RequirePermission permissions='lti.inventory.create'>
|
||||
<Button
|
||||
href='/inventory/adjustment/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-row justify-end'>
|
||||
|
||||
@@ -76,7 +76,7 @@ const InventoryAdjustmentForm = ({
|
||||
product_category: undefined,
|
||||
product: undefined,
|
||||
warehouse: undefined,
|
||||
quantity: initialValues?.quantity ?? 0,
|
||||
quantity: initialValues?.increase ?? initialValues?.decrease ?? 0,
|
||||
transaction_type: undefined,
|
||||
note: initialValues?.note ?? '',
|
||||
};
|
||||
@@ -214,16 +214,8 @@ const InventoryAdjustmentForm = ({
|
||||
'quantity',
|
||||
initialValues.product_warehouse.quantity
|
||||
);
|
||||
formik.setFieldValue(
|
||||
'transaction_type',
|
||||
initialValues.transaction_type.toLowerCase()
|
||||
);
|
||||
formik.setFieldValue('note', initialValues.note);
|
||||
}
|
||||
if (initialValues?.transaction_type) {
|
||||
const type = initialValues.transaction_type.toLowerCase();
|
||||
setQuantityLabel(type === 'increase' ? 'Tambah Stok' : 'Kurangi Stok');
|
||||
}
|
||||
}, [
|
||||
formik,
|
||||
initialValues,
|
||||
@@ -278,26 +270,6 @@ const InventoryAdjustmentForm = ({
|
||||
className='w-full mt-8 flex flex-col gap-6'
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Text Input Before Quantity */}
|
||||
{type === 'detail' && initialValues && (
|
||||
<>
|
||||
<TextInput
|
||||
label='Stok Sebelum'
|
||||
name='before_quantity'
|
||||
type='text'
|
||||
value={formatNumber(String(initialValues.before_quantity))}
|
||||
readOnly={true}
|
||||
/>
|
||||
<TextInput
|
||||
label='Stok Setelah'
|
||||
name='after_quantity'
|
||||
type='text'
|
||||
readOnly={true}
|
||||
value={formatNumber(String(initialValues.after_quantity))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Select Input Product Category */}
|
||||
<SelectInput
|
||||
required
|
||||
|
||||
@@ -19,6 +19,7 @@ import SelectInput 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';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
@@ -28,15 +29,17 @@ const RowOptionsMenu = ({
|
||||
props: CellContext<Movement, unknown>;
|
||||
}) => (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<Button
|
||||
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.inventory.transfer.detail'>
|
||||
<Button
|
||||
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
|
||||
@@ -145,15 +148,17 @@ const MovementTable = () => {
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||
<div className='w-full flex flex-row gap-2'>
|
||||
<Button
|
||||
href='/inventory/movement/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.inventory.transfer.create'>
|
||||
<Button
|
||||
href='/inventory/movement/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
|
||||
@@ -7,6 +7,7 @@ import Table from '@/components/Table';
|
||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
|
||||
@@ -31,15 +32,17 @@ const RowOptionsMenu = ({
|
||||
props: CellContext<InventoryProduct, unknown>;
|
||||
}) => (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<Button
|
||||
href={`/inventory/product/detail?inventoryProductId=${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 permissions='lti.inventory.product_stock.detail'>
|
||||
<Button
|
||||
href={`/inventory/product/detail?inventoryProductId=${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>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
|
||||
|
||||
@@ -13,8 +13,12 @@ const InventoryProductDetail = ({
|
||||
}) => {
|
||||
const stockLogs = useMemo(() => {
|
||||
return (
|
||||
inventoryProduct?.product_warehouses?.flatMap(
|
||||
(warehouse) => warehouse.stock_logs || []
|
||||
inventoryProduct?.product_warehouses?.flatMap((warehouse) =>
|
||||
warehouse.stock_logs.map((log) => ({
|
||||
...log,
|
||||
warehouse_name: warehouse.warehouse_name,
|
||||
warehouse_id: warehouse.warehouse_id,
|
||||
}))
|
||||
) || []
|
||||
);
|
||||
}, [inventoryProduct]);
|
||||
|
||||
@@ -3,7 +3,11 @@ import Table from '@/components/Table';
|
||||
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
|
||||
import { StockLog } from '@/types/api/inventory/product';
|
||||
|
||||
const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
|
||||
const StockLogTable = ({
|
||||
stockLogs,
|
||||
}: {
|
||||
stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[];
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
title='Informasi Stock Produk'
|
||||
@@ -27,6 +31,10 @@ const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
|
||||
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Gudang',
|
||||
accessorKey: 'warehouse_name',
|
||||
},
|
||||
{
|
||||
header: 'Peningkatan',
|
||||
accessorKey: 'increase',
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
@@ -26,6 +29,10 @@ import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { CustomerApi, ProductApi } from '@/services/api/master-data';
|
||||
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
|
||||
|
||||
const RowsOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
@@ -50,57 +57,75 @@ const RowsOptionsMenu = ({
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Button
|
||||
href={`/marketing/detail?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
{props.row.original.latest_approval.step_number != 1 && (
|
||||
<RequirePermission permissions='lti.marketing.delivery_order.detail'>
|
||||
<Button
|
||||
href={
|
||||
props.row.original.latest_approval.step_number == 3
|
||||
? `/marketing/detail/delivery-orders/edit?marketingId=${props.row.original.id}`
|
||||
: props.row.original.latest_approval.step_number == 2
|
||||
? `/marketing/add/delivery-orders?marketingId=${props.row.original.id}`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (props.row.original.latest_approval.step_number == 2) {
|
||||
deliveryClickHandler?.();
|
||||
}
|
||||
}}
|
||||
href={`/marketing/detail?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='success'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:truck' width={16} height={16} />
|
||||
Deliver
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
{props.row.original.latest_approval.step_number != 1 && (
|
||||
<>
|
||||
<RequirePermission
|
||||
permissions={
|
||||
props.row.original.latest_approval.step_number == 3
|
||||
? 'lti.marketing.delivery_order.update'
|
||||
: 'lti.marketing.delivery_order.create'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
href={
|
||||
props.row.original.latest_approval.step_number == 3
|
||||
? `/marketing/detail/delivery-orders/edit?marketingId=${props.row.original.id}`
|
||||
: props.row.original.latest_approval.step_number == 2
|
||||
? `/marketing/add/delivery-orders?marketingId=${props.row.original.id}`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (props.row.original.latest_approval.step_number == 2) {
|
||||
deliveryClickHandler?.();
|
||||
}
|
||||
}}
|
||||
variant='ghost'
|
||||
color='success'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:truck' width={16} height={16} />
|
||||
Deliver
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
{props.row.original.latest_approval.step_number != 3 && (
|
||||
<Button
|
||||
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
<>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.update'>
|
||||
<Button
|
||||
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='text-error hover:text-inherit justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:delete-outline' width={16} height={16} />
|
||||
Delete
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='text-error hover:text-inherit justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:delete-outline' width={16} height={16} />
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -108,32 +133,77 @@ const RowsOptionsMenu = ({
|
||||
|
||||
const MarketingTable = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [approveAction, setApproveAction] = useState<'APPROVED' | 'REJECTED'>(
|
||||
'APPROVED'
|
||||
);
|
||||
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const { permissionCheck } = useAuth();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
data: marketing,
|
||||
isLoading: isLoadingMarketing,
|
||||
mutate: refreshMarketing,
|
||||
} = useSWR(MarketingApi.basePath, MarketingApi.getAllFetcher);
|
||||
|
||||
const deleteModal = useModal();
|
||||
const confirmationModal = useModal();
|
||||
const productsModal = useModal();
|
||||
const deliveryModal = useModal();
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterToQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
product_ids: '',
|
||||
status: '',
|
||||
customer_id: '',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
product_ids: 'product_ids',
|
||||
status: 'status',
|
||||
customer_id: 'customer_id',
|
||||
},
|
||||
});
|
||||
|
||||
// ===== FETCH DATA =====
|
||||
const {
|
||||
data: marketing,
|
||||
isLoading: isLoadingMarketing,
|
||||
mutate: refreshMarketing,
|
||||
} = useSWR(
|
||||
`${MarketingApi.basePath}${getTableFilterToQueryString()}`,
|
||||
MarketingApi.getAllFetcher
|
||||
);
|
||||
|
||||
// ===== OPTIONS =====
|
||||
const {
|
||||
options: productsOptions,
|
||||
isLoadingOptions: isLoadingProductsOptions,
|
||||
} = useSelect(ProductApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
const {
|
||||
options: customersOptions,
|
||||
isLoadingOptions: isLoadingCustomersOptions,
|
||||
} = useSelect(CustomerApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
const statusOptions = MARKETING_APPROVAL_LINE.map((item) => ({
|
||||
value: item.step_number,
|
||||
label: item.step_name,
|
||||
}));
|
||||
|
||||
// ===== HANDLER =====
|
||||
const searchChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
updateFilter('page', 1);
|
||||
updateFilter('search', e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -141,7 +211,8 @@ const MarketingTable = () => {
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const newVal = val as OptionType;
|
||||
setPageSize(newVal.value as number);
|
||||
setPage(1);
|
||||
updateFilter('page', 1);
|
||||
updateFilter('limit', newVal.value as number);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -246,20 +317,6 @@ const MarketingTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
toQueryString: getTableFilterToQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
});
|
||||
|
||||
const getRowCanSelect = (row: Row<Marketing>): boolean => {
|
||||
const approval = row.original.latest_approval;
|
||||
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
|
||||
@@ -270,10 +327,14 @@ const MarketingTable = () => {
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<TableToolbar
|
||||
addButton={{
|
||||
href: '/marketing/add/sales-orders',
|
||||
label: 'Tambah Sales Order',
|
||||
}}
|
||||
addButton={
|
||||
permissionCheck('lti.marketing.sales_order.create')
|
||||
? {
|
||||
href: '/marketing/add/sales-orders',
|
||||
label: 'Tambah Sales Order',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
search={{
|
||||
value: search,
|
||||
onChange: searchChangeHandler,
|
||||
@@ -281,37 +342,64 @@ const MarketingTable = () => {
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-row gap-2'>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={approveClickHandler}
|
||||
className='justify-start text-sm'
|
||||
disabled={disableApprove}
|
||||
>
|
||||
<Icon icon='material-symbols:check' width={24} height={24} />
|
||||
Approve
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.approve'>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={approveClickHandler}
|
||||
className='justify-start text-sm'
|
||||
disabled={disableApprove}
|
||||
>
|
||||
<Icon icon='material-symbols:check' width={24} height={24} />
|
||||
Approve
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<Button
|
||||
color='error'
|
||||
onClick={rejectClickHandler}
|
||||
className='justify-start text-sm'
|
||||
disabled={disableReject}
|
||||
>
|
||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||
Reject
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.approve'>
|
||||
<Button
|
||||
color='error'
|
||||
onClick={rejectClickHandler}
|
||||
className='justify-start text-sm'
|
||||
disabled={disableReject}
|
||||
>
|
||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
<TableRowSizeSelector
|
||||
value={pageSize}
|
||||
value={tableFilterState.pageSize}
|
||||
onChange={pageSizeChangeHandler}
|
||||
options={ROWS_OPTIONS}
|
||||
className='flex sm:flex-row flex-col gap-3 items-end justify-end'
|
||||
>
|
||||
{/* select multiple product */}
|
||||
<SelectInput
|
||||
label='Product'
|
||||
isClearable
|
||||
placeholder='Pilih product'
|
||||
options={[]}
|
||||
options={productsOptions}
|
||||
isLoading={isLoadingProductsOptions}
|
||||
value={
|
||||
tableFilterState.product_ids
|
||||
?.split(',')
|
||||
.map((id) =>
|
||||
productsOptions.find(
|
||||
(option) => option.value === Number(id)
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(option): option is { value: number; label: string } =>
|
||||
option !== undefined
|
||||
) ?? null
|
||||
}
|
||||
onChange={(value: OptionType | OptionType[] | null) =>
|
||||
updateFilter(
|
||||
'product_ids',
|
||||
(value as OptionType[])
|
||||
?.map((item: OptionType) => item.value.toString())
|
||||
.join(',') || ''
|
||||
)
|
||||
}
|
||||
isMulti
|
||||
/>
|
||||
{/* select status */}
|
||||
@@ -319,14 +407,43 @@ const MarketingTable = () => {
|
||||
label='Status'
|
||||
isClearable
|
||||
placeholder='Pilih status'
|
||||
options={[]}
|
||||
options={statusOptions}
|
||||
value={
|
||||
tableFilterState.status
|
||||
? statusOptions.find(
|
||||
(option) =>
|
||||
option.value === Number(tableFilterState.status)
|
||||
)
|
||||
: null
|
||||
}
|
||||
onChange={(value: OptionType | OptionType[] | null) =>
|
||||
updateFilter(
|
||||
'status',
|
||||
(value as OptionType)?.value.toString() || ''
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* select customer */}
|
||||
<SelectInput
|
||||
label='Customer'
|
||||
isClearable
|
||||
placeholder='Pilih customer'
|
||||
options={[]}
|
||||
options={customersOptions}
|
||||
isLoading={isLoadingCustomersOptions}
|
||||
value={
|
||||
tableFilterState.customer_id
|
||||
? customersOptions.find(
|
||||
(option) =>
|
||||
option.value === Number(tableFilterState.customer_id)
|
||||
)
|
||||
: null
|
||||
}
|
||||
onChange={(value: OptionType | OptionType[] | null) =>
|
||||
updateFilter(
|
||||
'customer_id',
|
||||
(value as OptionType)?.value.toString() || ''
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableRowSizeSelector>
|
||||
</div>
|
||||
@@ -492,8 +609,8 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
]}
|
||||
pageSize={pageSize}
|
||||
page={page}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={tableFilterState.page}
|
||||
onPageChange={setPage}
|
||||
className={{
|
||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||
@@ -617,6 +734,7 @@ const MarketingTable = () => {
|
||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
isLoading={isLoadingMarketing}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
|
||||
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
const MarketingDetail = ({
|
||||
initialValues,
|
||||
@@ -134,6 +135,19 @@ const MarketingDetail = ({
|
||||
<div className='flex-row flex gap-3'>
|
||||
{initialValues?.latest_approval?.step_number == 1 && (
|
||||
<>
|
||||
{/* <RequirePermission permissions='lti.marketing.sales_order.approve'>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={approveClickHandler}
|
||||
disabled={
|
||||
initialValues?.latest_approval?.step_number == 1 &&
|
||||
initialValues?.latest_approval?.action == 'REJECTED'
|
||||
}
|
||||
>
|
||||
<Icon icon='mdi:check' width={24} height={24} />
|
||||
Approve
|
||||
</Button>
|
||||
</RequirePermission> */}
|
||||
<Button
|
||||
color='success'
|
||||
onClick={approveClickHandler}
|
||||
@@ -145,6 +159,20 @@ const MarketingDetail = ({
|
||||
<Icon icon='mdi:check' width={24} height={24} />
|
||||
Approve
|
||||
</Button>
|
||||
|
||||
{/* <RequirePermission permissions='lti.marketing.sales_order.approve'>
|
||||
<Button
|
||||
color='error'
|
||||
onClick={rejectClickHandler}
|
||||
disabled={
|
||||
initialValues?.latest_approval?.step_number == 1 &&
|
||||
initialValues?.latest_approval?.action == 'REJECTED'
|
||||
}
|
||||
>
|
||||
<Icon icon='mdi:close' width={24} height={24} />
|
||||
Reject
|
||||
</Button>
|
||||
</RequirePermission> */}
|
||||
<Button
|
||||
color='error'
|
||||
onClick={rejectClickHandler}
|
||||
@@ -159,20 +187,44 @@ const MarketingDetail = ({
|
||||
</>
|
||||
)}
|
||||
{initialValues?.latest_approval?.step_number != 1 && (
|
||||
<Button
|
||||
color='success'
|
||||
href={
|
||||
initialValues?.latest_approval?.step_number == 3
|
||||
? `/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
|
||||
: `/marketing/add/delivery-orders?marketingId=${initialValues?.id}`
|
||||
}
|
||||
>
|
||||
<Icon icon='mdi:truck' width={24} height={24} />
|
||||
{initialValues?.latest_approval?.step_number == 3
|
||||
? 'Edit '
|
||||
: 'Tambah '}
|
||||
Delivery Order
|
||||
</Button>
|
||||
<>
|
||||
{/* <RequirePermission
|
||||
permissions={
|
||||
initialValues?.latest_approval?.step_number == 3
|
||||
? 'lti.marketing.delivery_order.update'
|
||||
: 'lti.marketing.delivery_order.create'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
color='success'
|
||||
href={
|
||||
initialValues?.latest_approval?.step_number == 3
|
||||
? `/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
|
||||
: `/marketing/add/delivery-orders?marketingId=${initialValues?.id}`
|
||||
}
|
||||
>
|
||||
<Icon icon='mdi:truck' width={24} height={24} />
|
||||
{initialValues?.latest_approval?.step_number == 3
|
||||
? 'Edit '
|
||||
: 'Tambah '}
|
||||
Delivery Order
|
||||
</Button>
|
||||
</RequirePermission> */}
|
||||
<Button
|
||||
color='success'
|
||||
href={
|
||||
initialValues?.latest_approval?.step_number == 3
|
||||
? `/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
|
||||
: `/marketing/add/delivery-orders?marketingId=${initialValues?.id}`
|
||||
}
|
||||
>
|
||||
<Icon icon='mdi:truck' width={24} height={24} />
|
||||
{initialValues?.latest_approval?.step_number == 3
|
||||
? 'Edit '
|
||||
: 'Tambah '}
|
||||
Delivery Order
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -413,15 +465,33 @@ const MarketingDetail = ({
|
||||
)}
|
||||
<div className='flex flex-row gap-3'>
|
||||
{initialValues?.latest_approval?.step_number != 3 && (
|
||||
<Button
|
||||
color='warning'
|
||||
type='button'
|
||||
href={`/marketing/detail/${initialValues?.latest_approval.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
|
||||
>
|
||||
<Icon icon='mdi:pencil' width={24} height={24} />
|
||||
Edit
|
||||
</Button>
|
||||
<>
|
||||
{/* <RequirePermission permissions='lti.marketing.sales_order.update'>
|
||||
<Button
|
||||
color='warning'
|
||||
type='button'
|
||||
href={`/marketing/detail/${initialValues?.latest_approval?.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
|
||||
>
|
||||
<Icon icon='mdi:pencil' width={24} height={24} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission> */}
|
||||
<Button
|
||||
color='warning'
|
||||
type='button'
|
||||
href={`/marketing/detail/${initialValues?.latest_approval?.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
|
||||
>
|
||||
<Icon icon='mdi:pencil' width={24} height={24} />
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* <RequirePermission permissions='lti.marketing.sales_order.delete'>
|
||||
<Button color='error' onClick={deleteClickHandler}>
|
||||
<Icon icon='mdi:delete' width={24} height={24} />
|
||||
Hapus
|
||||
</Button>
|
||||
</RequirePermission> */}
|
||||
<Button color='error' onClick={deleteClickHandler}>
|
||||
<Icon icon='mdi:delete' width={24} height={24} />
|
||||
Hapus
|
||||
|
||||
@@ -47,6 +47,7 @@ import DeliveryOrderProductTable from '@/components/pages/marketing/form/table-v
|
||||
import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct';
|
||||
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
|
||||
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
|
||||
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
|
||||
@@ -575,7 +576,7 @@ const MarketingForm = ({
|
||||
wrapper: 'bg-white w-full',
|
||||
}}
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3 mt-3'>
|
||||
<div className='grid sm:grid-cols-2 gap-3 mt-3'>
|
||||
<SelectInput
|
||||
label='Pelanggan'
|
||||
options={customerOptions}
|
||||
@@ -650,7 +651,7 @@ const MarketingForm = ({
|
||||
)}
|
||||
|
||||
{/* Input Notes */}
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='grid sm:grid-cols-2 gap-3'>
|
||||
<DebouncedTextArea
|
||||
required
|
||||
name='notes'
|
||||
@@ -689,6 +690,17 @@ const MarketingForm = ({
|
||||
{/* Actions button */}
|
||||
{formType == 'edit' && (
|
||||
<div className='flex flex-row justify-start'>
|
||||
{/* <RequirePermission permissions='lti.marketing.sales_order.delete'>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={handleDelete}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<Icon icon='mdi:trash' width={24} height={24} />
|
||||
Hapus
|
||||
</Button>
|
||||
</RequirePermission> */}
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
|
||||
+1
-14
@@ -174,26 +174,13 @@ const DeliveryOrderProductForm = ({
|
||||
}}
|
||||
onReset={handleResetForm}
|
||||
>
|
||||
{/* <small className='block text-blue-500'>
|
||||
{JSON.stringify(exisitingValues)}
|
||||
</small>
|
||||
<small className='block text-emerald-500'>
|
||||
{JSON.stringify(formik.values)}
|
||||
</small> */}
|
||||
{/* <small className='block text-red-500'>
|
||||
{JSON.stringify(formik.errors)}
|
||||
</small>
|
||||
<div className='hidden'>
|
||||
{JSON.stringify(formik.values.marketing_product)}
|
||||
</div> */}
|
||||
|
||||
{formikErrorMessage && (
|
||||
<div onClick={() => setFormErrorMessage('')} className='my-3 w-full'>
|
||||
<Alert color='error'>{formikErrorMessage}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<SelectInput
|
||||
options={options}
|
||||
label='Produk'
|
||||
|
||||
@@ -11,7 +11,7 @@ import SelectInput, {
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import { KandangApi, WarehouseApi } from '@/services/api/master-data';
|
||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||
import { ProductWarehouseApi } from '@/services/api/inventory';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
@@ -61,7 +61,7 @@ const SalesOrderProductForm = ({
|
||||
const {
|
||||
options: kandangSourceOptions,
|
||||
isLoadingOptions: isLoadingKandangSourceOptions,
|
||||
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
|
||||
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
options: warehouseSourceOptions,
|
||||
@@ -183,7 +183,7 @@ const SalesOrderProductForm = ({
|
||||
{/* <small className='block text-rose-500'>
|
||||
{JSON.stringify(formik.errors)}
|
||||
</small> */}
|
||||
<div className='grid grid-cols-2 gap-4 z-200'>
|
||||
<div className='grid sm:grid-cols-2 gap-4 z-200'>
|
||||
<PatternInput
|
||||
name='vehicle_number'
|
||||
label='No. Polisi'
|
||||
|
||||
@@ -15,6 +15,7 @@ import SelectInput, { OptionType } 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 { Area } from '@/types/api/master-data/area';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
@@ -34,40 +35,46 @@ const RowOptionsMenu = ({
|
||||
}) => {
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<Button
|
||||
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='text-error hover:text-inherit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
<RequirePermission permissions='lti.master.area.detail'>
|
||||
<Button
|
||||
href={`/master-data/area/detail/?areaId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.area.update'>
|
||||
<Button
|
||||
href={`/master-data/area/detail/edit/?areaId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.area.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='text-error hover:text-inherit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
};
|
||||
@@ -192,15 +199,19 @@ const AreasTable = () => {
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||
<div className='w-full flex flex-row'>
|
||||
<Button
|
||||
href='/master-data/area/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
<div className='w-full flex flex-row'>
|
||||
<RequirePermission permissions='lti.master.area.create'>
|
||||
<Button
|
||||
href='/master-data/area/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from '@/components/Button';
|
||||
import TextInput from '@/components/input/TextInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
import {
|
||||
AreaFormSchema,
|
||||
@@ -160,36 +161,40 @@ const AreaForm = ({ type = 'add', initialValues }: AreaFormProps) => {
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
{type !== 'add' && (
|
||||
<div className='flex flex-row justify-start gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={deleteAreaClickHandler}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{type !== 'edit' && (
|
||||
<RequirePermission permissions='lti.master.area.delete'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/master-data/area/detail/edit/?areaId=${initialValues?.id}`}
|
||||
color='error'
|
||||
onClick={deleteAreaClickHandler}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{type !== 'edit' && (
|
||||
<RequirePermission permissions='lti.master.area.update'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/master-data/area/detail/edit/?areaId=${initialValues?.id}`}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import SelectInput, { OptionType } 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 { Bank } from '@/types/api/master-data/bank';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
@@ -34,40 +35,46 @@ const RowOptionsMenu = ({
|
||||
}) => {
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<Button
|
||||
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
<RequirePermission permissions='lti.master.banks.detail'>
|
||||
<Button
|
||||
href={`/master-data/bank/detail/?bankId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='primary'
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
>
|
||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.banks.update'>
|
||||
<Button
|
||||
href={`/master-data/bank/detail/edit/?bankId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
<RequirePermission permissions='lti.master.banks.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
variant='ghost'
|
||||
color='error'
|
||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</RowOptionsMenuWrapper>
|
||||
);
|
||||
};
|
||||
@@ -205,15 +212,17 @@ const BanksTable = () => {
|
||||
<div className='flex flex-col gap-2 mb-4'>
|
||||
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
|
||||
<div className='w-full flex flex-row'>
|
||||
<Button
|
||||
href='/master-data/bank/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
<RequirePermission permissions='lti.master.banks.create'>
|
||||
<Button
|
||||
href='/master-data/bank/add'
|
||||
variant='outline'
|
||||
color='primary'
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||
Tambah
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from '@/components/Button';
|
||||
import TextInput from '@/components/input/TextInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
|
||||
import {
|
||||
BankFormSchema,
|
||||
@@ -208,36 +209,40 @@ const BankForm = ({ type = 'add', initialValues }: BankFormProps) => {
|
||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
{type !== 'add' && (
|
||||
<div className='flex flex-row justify-start gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
color='error'
|
||||
onClick={deleteBankClickHandler}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{type !== 'edit' && (
|
||||
<RequirePermission permissions='lti.master.banks.delete'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/master-data/bank/detail/edit/?bankId=${initialValues?.id}`}
|
||||
color='error'
|
||||
onClick={deleteBankClickHandler}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
icon='material-symbols:delete-outline-rounded'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
Delete
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
|
||||
{type !== 'edit' && (
|
||||
<RequirePermission permissions='lti.master.banks.update'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/master-data/bank/detail/edit/?bankId=${initialValues?.id}`}
|
||||
className='px-4'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user