diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ee8a79a5..e02ea8ee 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 - diff --git a/.husky/pre-commit b/.husky/pre-commit index 3782914b..ff51d55a 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,3 @@ npm run format npm run lint -npm run build \ No newline at end of file +npx tsc --noEmit \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f0212474..d7ffd3eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 52fc6ce2..319f0e3d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/app/closing/detail/page.tsx b/src/app/closing/detail/page.tsx index 1b4ebc45..62f3fa20 100644 --- a/src/app/closing/detail/page.tsx +++ b/src/app/closing/detail/page.tsx @@ -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 (
@@ -50,6 +55,11 @@ const ClosingDetailPage = () => { id={Number(closingId)} initialValue={closing.data} salesData={isResponseSuccess(salesData) ? salesData.data : undefined} + hppExpeditionData={ + isResponseSuccess(hppEkspedisiData) + ? hppEkspedisiData.data + : undefined + } /> )}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 4f2c344e..426cf6b9 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,9 +1,7 @@ +import DashboardProduction from '@/components/pages/dashboard/DashboardProduction'; + const Dashboard = () => { - return ( -
-

Dashboard

-
- ); + return ; }; export default Dashboard; diff --git a/src/app/finance/add/adjust/page.tsx b/src/app/finance/add/adjust/page.tsx new file mode 100644 index 00000000..3536892d --- /dev/null +++ b/src/app/finance/add/adjust/page.tsx @@ -0,0 +1,5 @@ +const FinanceAdjust = () => { + return
Finance Adjust
; +}; + +export default FinanceAdjust; diff --git a/src/app/finance/add/initial-balance/page.tsx b/src/app/finance/add/initial-balance/page.tsx new file mode 100644 index 00000000..fb3114ad --- /dev/null +++ b/src/app/finance/add/initial-balance/page.tsx @@ -0,0 +1,7 @@ +import FormFinanceAddInitialBalance from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance'; + +const FinanceAddInitialBalancePage = () => { + return ; +}; + +export default FinanceAddInitialBalancePage; diff --git a/src/app/finance/add/injection/page.tsx b/src/app/finance/add/injection/page.tsx new file mode 100644 index 00000000..502df04b --- /dev/null +++ b/src/app/finance/add/injection/page.tsx @@ -0,0 +1,7 @@ +import FormFinanceInjection from '@/components/pages/finance/add/injection/FormFinanceInjection'; + +const FinanceAddInjectionPage = () => { + return ; +}; + +export default FinanceAddInjectionPage; diff --git a/src/app/finance/add/page.tsx b/src/app/finance/add/page.tsx new file mode 100644 index 00000000..162cd7ec --- /dev/null +++ b/src/app/finance/add/page.tsx @@ -0,0 +1,7 @@ +import FormFinanceAdd from '@/components/pages/finance/add/FormFinanceAdd'; + +const FinanceAddPage = () => { + return ; +}; + +export default FinanceAddPage; diff --git a/src/app/finance/detail/edit/initial-balance/page.tsx b/src/app/finance/detail/edit/initial-balance/page.tsx new file mode 100644 index 00000000..fddb46d9 --- /dev/null +++ b/src/app/finance/detail/edit/initial-balance/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (!isLoadingFinance && (!finance || isResponseError(finance))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFinance && ( + + )} + + {!isLoadingFinance && ( + + )} +
+ ); +}; + +export default EditFinanceInitialBalancePage; diff --git a/src/app/finance/detail/edit/injection/page.tsx b/src/app/finance/detail/edit/injection/page.tsx new file mode 100644 index 00000000..a538ffd1 --- /dev/null +++ b/src/app/finance/detail/edit/injection/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (!isLoadingFinance && (!finance || isResponseError(finance))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFinance && ( + + )} + + {!isLoadingFinance && ( + + )} +
+ ); +}; + +export default EditFinanceInjectionPage; diff --git a/src/app/finance/detail/edit/page.tsx b/src/app/finance/detail/edit/page.tsx new file mode 100644 index 00000000..93a0daea --- /dev/null +++ b/src/app/finance/detail/edit/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (!isLoadingFinance && (!finance || isResponseError(finance))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFinance && ( + + )} + + {!isLoadingFinance && ( + + )} +
+ ); +}; + +export default EditFinanceTransactionPage; diff --git a/src/app/production/project-flock/chickin/add/layout.tsx b/src/app/finance/detail/layout.tsx similarity index 100% rename from src/app/production/project-flock/chickin/add/layout.tsx rename to src/app/finance/detail/layout.tsx diff --git a/src/app/finance/detail/page.tsx b/src/app/finance/detail/page.tsx new file mode 100644 index 00000000..1d20e9f5 --- /dev/null +++ b/src/app/finance/detail/page.tsx @@ -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 ( +
+ +
+ ); + } + + console.log(finance); + + // if (!finance || isResponseError(finance)) { + // router.replace('/404'); + // return; + // } + + return ( + <> + {isResponseSuccess(finance) && } + + ); +}; + +export default FinanceDetailPage; diff --git a/src/app/finance/page.tsx b/src/app/finance/page.tsx new file mode 100644 index 00000000..ec78820c --- /dev/null +++ b/src/app/finance/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import FinanceTable from '@/components/pages/finance/FinanceTable'; + +const Finance = () => { + return ( +
+
+ +
+ ); +}; + +export default Finance; diff --git a/src/app/master-data/production-standard/add/page.tsx b/src/app/master-data/production-standard/add/page.tsx new file mode 100644 index 00000000..f25338d6 --- /dev/null +++ b/src/app/master-data/production-standard/add/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import ProductionStandardForm from '@/components/pages/master-data/production-standard/form/ProductionStandardForm'; + +const AddProductionStandardPage = () => { + return ( + <> + + + ); +}; + +export default AddProductionStandardPage; diff --git a/src/app/master-data/production-standard/detail/edit/page.tsx b/src/app/master-data/production-standard/detail/edit/page.tsx new file mode 100644 index 00000000..d048b411 --- /dev/null +++ b/src/app/master-data/production-standard/detail/edit/page.tsx @@ -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 ( +
+ +
+ ); + } + + if ( + !isLoadingProductionStandard && + (!productionStandard || isResponseError(productionStandard)) + ) { + router.replace('/404'); + return; + } + + return ( + <> + {isLoadingProductionStandard && ( + + )} + {!isLoadingProductionStandard && + isResponseSuccess(productionStandard) && ( + + )} + + ); +}; + +export default EditProductionStandardPage; diff --git a/src/app/master-data/production-standard/detail/layout.tsx b/src/app/master-data/production-standard/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/production-standard/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/master-data/production-standard/detail/page.tsx b/src/app/master-data/production-standard/detail/page.tsx new file mode 100644 index 00000000..99806dcd --- /dev/null +++ b/src/app/master-data/production-standard/detail/page.tsx @@ -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 ( +
+ +
+ ); + } + + if ( + !isLoadingProductionStandard && + (!productionStandard || isResponseError(productionStandard)) + ) { + router.replace('/404'); + return; + } + + return ( + <> + {isLoadingProductionStandard && ( + + )} + {!isLoadingProductionStandard && + isResponseSuccess(productionStandard) && ( + + )} + + ); +}; + +export default DetailProductionStandardPage; diff --git a/src/app/master-data/production-standard/page.tsx b/src/app/master-data/production-standard/page.tsx new file mode 100644 index 00000000..ed1107cd --- /dev/null +++ b/src/app/master-data/production-standard/page.tsx @@ -0,0 +1,11 @@ +import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable'; + +const ProductionStandardPage = () => { + return ( +
+ +
+ ); +}; + +export default ProductionStandardPage; diff --git a/src/app/production/project-flock/chickin/add/page.tsx b/src/app/production/project-flock/chickin/add/page.tsx deleted file mode 100644 index 831979cb..00000000 --- a/src/app/production/project-flock/chickin/add/page.tsx +++ /dev/null @@ -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 ( - <> -
- -
- - ); -}; - -export default AddChickin; diff --git a/src/app/production/project-flock/chickin/page.tsx b/src/app/production/project-flock/chickin/page.tsx deleted file mode 100644 index d40c39a3..00000000 --- a/src/app/production/project-flock/chickin/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import ChickinTable from '@/components/pages/production/chickin/ChickinTable'; - -const Chickin = () => { - return ( -
- -
- ); -}; -export default Chickin; diff --git a/src/app/production/uniformity/add/page.tsx b/src/app/production/uniformity/add/page.tsx new file mode 100644 index 00000000..136aab5d --- /dev/null +++ b/src/app/production/uniformity/add/page.tsx @@ -0,0 +1,7 @@ +import UniformityForm from '@/components/pages/production/uniformity/form/UniformityForm'; + +const AddUniformity = () => { + return ; +}; + +export default AddUniformity; diff --git a/src/app/production/uniformity/detail/page.tsx b/src/app/production/uniformity/detail/page.tsx new file mode 100644 index 00000000..bf1458ef --- /dev/null +++ b/src/app/production/uniformity/detail/page.tsx @@ -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 ( +
+ +
+ ); + } + + if (!isLoadingUniformity && (!uniformity || isResponseError(uniformity))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingUniformity && ( +
+ +
+ )} + {isResponseSuccess(uniformity) && ( + + )} +
+ ); +}; + +export default UniformityDetailPage; diff --git a/src/app/production/uniformity/layout.tsx b/src/app/production/uniformity/layout.tsx new file mode 100644 index 00000000..511aa0a1 --- /dev/null +++ b/src/app/production/uniformity/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; +import UniformityPageWrapper from '@/components/pages/production/uniformity/UniformityPageWrapper'; + +export default function UniformityLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/app/production/uniformity/page.tsx b/src/app/production/uniformity/page.tsx new file mode 100644 index 00000000..841a7507 --- /dev/null +++ b/src/app/production/uniformity/page.tsx @@ -0,0 +1,7 @@ +import UniformityTable from '@/components/pages/production/uniformity/UniformityTable'; + +const Uniformity = () => { + return ; +}; + +export default Uniformity; diff --git a/src/app/report/expense/detail/layout.tsx b/src/app/report/expense/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/report/expense/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/report/expense/detail/page.tsx b/src/app/report/expense/detail/page.tsx new file mode 100644 index 00000000..f7ae906e --- /dev/null +++ b/src/app/report/expense/detail/page.tsx @@ -0,0 +1,5 @@ +const ReportExpenseDetail = () => { + return
ReportExpenseDetail
; +}; + +export default ReportExpenseDetail; diff --git a/src/app/report/expense/page.tsx b/src/app/report/expense/page.tsx new file mode 100644 index 00000000..99d2862e --- /dev/null +++ b/src/app/report/expense/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable'; + +const ReportExpense = () => { + return ( +
+ +
+ ); +}; + +export default ReportExpense; diff --git a/src/app/report/logistic-stock/layout.tsx b/src/app/report/logistic-stock/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/report/logistic-stock/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/report/logistic-stock/page.tsx b/src/app/report/logistic-stock/page.tsx new file mode 100644 index 00000000..77ba31ed --- /dev/null +++ b/src/app/report/logistic-stock/page.tsx @@ -0,0 +1,7 @@ +import LogisticStockTabs from '@/components/pages/report/logistic-stock/LogisticStockTabs'; + +const LogisticStock = () => { + return ; +}; + +export default LogisticStock; diff --git a/src/app/report/marketing/layout.tsx b/src/app/report/marketing/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/report/marketing/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/report/marketing/page.tsx b/src/app/report/marketing/page.tsx new file mode 100644 index 00000000..52a3d4dd --- /dev/null +++ b/src/app/report/marketing/page.tsx @@ -0,0 +1,11 @@ +import MarketingReportContent from '@/components/pages/report/MarketingReportContent'; + +const MarketingReportPage = () => { + return ( +
+ +
+ ); +}; + +export default MarketingReportPage; diff --git a/src/app/report/production-result/page.tsx b/src/app/report/production-result/page.tsx new file mode 100644 index 00000000..691ea734 --- /dev/null +++ b/src/app/report/production-result/page.tsx @@ -0,0 +1,11 @@ +import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent'; + +const ProductionResultReportPage = () => { + return ( +
+ +
+ ); +}; + +export default ProductionResultReportPage; diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 5dc5022d..821aae42 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -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, '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 = { default: '', outline: 'badge-outline', ghost: 'badge-ghost', @@ -42,7 +38,7 @@ const Badge = ({ dash: 'badge-dash', }; - const colorClasses = { + const colorClasses: Record = { 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 = { xs: 'badge-xs', sm: 'badge-sm', md: 'badge-md', @@ -70,8 +67,31 @@ const Badge = ({ ); }; + const getStatusClasses = () => { + if (!statusIndicator) return ''; + + const statusIndicatorClasses: Record = { + 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 ( + {statusIndicator && } {children} ); diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index 17b8a56f..7b5e2374 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -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 */}
- {sidebarContent} + {/* Primary Sidebar Content */} +
+ {sidebarContent} +
+ + {/* Expanded Drawer (Right side, side-by-side) */} + {expandedContent && ( +
+
+ {expandedContent} +
+
+ )}
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx new file mode 100644 index 00000000..5bfa7a7d --- /dev/null +++ b/src/components/Dropdown.tsx @@ -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(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 ( +
+ {trigger} + {open && !close && ( +
+ {children} +
+ )} +
+ ); + } + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleDropdown(); + } + }} + > + {trigger} +
+ {!close && ( +
+ {children} +
+ )} +
+ ); +}; + +export default Dropdown; diff --git a/src/components/FloatingActionsButton.tsx b/src/components/FloatingActionsButton.tsx index c9ca3454..974ca280 100644 --- a/src/components/FloatingActionsButton.tsx +++ b/src/components/FloatingActionsButton.tsx @@ -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 = ({
{/* 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 ( - ))} + {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) => ( + + ))}
diff --git a/src/components/MainDrawer.tsx b/src/components/MainDrawer.tsx index 3a09c0b1..fc8cbb18 100644 --- a/src/components/MainDrawer.tsx +++ b/src/components/MainDrawer.tsx @@ -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 ; + } + return ( {
@@ -62,9 +63,11 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
} - contentClassName='w-52 mt-3' + className={{ + content: 'w-52 mt-3', + }} > - + diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 9feb33e2..9791dd59 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -60,6 +60,12 @@ export interface TableProps { renderFooter?: boolean; withCheckbox?: boolean; rowOptions?: number[]; + /** + * Custom row renderer. Should return a complete element or null. + * This gives full control over the row structure including colspan. + * Return null to render the default row. + */ + renderCustomRow?: (row: Row) => ReactNode | null; } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -112,6 +118,7 @@ const Table = ({ renderFooter = false, withCheckbox = false, rowOptions = [10, 20, 50, 100], + renderCustomRow, }: TableProps) => { const isServerSideTable = totalItems !== undefined && @@ -305,24 +312,35 @@ const Table = ({ - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {!isLoading && - flexRender(cell.column.columnDef.cell, cell.getContext())} + {table.getRowModel().rows.map((row) => { + const customRowContent = renderCustomRow?.(row); - {isLoading &&
} - - ))} - - ))} + if (customRowContent) { + return renderCustomRow?.(row); + } + + return ( + + {row.getVisibleCells().map((cell) => ( + + {!isLoading && + flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + {isLoading &&
} + + ))} + + ); + })} {renderFooter && ( diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 2ad2477d..8f685452 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -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 = { @@ -104,7 +109,7 @@ const Tabs = ({ {...props} className={cn( 'w-full', - typeof className === 'string' ? className : undefined + typeof className === 'string' ? className : containerClassName )} >
@@ -121,7 +126,9 @@ const Tabs = ({ ))}
- {activeContent &&
{activeContent}
} + {activeContent && ( +
{activeContent}
+ )}
); }; diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx index 4489231d..5bfa7a7d 100644 --- a/src/components/dropdown/Dropdown.tsx +++ b/src/components/dropdown/Dropdown.tsx @@ -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(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 ( +
+ {trigger} + {open && !close && ( +
+ {children} +
+ )} +
+ ); + } + return ( -
- {/* Trigger Button */} -
+
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleDropdown(); + } + }} + > {trigger}
- - {/* Dropdown Content - Only render when open */} - {isOpen && ( -
setIsOpen(false)} // Close on item click - > + {!close && ( +
{children}
)} diff --git a/src/components/helper/PermissionNotFound.tsx b/src/components/helper/PermissionNotFound.tsx new file mode 100644 index 00000000..75e48c62 --- /dev/null +++ b/src/components/helper/PermissionNotFound.tsx @@ -0,0 +1,12 @@ +const PermissionNotFound = () => { + return ( +
+

Permission Not Found

+

+ You do not have permission to access this page. +

+
+ ); +}; + +export default PermissionNotFound; diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 65adf48c..a4c9f5e0 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -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 (

Authentication Failed

@@ -71,10 +94,7 @@ const RequireAuth = ({ children }: RequireAuthProps) => { Please try refreshing the page or contact support if the problem persists.

-
diff --git a/src/components/helper/RequirePermission.tsx b/src/components/helper/RequirePermission.tsx new file mode 100644 index 00000000..2a7061ed --- /dev/null +++ b/src/components/helper/RequirePermission.tsx @@ -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; diff --git a/src/components/input/DebouncedTextInput.tsx b/src/components/input/DebouncedTextInput.tsx index 4b62aaf7..d52ab72e 100644 --- a/src/components/input/DebouncedTextInput.tsx +++ b/src/components/input/DebouncedTextInput.tsx @@ -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); diff --git a/src/components/menu/MenuItem.tsx b/src/components/menu/MenuItem.tsx index dce81dac..61af4b04 100644 --- a/src/components/menu/MenuItem.tsx +++ b/src/components/menu/MenuItem.tsx @@ -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 (
  • - {href && ( + {!isLoading && href && ( {menuItemContent} )} - {!href && ( + {!isLoading && !href && ( )} + + {isLoading && ( + + )}
  • ); }; diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx index 00b63c86..9cf17008 100644 --- a/src/components/modal/ConfirmationModal.tsx +++ b/src/components/modal/ConfirmationModal.tsx @@ -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; 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 ( +
    +
    +
    +
    + +
    +
    +
    +
    + ); +}; + 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 (
    -
    - {type === 'info' && ( - - )} + {iconPosition === 'center' ? ( + <> +
    + +
    - {type === 'success' && ( - - )} +

    + {text ?? 'Apakah anda yakin ingin melakukan hal ini?'} +

    - {type === 'error' && ( - - )} -
    + {subtitleText && ( +

    + {subtitleText} +

    + )} + + ) : ( +
    +
    + +
    -

    - {text ?? 'Apakah anda yakin ingin melakukan hal ini?'} -

    +
    +

    + {text ?? 'Apakah anda yakin ingin melakukan hal ini?'} +

    + + {subtitleText && ( +

    {subtitleText}

    + )} +
    +
    + )} {children &&
    {children}
    } @@ -103,7 +170,7 @@ const ConfirmationModal = ({ {secondaryButton && secondaryButton.text && ( + + +
    ); @@ -123,28 +126,6 @@ const ClosingsTable = () => { accessorKey: 'shed_label', header: 'Jumlah Kandang', }, - { - accessorKey: 'sales_paid_amount', - header: 'Jumlah Sudah Bayar', - cell: (props) => ( - - {formatCurrency(props.row.original.sales_paid_amount)} - - ), - }, - { - accessorKey: 'sales_remaining_amount', - header: 'Jumlah Sisa Bayar', - cell: (props) => ( - - {formatCurrency(props.row.original.sales_remaining_amount)} - - ), - }, - { - accessorKey: 'sales_payment_status', - header: 'Status Pembayaran', - }, { accessorKey: 'project_status', header: 'Status', diff --git a/src/components/pages/closing/hpp-ekspedisi/HppExpeditionReportTable.tsx b/src/components/pages/closing/hpp-ekspedisi/HppExpeditionReportTable.tsx new file mode 100644 index 00000000..f683ec58 --- /dev/null +++ b/src/components/pages/closing/hpp-ekspedisi/HppExpeditionReportTable.tsx @@ -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[] = + useMemo( + () => [ + { + id: 'id', + accessorKey: 'id', + header: 'No', + cell: (props) => { + return
    {props.row.index + 1}
    ; + }, + footer: () => ( +
    + Total HPP Ekspedisi +
    + ), + }, + { + 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
    {formatCurrency(value)}
    ; + }, + footer: () => ( +
    + {formatCurrency(totals.totalHpp)} +
    + ), + }, + ], + [totals] + ); + + return ( + <> +
    +
    +

    HPP Ekspedisi

    + + 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', + }} + /> + + + + + ); +}; + +export default HppExpeditionReportTable; diff --git a/src/components/pages/dashboard/DashboardProduction.tsx b/src/components/pages/dashboard/DashboardProduction.tsx new file mode 100644 index 00000000..fb8190aa --- /dev/null +++ b/src/components/pages/dashboard/DashboardProduction.tsx @@ -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([ + '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 ( +
    + +
    + ); + } + return ( + <> +
    +
    +

    Dashboard

    +
    + + +
    +
    + + {/* Dashboard Statistics */} + + + {/* Charts Grid */} +
    + {/* Production Line Chart */} + + + + + {/* Standard Line Chart */} + + + + + {/* Bar Charts Grid - 2 columns */} +
    + {/* FCR Bar Chart */} + + + + + {/* Egg Weight Bar Chart */} + + + +
    +
    +
    + +
    + {/* Modal Header */} +
    +
    + +

    Filter Data

    +
    + +
    + +
    + {/* Rentang Waktu */} +
    + +
    + + + +
    +
    + + {/* Flock */} +
    + formik.setFieldValue('flock', selected)} + errorMessage={formik.errors.flock as string} + options={flockOptions} + isLoading={isLoadingFlockOptions} + isMulti + isError={ + Boolean(formik.errors.flock) && Boolean(formik.touched.flock) + } + /> +
    + + {/* Production */} +
    + + 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) + } + /> +
    + + {/* Standard */} +
    + ({ + 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) + } + /> +
    + + {/* Periode Perbandingan */} +
    + +
    + + + + +
    +
    + + {/* Action Buttons */} +
    + + +
    + +
    +
    + + ); +}; + +export default DashboardProduction; diff --git a/src/components/pages/dashboard/chart/EggWeightBarChart.tsx b/src/components/pages/dashboard/chart/EggWeightBarChart.tsx new file mode 100644 index 00000000..7a9a02c6 --- /dev/null +++ b/src/components/pages/dashboard/chart/EggWeightBarChart.tsx @@ -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 ( +
    +

    + Rata-rata Berat Telur (EW) +

    +
    +

    Memuat data...

    +
    +
    + ); + } + + return ( +
    +

    Rata-rata Berat Telur (EW)

    + + + + + + + value !== undefined ? [`${value} gram`, ''] : ['', ''] + } + cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }} + /> + + {data.map((entry, index) => ( + + ))} + + + +
    + ); +}; + +export default EggWeightBarChart; diff --git a/src/components/pages/dashboard/chart/FCRBarChart.tsx b/src/components/pages/dashboard/chart/FCRBarChart.tsx new file mode 100644 index 00000000..2647c7f7 --- /dev/null +++ b/src/components/pages/dashboard/chart/FCRBarChart.tsx @@ -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 ( +
    +

    + Feed Conversion Ratio (FCR) +

    +
    +

    Memuat data...

    +
    +
    + ); + } + + return ( +
    +

    + Feed Conversion Ratio (FCR) +

    + + + + + + + value !== undefined ? [value.toFixed(2), 'FCR'] : ['', ''] + } + cursor={{ fill: 'rgba(16, 185, 129, 0.1)' }} + /> + + {data.map((entry, index) => ( + + ))} + + + +
    + ); +}; + +export default FCRBarChart; diff --git a/src/components/pages/dashboard/chart/ProductionLineChart.tsx b/src/components/pages/dashboard/chart/ProductionLineChart.tsx new file mode 100644 index 00000000..470e09c9 --- /dev/null +++ b/src/components/pages/dashboard/chart/ProductionLineChart.tsx @@ -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 = { + 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([]); + + // 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 ( +
    +

    + Performa Produksi per Flock +

    + + + + formatDateByPeriod(value, period)} + /> + + + formatDateByPeriod(value as string, period) + } + /> + { + if (e.dataKey) handleLegendClick(e.dataKey as string); + }} + style={{ cursor: 'pointer' }} + /> + + + + + + +
    + ); +}; + +export default ProductionLineChart; + +// Export types for external use +export type { FlockData, ProductionChartItem, ProductionChartsData }; diff --git a/src/components/pages/dashboard/chart/ProductionStat.tsx b/src/components/pages/dashboard/chart/ProductionStat.tsx new file mode 100644 index 00000000..7e299223 --- /dev/null +++ b/src/components/pages/dashboard/chart/ProductionStat.tsx @@ -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 ( +
    + {[1, 2, 3, 4].map((i) => ( + +
    +
    +
    +
    +
    +
    + ))} +
    + ); + } + + return ( +
    + {data.map((stat, index) => ( + +
    +
    +

    {stat.title}

    +

    + {formatCurrency(stat.value)} +

    +

    + + {stat.change > 0 ? '+' : ''} + {stat.change}% vs{' '} + {stat.period === 'monthly' ? 'bulan lalu' : 'periode lalu'} +

    +
    +
    +
    + +
    +
    +
    +
    + ))} +
    + ); +}; + +export default ProductionStat; diff --git a/src/components/pages/dashboard/chart/StandardLineChart.tsx b/src/components/pages/dashboard/chart/StandardLineChart.tsx new file mode 100644 index 00000000..18bcabf6 --- /dev/null +++ b/src/components/pages/dashboard/chart/StandardLineChart.tsx @@ -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 = { + 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([]); + + // 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 = { + hen_day: '#94a3b8', + hen_house: '#64748b', + uniformity: '#475569', + egg_weight: '#334155', + egg_mass: '#1e293b', + }; + + // Standard names mapping for display + const standardLabels: Record = { + hen_day: 'Hen Day', + hen_house: 'Hen House', + uniformity: 'Uniformity', + egg_weight: 'Egg Weight', + egg_mass: 'Egg Mass', + }; + + return ( +
    +

    + Perbandingan Henday per Umur +

    + + + + + + + value !== undefined ? [`${value}%`, ''] : ['', ''] + } + labelFormatter={(label) => `Minggu ${label}`} + /> + { + if (e.dataKey) handleLegendClick(e.dataKey as string); + }} + style={{ cursor: 'pointer' }} + /> + {/* Dynamic Standard Lines */} + {selectedStandards.map((standardName) => ( + + ))} + {/* Flock Lines */} + + + + + + +
    + ); +}; + +export default StandardLineChart; + +// Export types for external use +export type { FlockData, StandardData, StandardChartItem }; diff --git a/src/components/pages/dashboard/filter/DashboardProductionFilter.schema.ts b/src/components/pages/dashboard/filter/DashboardProductionFilter.schema.ts new file mode 100644 index 00000000..4ed86a48 --- /dev/null +++ b/src/components/pages/dashboard/filter/DashboardProductionFilter.schema.ts @@ -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; diff --git a/src/components/pages/expense/ExpenseRealizationContent.tsx b/src/components/pages/expense/ExpenseRealizationContent.tsx index 2b5b0a0a..ccd57ec3 100644 --- a/src/components/pages/expense/ExpenseRealizationContent.tsx +++ b/src/components/pages/expense/ExpenseRealizationContent.tsx @@ -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 = ({
    - {/* TODO: apply RBAC */} - + + +
    @@ -101,59 +103,69 @@ const ExpenseRealizationContent = ({ initialValues?.realization_docs.length > 0 && (
      {initialValues?.realization_docs.map( - (realizationDocument, realizationDocumentIdx) => ( -
    • - - {realizationDocument.path}{' '} - - -
    • - ) + (realizationDocument, realizationDocumentIdx) => { + const path = realizationDocument.path.startsWith( + '/' + ) + ? realizationDocument.path.slice(1) + : realizationDocument.path; + const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`; + return ( +
    • + + {realizationDocument.path}{' '} + + +
    • + ); + } )}
    )}
    -
    - + +
    + - {formik.values.documents && - formik.values.documents.length > 0 && ( - - )} -
    + {formik.values.documents && + formik.values.documents.length > 0 && ( + + )} +
    + @@ -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 ( diff --git a/src/components/pages/expense/ExpenseRequestContent.tsx b/src/components/pages/expense/ExpenseRequestContent.tsx index 0d7d959d..2b9086e0 100644 --- a/src/components/pages/expense/ExpenseRequestContent.tsx +++ b/src/components/pages/expense/ExpenseRequestContent.tsx @@ -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 = ({
    {isCurrentApprovalOnManager && ( - + + + )} {isCurrentApprovalOnFinance && ( - + + + )} {isCurrentApprovalOnRealization && ( - + + + )} {showRejectButton && ( - + + )} {isExpenseCanBeRealized && ( - + + + )}
    {showEditButton && ( - + + + )} - + + +
    @@ -388,9 +408,13 @@ const ExpenseRequestContent = ({
    @@ -428,7 +452,14 @@ const ExpenseRequestContent = ({ - + @@ -462,59 +493,73 @@ const ExpenseRequestContent = ({ initialValues?.documents.length > 0 && (
      {initialValues?.documents.map( - (requestDocument, requestDocumentIdx) => ( -
    • - - {requestDocument.path}{' '} - - -
    • - ) + (requestDocument, requestDocumentIdx) => { + const path = requestDocument.path.startsWith( + '/' + ) + ? requestDocument.path.slice(1) + : requestDocument.path; + const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`; + return ( +
    • + + {requestDocument.path}{' '} + + +
    • + ); + } )}
    )} -
    - + +
    + - {formik.values.documents && - formik.values.documents.length > 0 && ( - - )} -
    + {formik.values.documents && + formik.values.documents.length > 0 && ( + + )} +
    + @@ -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'}`} diff --git a/src/components/pages/expense/ExpensesTable.tsx b/src/components/pages/expense/ExpensesTable.tsx index bbcb6c4e..9ae3ed34 100644 --- a/src/components/pages/expense/ExpensesTable.tsx +++ b/src/components/pages/expense/ExpensesTable.tsx @@ -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 (
    - - - {showEditButton && ( + + + + {showEditButton && ( + + + )} {showRealizationButton && ( - + + + )} - + + +
    ); @@ -559,57 +572,70 @@ const ExpensesTable = () => {
    - + + + {selectedRowIds.length > 0 && ( <> - + + + - + + + - + + )}
    diff --git a/src/components/pages/expense/form/ExpenseKandangsTable.tsx b/src/components/pages/expense/form/ExpenseKandangsTable.tsx index b3c9f46d..7d7f76ca 100644 --- a/src/components/pages/expense/form/ExpenseKandangsTable.tsx +++ b/src/components/pages/expense/form/ExpenseKandangsTable.tsx @@ -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([]); const [rowSelection, setRowSelection] = useState>( - convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id)) + convertRowSelectionArrToObj( + selectedKandangs + .map((item) => item.id) + .filter((id): id is number => id !== undefined) + ) ); const kandangsColumns: ColumnDef[] = [ diff --git a/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts index 77db761c..1f3682ea 100644 --- a/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts +++ b/src/components/pages/expense/form/ExpenseRealizationForm.schema.ts @@ -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 ({ - 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) => { diff --git a/src/components/pages/expense/form/ExpenseRealizationForm.tsx b/src/components/pages/expense/form/ExpenseRealizationForm.tsx index a7ebdbca..6526b1c1 100644 --- a/src/components/pages/expense/form/ExpenseRealizationForm.tsx +++ b/src/components/pages/expense/form/ExpenseRealizationForm.tsx @@ -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' }} /> - + + + {formik.values.existing_documents && formik.values.existing_documents.length > 0 && ( @@ -335,7 +355,10 @@ const ExpenseRealizationForm = ({ )} {type !== 'edit' && ( - + + + )}
    )} diff --git a/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx index 017a733e..3f6f2220 100644 --- a/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx +++ b/src/components/pages/expense/form/ExpenseRealizationKandangDetailExpense.tsx @@ -18,6 +18,11 @@ import { Nonstock } from '@/types/api/master-data/nonstock'; interface ExpenseRealizationKandangDetailExpenseProps { type?: 'add' | 'edit' | 'detail'; formik: FormikContextType; + 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(NonstockApi.basePath, 'id', 'name'); + } = useSelect( + NonstockApi.basePath, + 'id', + 'name', + 'search', + supplierId ? { supplier_id: String(supplierId) } : undefined + ); const nonstockChangeHandler = ( kandangExpenseIdx: number, @@ -82,140 +93,159 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
    - {formik.values.realizations.length === 0 && ( + {!formik.values.supplier?.value && (

    - Pilih kandang terlebih dahulu! + Pilih supplier terlebih dahulu!

    )} - {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 && ( +
    +

    + Belum ada item biaya. Silakan pilih lokasi terlebih dahulu. +

    +
    + )} - return ( - kandangName?.name && ( -
    -
    -
    - Biaya {kandangName?.name} -
    + {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; -
    -
    Kandang : - {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(', ') + : '-'}
    Nominal Biaya :{formatCurrency(initialValues?.grand_total ?? 0)} + {formatCurrency( + initialValues?.latest_approval.step_number === 4 || + initialValues?.latest_approval.step_number === 5 + ? (initialValues?.total_realisasi ?? 0) + : (initialValues?.total_pengajuan ?? 0) + )} +
    Status Pencairan
    - - - - - - - - + return ( + (kandangName?.name || !kandangExpense.kandang_id) && ( +
    +
    +
    + {kandangName?.name + ? `Biaya ${kandangName.name}` + : location?.label + ? `Biaya ${location.label}` + : 'Biaya Umum'} +
    -
    - {kandangExpense.cost_items.map( - (expenseItem, expenseIdx) => ( - - - - - - - - +
    +
    NonstockTotal KuantitasHarga SatuanCatatan
    - { - nonstockChangeHandler( - kandangExpenseIdx, - expenseIdx, - val - ); - }} - options={nonstockOptions} - isLoading={isLoadingNonstockOptions} - onInputChange={setNonstockInputValue} - className={{ wrapper: 'min-w-48' }} - isDisabled - /> - - - - - Rp - - } - className={{ wrapper: 'min-w-24' }} - /> - - -
    + + + + + + - ) - )} - -
    NonstockTotal KuantitasHarga SatuanCatatan
    + + + + {kandangExpense.cost_items.map( + (expenseItem, expenseIdx) => ( + + + { + nonstockChangeHandler( + kandangExpenseIdx, + expenseIdx, + val + ); + }} + options={nonstockOptions} + isLoading={isLoadingNonstockOptions} + onInputChange={setNonstockInputValue} + className={{ wrapper: 'min-w-48' }} + isDisabled + /> + + + + + + + + + Rp + + } + className={{ wrapper: 'min-w-24' }} + /> + + + + + + + ) + )} + + +
    +
    -
    -
    - ) - ); - })} + ) + ); + } + )}
    ); diff --git a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts index 7758df83..71357361 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.schema.ts +++ b/src/components/pages/expense/form/ExpenseRequestForm.schema.ts @@ -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 = 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 = 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 diff --git a/src/components/pages/expense/form/ExpenseRequestForm.tsx b/src/components/pages/expense/form/ExpenseRequestForm.tsx index d52bde0d..60e55397 100644 --- a/src/components/pages/expense/form/ExpenseRequestForm.tsx +++ b/src/components/pages/expense/form/ExpenseRequestForm.tsx @@ -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' }} /> - + + + {formik.values.existing_documents && formik.values.existing_documents.length > 0 && ( @@ -451,7 +484,10 @@ const ExpenseRequestForm = ({ )} {type !== 'add' && (
    - - - {type !== 'edit' && ( + + + + {type !== 'edit' && ( + + + )}
    )} diff --git a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx index 11f54585..e219870e 100644 --- a/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx +++ b/src/components/pages/expense/form/ExpenseRequestKandangDetailExpense.tsx @@ -21,6 +21,11 @@ import { removeArrayItemAndSync } from '@/lib/utils/formik'; interface ExpenseRequestKandangDetailExpenseProps { type?: 'add' | 'edit' | 'detail'; formik: FormikContextType; + 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(NonstockApi.basePath, 'id', 'name'); + } = useSelect( + NonstockApi.basePath, + 'id', + 'name', + 'search', + supplierId ? { supplier_id: String(supplierId) } : undefined + ); const nonstockChangeHandler = ( kandangExpenseIdx: number, @@ -113,41 +124,57 @@ const ExpenseRequestKandangDetailExpense: React.FC<
    - {(formik.values.expense_nonstocks.length === 0 || - !formik.values.supplier?.value) && ( + {!formik.values.supplier?.value && (

    - Pilih kandang terlebih dahulu! + Pilih supplier terlebih dahulu!

    )} + {formik.values.expense_nonstocks.length === 0 && + formik.values.supplier?.value && ( +
    +

    + Belum ada item biaya. Silakan pilih lokasi terlebih dahulu. +

    +
    + )} + {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) && (
    - Biaya {kandangName?.name} + Biaya {kandangName?.name || location?.label || 'Umum'}
    - - - + + + {type !== 'detail' && } diff --git a/src/components/pages/expense/pdf/ExpensePDF.tsx b/src/components/pages/expense/pdf/ExpensePDF.tsx index 5b107127..ef1c7d8b 100644 --- a/src/components/pages/expense/pdf/ExpensePDF.tsx +++ b/src/components/pages/expense/pdf/ExpensePDF.tsx @@ -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} > - {kandangExpense.name} + {kandangExpense.kandang_id && kandangExpense.name + ? `Biaya ${kandangExpense.name}` + : `Biaya ${expense?.location.name || 'Umum'}`} @@ -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} > - {kandangExpense.name} + {kandangExpense.kandang_id && kandangExpense.name + ? `Biaya ${kandangExpense.name}` + : `Biaya ${expense?.location.name || 'Umum'}`} diff --git a/src/components/pages/finance/FinanceDetail.tsx b/src/components/pages/finance/FinanceDetail.tsx new file mode 100644 index 00000000..c7057efa --- /dev/null +++ b/src/components/pages/finance/FinanceDetail.tsx @@ -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 ( +
    + + + +
    +
    NonstockTotal KuantitasHarga Satuan + Nonstock + + Total Kuantitas + + Harga Satuan + CatatanAksi
    +
    + + + +
    + {FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && ( + + + + )} + {FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && ( + + + + )} + + + +
    + + + ); +}; + +export default FinanceDetail; diff --git a/src/components/pages/finance/FinanceTable.tsx b/src/components/pages/finance/FinanceTable.tsx new file mode 100644 index 00000000..71ed6c84 --- /dev/null +++ b/src/components/pages/finance/FinanceTable.tsx @@ -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; + deleteClickHandler: () => void; +}) => { + return ( + + + + + + {FINANCE_TRANSACTION_STATUS.includes( + props.row.original.transaction_type + ) && ( + + + + )} + + {FINANCE_INITIAL_BALANCE_STATUS.includes( + props.row.original.transaction_type + ) && ( + + + + )} + + {FINANCE_INJECTION_STATUS.includes( + props.row.original.transaction_type + ) && ( + + + + )} + + + + + + ); +}; + +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(null); + const [selectedBank, setSelectedBank] = useState(null); + const [selectedPartyType, setSelectedPartyType] = useState( + null + ); + const [selectedSortBy, setSelectedSortBy] = useState(null); + const [selectedFinance, setSelectedFinance] = useState(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( + BankApi.basePath, + 'id', + 'alias', + '', + { + limit: 'limit', + } + ); + + // ===== Handler ===== + const searchChangeHandler: ChangeEventHandler = (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 = (e) => { + setPendingFilters((prev) => ({ ...prev, startDate: e.target.value })); + }; + const endDateChangeHandler: ChangeEventHandler = (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) => { + const value = props.row.original.reference_number; + return {value ?? '-'}; + }, + }, + { + header: 'Jenis Transaksi', + accessorKey: 'transaction_type', + cell: (props: CellContext) => { + const value = props.row.original.transaction_type + .split('_') + .join(' '); + return {formatTitleCase(value)}; + }, + }, + { + header: 'Pihak', + accessorFn: (finance: Finance) => finance.party.name, + cell: (props: CellContext) => { + if (props.row.original.party.id) { + return {props.row.original.party.name}; + } + return {'-'}; + }, + }, + { + header: 'Tanggal', + accessorFn: (finance: Finance) => + formatDate(finance.payment_date, 'DD MMM YYYY'), + }, + { + header: 'Metode Pembayaran', + accessorKey: 'payment_method', + cell: (props: CellContext) => { + const value = props.row.original.payment_method.split('_').join(' '); + return {formatTitleCase(value)}; + }, + }, + { + 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) => { + 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 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + }, []); + return ( +
    +
    + + + + + + + + + +
    + + + + + } + > +
    + + ({ + 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 + /> + + + + + +
    +
    + + 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} + /> + +
    + ); +}; + +export default FinanceTable; diff --git a/src/components/pages/finance/add/FormFinanceAdd.schema.ts b/src/components/pages/finance/add/FormFinanceAdd.schema.ts new file mode 100644 index 00000000..9aff81b9 --- /dev/null +++ b/src/components/pages/finance/add/FormFinanceAdd.schema.ts @@ -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; diff --git a/src/components/pages/finance/add/FormFinanceAdd.tsx b/src/components/pages/finance/add/FormFinanceAdd.tsx new file mode 100644 index 00000000..c835740e --- /dev/null +++ b/src/components/pages/finance/add/FormFinanceAdd.tsx @@ -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({ + 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( + formik.values.party_type_option?.value === 'CUSTOMER' + ? CustomerApi.basePath + : SupplierApi.basePath, + 'id', + 'name', + '', + { limit: 'limit' } + ); + const { + options: bankOptions, + rawData: bankRawData, + isLoadingOptions: isLoadingBankOptions, + } = useSelect(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 ( + <> +
    +
    + +
    + { + 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 + /> + { + 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} + /> + + { + 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 + /> + ({ + 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 + /> + + + +