diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ee8a79a5..935cac46 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -165,8 +165,6 @@ deploy:staging: environment: name: staging url: https://stg-lti-erp.mbugroup.id - - # ====== PRODUCTION ====== # build:production: # <<: *build_template diff --git a/package-lock.json b/package-lock.json index f0212474..56433eda 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", @@ -26,6 +28,7 @@ "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 +1085,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": { @@ -1844,17 +1847,31 @@ "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", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1878,6 +1895,13 @@ "@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/@typescript-eslint/eslint-plugin": { "version": "8.46.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", @@ -1924,6 +1948,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2447,6 +2472,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2775,6 +2801,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", @@ -3019,6 +3055,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,11 +3104,22 @@ "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" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.5.8", @@ -3275,6 +3334,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", @@ -3516,6 +3585,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3689,6 +3759,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3994,6 +4065,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 +4092,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 +4585,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", @@ -4570,6 +4678,12 @@ "node": ">= 0.4" } }, + "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 +5219,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 +5794,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 +6149,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,11 +6309,22 @@ ], "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", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6197,6 +6355,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6320,6 +6479,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", @@ -6412,6 +6578,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 +6937,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 +7166,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 +7220,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", @@ -7083,6 +7289,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7250,6 +7457,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7396,6 +7604,16 @@ "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/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 +7743,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..039101dc 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", @@ -29,6 +31,7 @@ "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/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/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/components/FloatingActionsButton.tsx b/src/components/FloatingActionsButton.tsx index c9ca3454..2e4eed07 100644 --- a/src/components/FloatingActionsButton.tsx +++ b/src/components/FloatingActionsButton.tsx @@ -33,7 +33,9 @@ const FloatingActionsButton = ({ }: FloatingActionsButtonProps) => { // 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') => { 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 new file mode 100644 index 00000000..5bfa7a7d --- /dev/null +++ b/src/components/dropdown/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/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx index 65adf48c..9dbd2557 100644 --- a/src/components/helper/RequireAuth.tsx +++ b/src/components/helper/RequireAuth.tsx @@ -27,6 +27,9 @@ const RequireAuth = ({ children }: RequireAuthProps) => { SWRHttpKey >('/sso/userinfo', httpClientFetcher, { shouldRetryOnError: false, + + // refresh every 13 minutes + refreshInterval: 13 * 60 * 1000, }); useEffect(() => { 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/pages/closing/ClosingDetail.tsx b/src/components/pages/closing/ClosingDetail.tsx index 336047e2..94647f87 100644 --- a/src/components/pages/closing/ClosingDetail.tsx +++ b/src/components/pages/closing/ClosingDetail.tsx @@ -6,16 +6,18 @@ import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import Tabs from '@/components/Tabs'; import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable'; +import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent'; +import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent'; import { ClosingGeneralInformation, BaseClosingSales, ClosingHppExpedition, } from '@/types/api/closing'; -import ClosingSapronakTabContent from './ClosingSapronakTabContent'; import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent'; import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent'; -import SalesReportTable from './sale/SalesReportTable'; +import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent'; +import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable'; import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable'; interface ClosingDetailProps { @@ -63,12 +65,12 @@ const ClosingDetail: React.FC = ({ { id: 'dataProduksi', label: 'Data Produksi', - content: 'Data Produksi', + content: , }, { id: 'keuangan', label: 'Keuangan', - content: 'Keuangan', + content: , }, ]; diff --git a/src/components/pages/closing/ClosingFinanceTabContent.tsx b/src/components/pages/closing/ClosingFinanceTabContent.tsx new file mode 100644 index 00000000..92386178 --- /dev/null +++ b/src/components/pages/closing/ClosingFinanceTabContent.tsx @@ -0,0 +1,17 @@ +import ClosingFinanceTable from '@/components/pages/closing/ClosingFinanceTable'; + +const ClosingFinanceTabContent = ({ + projectFlockId, +}: { + projectFlockId: number; +}) => { + return ( +
    + {projectFlockId && ( + + )} +
    + ); +}; + +export default ClosingFinanceTabContent; diff --git a/src/components/pages/closing/ClosingFinanceTable.tsx b/src/components/pages/closing/ClosingFinanceTable.tsx new file mode 100644 index 00000000..9d0c92d6 --- /dev/null +++ b/src/components/pages/closing/ClosingFinanceTable.tsx @@ -0,0 +1,495 @@ +import Card from '@/components/Card'; +import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { formatCurrency, formatTitleCase } from '@/lib/helper'; +import { ClosingApi } from '@/services/api/closing'; +import { + DataSummarySubTotal, + HppPurchaseData, + ProfitLossDataAmount, +} from '@/types/api/closing'; +import useSWR from 'swr'; + +type HppTableRow = + | (HppPurchaseData & { + group_name: string; + group_index: number; + isGroupHeader?: boolean; + }) + | { + group_name: string; + group_index: number; + isGroupHeader: true; + type?: never; + budgeting?: never; + realization?: never; + }; + +type ProfitLossTableRow = + | (DataSummarySubTotal & { + type: string; + group_name: string; + group_index: number; + isGroupHeader?: boolean; + }) + | { + group_name: string; + group_index: number; + isGroupHeader: true; + type?: never; + rp_per_bird?: never; + rp_per_kg?: never; + amount?: never; + }; + +const ClosingFinanceTable = ({ + projectFlockId, +}: { + projectFlockId: number; +}) => { + const { data: finance, isLoading } = useSWR( + `/closing/finance/${projectFlockId}`, + () => ClosingApi.getFinance(projectFlockId) + ); + + const hppTableData: HppTableRow[] = isResponseSuccess(finance) + ? finance.data.hpp_purchases.hpp.flatMap((hpp, groupIndex) => [ + // Group header row + { + group_name: hpp.group_name, + group_index: groupIndex, + isGroupHeader: true as const, + }, + // Data rows + ...hpp.data.map((item) => ({ + group_name: hpp.group_name, + group_index: groupIndex, + type: item.type, + budgeting: item.budgeting, + realization: item.realization, + isGroupHeader: false as const, + })), + ]) + : []; + + const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance) + ? [ + // Pembelian group + ...finance.data.profit_loss.data.pembelian.map((item) => ({ + label: 'Pembelian', + group_name: 'Pembelian', + group_index: 1, + type: item.type, + rp_per_bird: item.rp_per_bird, + rp_per_kg: item.rp_per_kg, + amount: item.amount, + isGroupHeader: false as const, + })), + { + label: finance.data.profit_loss.data.summary.gross_profit.label, + group_name: 'Penjualan', + group_index: 0, + isGroupHeader: true as const, + type: finance.data.profit_loss.data.summary.gross_profit.label, + rp_per_bird: + finance.data.profit_loss.data.summary.gross_profit.rp_per_bird, + rp_per_kg: + finance.data.profit_loss.data.summary.gross_profit.rp_per_kg, + amount: finance.data.profit_loss.data.summary.gross_profit.amount, + }, + // Penjualan group + ...finance.data.profit_loss.data.penjualan.map((item) => ({ + label: 'Penjualan', + group_name: 'Penjualan', + group_index: 0, + type: item.type, + rp_per_bird: item.rp_per_bird, + rp_per_kg: item.rp_per_kg, + amount: item.amount, + isGroupHeader: false as const, + })), + { + label: finance.data.profit_loss.data.summary.sub_total.label, + group_name: 'Pembelian', + group_index: 1, + isGroupHeader: true as const, + type: finance.data.profit_loss.data.summary.sub_total.label, + rp_per_bird: + finance.data.profit_loss.data.summary.sub_total.rp_per_bird, + rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg, + amount: finance.data.profit_loss.data.summary.sub_total.amount, + }, + ] + : []; + + return ( +
    + <> + +
    +
    +
    + {isResponseSuccess(finance) + ? formatTitleCase( + finance.data.profit_loss.data.summary.gross_profit + .label || '-' + ) + : 'Laba Rugi Brutto'} +
    +
    + {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.data.summary.gross_profit.amount + ) + : '-'} +
    +
    +
    +
    + {isResponseSuccess(finance) + ? formatTitleCase( + finance.data.profit_loss.data.summary.net_profit.label || + '-' + ) + : 'Laba Rugi Netto'} +
    +
    + {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.data.summary.net_profit.amount + ) + : '-'} +
    +
    +
    +
    + +
    + + data={hppTableData} + columns={[ + { + header: 'No.', + enableSorting: false, + accessorFn: (item, index) => { + if (item.isGroupHeader) return '-'; + const dataRowsBefore = hppTableData + .slice(0, index) + .filter((row) => !row.isGroupHeader).length; + return dataRowsBefore + 1; + }, + footer: (props) => { + return 'HPP'; + }, + }, + { + header: 'Type', + enableSorting: false, + accessorFn: (item) => formatTitleCase(item.type || '-'), + }, + { + header: 'Budgeting', + enableSorting: false, + columns: [ + { + header: 'Rp/Ekor', + id: 'budgeting_rp_per_bird', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.rp_per_bird || 0), + footer: (props) => { + return props.column.id === 'budgeting_rp_per_bird' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp_purchases.summary_hpp.budgeting + .rp_per_bird || 0 + ) + : '-'; + }, + }, + { + header: 'Rp/Kg', + id: 'budgeting_rp_per_kg', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.rp_per_kg || 0), + footer: (props) => { + return props.column.id === 'budgeting_rp_per_kg' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp_purchases.summary_hpp.budgeting + .rp_per_kg || 0 + ) + : '-'; + }, + }, + { + header: 'Jumlah (Rp)', + id: 'budgeting_amount', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.amount || 0), + footer: (props) => { + return props.column.id === 'budgeting_amount' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp_purchases.summary_hpp.budgeting + .amount || 0 + ) + : '-'; + }, + }, + ], + }, + { + header: 'Realization', + enableSorting: false, + columns: [ + { + header: 'Rp/Ekor', + id: 'realization_rp_per_bird', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.rp_per_bird || 0), + footer: (props) => { + return props.column.id === 'realization_rp_per_bird' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp_purchases.summary_hpp.realization + .rp_per_bird || 0 + ) + : '-'; + }, + }, + { + header: 'Rp/Kg', + id: 'realization_rp_per_kg', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.rp_per_kg || 0), + footer: (props) => { + return props.column.id === 'realization_rp_per_kg' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp_purchases.summary_hpp.realization + .rp_per_kg || 0 + ) + : '-'; + }, + }, + { + header: 'Jumlah (Rp)', + id: 'realization_amount', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.amount || 0), + footer: (props) => { + return props.column.id === 'realization_amount' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp_purchases.summary_hpp.realization + .amount || 0 + ) + : '-'; + }, + }, + ], + }, + ]} + renderCustomRow={(row) => { + const rowData = row.original; + if (rowData.isGroupHeader) { + return ( + + + +
    + {formatTitleCase(rowData.group_name ?? '-')} +
    + + + ); + } + return null; + }} + renderFooter={isResponseSuccess(finance)} + /> +
    +
    + +
    + + data={profitLossTableData} + columns={[ + { + header: 'Jenis', + enableSorting: false, + accessorFn: (item) => item.type, + cell: (item) => ( +
    + {formatTitleCase(item.row.original.type || '-')} +
    + ), + footer: (item) => ( +
    + {isResponseSuccess(finance) + ? formatTitleCase( + finance.data.profit_loss.data.summary.net_profit + .label || '-' + ) + : '-'} +
    + ), + }, + { + header: 'Rp/Ekor', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.rp_per_bird || 0), + footer: (item) => ( +
    + {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.data.summary.net_profit + .rp_per_bird || 0 + ) + : formatCurrency(0)} +
    + ), + }, + { + header: 'Rp/Kg', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.rp_per_kg || 0), + footer: (item) => ( +
    + {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.data.summary.net_profit + .rp_per_kg || 0 + ) + : formatCurrency(0)} +
    + ), + }, + { + header: 'Jumlah (Rp)', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.amount || 0), + footer: (item) => ( +
    + {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.data.summary.net_profit + .amount || 0 + ) + : formatCurrency(0)} +
    + ), + }, + ]} + renderCustomRow={(row) => { + const rowData = row.original; + if (rowData.isGroupHeader) { + if (rowData.amount) { + return ( + + +
    + {formatTitleCase(rowData.label ?? '-')} +
    + + +
    + {formatCurrency(rowData.rp_per_bird ?? 0)} +
    + + +
    + {formatCurrency(rowData.rp_per_kg ?? 0)} +
    + + +
    + {formatCurrency(rowData.amount ?? 0)} +
    + + + ); + } + return ( + + +
    + {formatTitleCase(rowData.group_name ?? '-')} +
    + + + ); + } + return null; + }} + className={{ + paginationClassName: 'hidden', + }} + renderFooter={isResponseSuccess(finance)} + /> +
    +
    + +
    + ); +}; + +export default ClosingFinanceTable; diff --git a/src/components/pages/closing/ClosingProductionDataTabContent.tsx b/src/components/pages/closing/ClosingProductionDataTabContent.tsx new file mode 100644 index 00000000..bffe1707 --- /dev/null +++ b/src/components/pages/closing/ClosingProductionDataTabContent.tsx @@ -0,0 +1,235 @@ +'use client'; + +import useSWR from 'swr'; +import { ClosingApi } from '@/services/api/closing'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { formatNumber } from '@/lib/helper'; + +interface ClosingProductionDataTabContentProps { + projectFlockId: number; +} + +const ClosingProductionDataTabContent = ({ + projectFlockId, +}: ClosingProductionDataTabContentProps) => { + const { data: productionData, isLoading } = useSWR( + `${ClosingApi.basePath}/${projectFlockId}/production-data`, + () => ClosingApi.getProductionData(projectFlockId) + ); + + if (isLoading) { + return ( +
    + +
    + ); + } + + if (!productionData || !isResponseSuccess(productionData)) { + return ( +
    + Gagal memuat data produksi. +
    + ); + } + + const { purchase, sales, performance } = productionData.data; + + // Helper for consistent row styling + const DataRow = ({ + label, + value, + unit = '', + valueClassName = 'font-bold text-gray-800', + unitClassName = 'text-gray-500 w-12 text-right', + }: { + label: string; + value: string | number; + unit?: string; + valueClassName?: string; + unitClassName?: string; + }) => ( +
    + {label} +
    + {value} + {unit && {unit}} +
    +
    + ); + + return ( +
    +

    Data Produksi

    + +
    + {/* Left Column */} +
    + {/* Purchase Section */} +
    +

    + Pembelian +

    +
    + + + + + + +
    +
    + + {/* Sales Section */} +
    +

    + Penjualan +

    +
    + {/* Chicken Sales */} +
    + + + + +
    + + {/* Egg Sales (if available) */} + {sales.egg && ( + <> +
    +
    + + + + +
    + + )} +
    +
    +
    + + {/* Divider Line (Absolute centered) */} +
    + + {/* Right Column */} +
    + {/* Performance Section */} +
    +

    + Performance +

    +
    + + + + + + + + + +
    +
    +
    +
    +
    + ); +}; + +export default ClosingProductionDataTabContent; diff --git a/src/components/pages/closing/ClosingSapronakCalculationTable.tsx b/src/components/pages/closing/ClosingSapronakCalculationTable.tsx index 445b7d8c..ea27fd80 100644 --- a/src/components/pages/closing/ClosingSapronakCalculationTable.tsx +++ b/src/components/pages/closing/ClosingSapronakCalculationTable.tsx @@ -154,66 +154,74 @@ const ClosingSapronakCalculationTable = ({ return (
    - {isResponseSuccess(sapronakCalculation) && ( - <> - - - data={sapronakCalculation.data?.doc_broiler.rows ?? []} - columns={docBroilerColumns} - className={{ - containerClassName: 'my-4', - }} - renderFooter - /> - + + + data={ + isResponseSuccess(sapronakCalculation) + ? (sapronakCalculation.data?.doc_broiler.rows ?? []) + : [] + } + columns={docBroilerColumns} + className={{ + containerClassName: 'my-4', + }} + renderFooter={isResponseSuccess(sapronakCalculation)} + /> + - - - data={sapronakCalculation.data?.ovk.rows ?? []} - columns={ovkColumns} - className={{ - containerClassName: 'my-4', - }} - renderFooter - /> - + + + data={ + isResponseSuccess(sapronakCalculation) + ? (sapronakCalculation.data?.ovk.rows ?? []) + : [] + } + columns={ovkColumns} + className={{ + containerClassName: 'my-4', + }} + renderFooter={isResponseSuccess(sapronakCalculation)} + /> + - - - data={sapronakCalculation.data?.pakan.rows ?? []} - columns={pakanColumns} - className={{ - containerClassName: 'my-4', - }} - renderFooter - /> - - - )} + + + data={ + isResponseSuccess(sapronakCalculation) + ? (sapronakCalculation.data?.pakan.rows ?? []) + : [] + } + columns={pakanColumns} + className={{ + containerClassName: 'my-4', + }} + renderFooter={isResponseSuccess(sapronakCalculation)} + /> +
    ); }; diff --git a/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx index 30807d1c..a3de8a34 100644 --- a/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx +++ b/src/components/pages/inventory/adjustment/InventoryAdjustmentTable.tsx @@ -1,5 +1,6 @@ 'use client'; +import Badge from '@/components/Badge'; import Button from '@/components/Button'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import Table from '@/components/Table'; @@ -77,46 +78,39 @@ const InventoryAdjustmentTable = () => { year: 'numeric', }), }, - { - id: 'before_quantity', - header: 'Stok Sebelum', - accessorFn: (row) => formatNumber(String(row.before_quantity)), - }, - { - id: 'after_quantity', - header: 'Stok Sesudah', - accessorFn: (row) => formatNumber(String(row.after_quantity)), - }, + // { + // id: 'before_quantity', + // header: 'Stok Sebelum', + // accessorFn: (row) => + // formatNumber(String(row.product_warehouse?.quantity)), + // }, + // { + // id: 'after_quantity', + // header: 'Stok Sesudah', + // accessorFn: (row) => + // formatNumber(String(row.product_warehouse?.quantity)), + // }, { id: 'quantity', header: 'Kuantitas', - accessorFn: (row) => formatNumber(String(row.quantity)), + accessorFn: (row) => formatNumber(String(row.increase + row.decrease)), }, { id: 'transaction_type', header: 'Tipe Transaksi', accessorFn: (row) => { - if (row.transaction_type === 'INCREASE') return 'Peningkatan'; - if (row.transaction_type === 'DECREASE') return 'Penurunan'; + if (row.increase > 0) return 'Peningkatan'; + if (row.decrease > 0) return 'Penurunan'; return '-'; }, cell: (props) => { - const type = props.row.original.transaction_type; - const label = - type === 'INCREASE' - ? 'Peningkatan' - : type === 'DECREASE' - ? 'Penurunan' - : '-'; + const type = props.row.original.increase; + const label = type > 0 ? 'Peningkatan' : type <= 0 ? 'Penurunan' : '-'; return ( -
    + 0 ? 'success' : 'error'}> {label} -
    + ); }, }, diff --git a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx index 2c6c463c..f134369e 100644 --- a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx @@ -76,7 +76,7 @@ const InventoryAdjustmentForm = ({ product_category: undefined, product: undefined, warehouse: undefined, - quantity: initialValues?.quantity ?? 0, + quantity: initialValues?.increase ?? initialValues?.decrease ?? 0, transaction_type: undefined, note: initialValues?.note ?? '', }; @@ -214,16 +214,8 @@ const InventoryAdjustmentForm = ({ 'quantity', initialValues.product_warehouse.quantity ); - formik.setFieldValue( - 'transaction_type', - initialValues.transaction_type.toLowerCase() - ); formik.setFieldValue('note', initialValues.note); } - if (initialValues?.transaction_type) { - const type = initialValues.transaction_type.toLowerCase(); - setQuantityLabel(type === 'increase' ? 'Tambah Stok' : 'Kurangi Stok'); - } }, [ formik, initialValues, @@ -278,26 +270,6 @@ const InventoryAdjustmentForm = ({ className='w-full mt-8 flex flex-col gap-6' >
    - {/* Text Input Before Quantity */} - {type === 'detail' && initialValues && ( - <> - - - - )} - {/* Select Input Product Category */} { const stockLogs = useMemo(() => { return ( - inventoryProduct?.product_warehouses?.flatMap( - (warehouse) => warehouse.stock_logs || [] + inventoryProduct?.product_warehouses?.flatMap((warehouse) => + warehouse.stock_logs.map((log) => ({ + ...log, + warehouse_name: warehouse.warehouse_name, + warehouse_id: warehouse.warehouse_id, + })) ) || [] ); }, [inventoryProduct]); diff --git a/src/components/pages/inventory/product/detail/StockLogTable.tsx b/src/components/pages/inventory/product/detail/StockLogTable.tsx index 42f7bc29..96d3dda6 100644 --- a/src/components/pages/inventory/product/detail/StockLogTable.tsx +++ b/src/components/pages/inventory/product/detail/StockLogTable.tsx @@ -3,7 +3,11 @@ import Table from '@/components/Table'; import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; import { StockLog } from '@/types/api/inventory/product'; -const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => { +const StockLogTable = ({ + stockLogs, +}: { + stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[]; +}) => { return ( { return formatDate(props.row.original.created_at, 'DD-MMM-yyyy'); }, }, + { + header: 'Gudang', + accessorKey: 'warehouse_name', + }, { header: 'Peningkatan', accessorKey: 'increase', diff --git a/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx b/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx index 26072927..8caaf216 100644 --- a/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx +++ b/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx @@ -21,6 +21,7 @@ import { useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { useRouter } from 'next/navigation'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; +import { ApprovalApi } from '@/services/api/approval'; const ProjectFlockClosingForm = ({ projectFlock, @@ -31,7 +32,7 @@ const ProjectFlockClosingForm = ({ }) => { const router = useRouter(); const closeModal = useModal(); - const isCanClose = projectFlock.approval?.step_number <= 2; + const [isClosingLoading, setIsClosingLoading] = useState(false); const { data: closingData, isLoading } = useSWR( @@ -39,19 +40,35 @@ const ProjectFlockClosingForm = ({ () => ProjectFlockKandangApi.checkClosing(projectFlockKandang.id) ); + const { data: projectFlockKandangApprovals } = useSWR( + `${ApprovalApi.basePath}?module_name=PROJECT_FLOCK_KANDANGS&module_id=${projectFlockKandang.id}`, + () => + ApprovalApi.getAllFetcher( + `${ApprovalApi.basePath}?module_name=PROJECT_FLOCK_KANDANGS&module_id=${projectFlockKandang.id}` + ) + ); + + const isCanClose = useMemo(() => { + return isResponseSuccess(projectFlockKandangApprovals) + ? projectFlockKandangApprovals?.data?.[0]?.step_number <= 2 + : true; + }, [projectFlockKandangApprovals]); + const confirmationModalCloseClickHandler = async () => { setIsClosingLoading(true); const deleteProjectFlockRes = await ProjectFlockKandangApi.closing( projectFlockKandang?.id as number, { - closed_date: formatDate(new Date(), 'YYYY-MM-DD'), + closed_date: isCanClose ? formatDate(new Date(), 'YYYY-MM-DD') : '', action: isCanClose ? 'close' : 'unclose', } ); if (isResponseSuccess(deleteProjectFlockRes)) { toast.success(deleteProjectFlockRes?.message as string); - router.push(`/production/project-flock`); + router.push( + `/production/project-flock/detail?projectFlockId=${projectFlock.id}` + ); } if (isResponseError(deleteProjectFlockRes)) { toast.error(deleteProjectFlockRes?.message as string); diff --git a/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx b/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx index 92510a8d..41b511c9 100644 --- a/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx +++ b/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx @@ -68,7 +68,7 @@ const ProjectFlockDetail = ({ latestApproval: projectFlock?.approval, approvalLines: PROJECT_FLOCK_APPROVAL_LINE, moduleName: 'PROJECT_FLOCKS', - moduleId: projectFlock?.id.toString() ?? '', + moduleId: projectFlock?.id?.toString() ?? '', }); const { approvals: kandangApprovals, isLoading: kandangApprovalsLoading } = diff --git a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx index 9e5eaeef..5ce62733 100644 --- a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx +++ b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx @@ -47,9 +47,7 @@ import Card from '@/components/Card'; import ProjectFlockKandangTable from '@/components/pages/production/project-flock/form/ProjectFlockKandangTable'; import { Nonstock } from '@/types/api/master-data/nonstock'; import { useUiStore } from '@/stores/ui/ui.store'; -import Link from 'next/link'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; -import { formatDate } from '@/lib/helper'; interface ProjectFlockFormProps { formType?: 'add' | 'edit' | 'detail'; @@ -260,7 +258,9 @@ const ProjectFlockForm = ({ const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { formik.setFieldValue('category', (val as OptionType)?.value); formik.setFieldValue('category_option', val); - formik.setFieldTouched('category', true); + if (val == null) { + formik.setFieldTouched('category', true); + } }; // Submit Handler @@ -788,7 +788,7 @@ const ProjectFlockForm = ({ } errorMessage={formik.errors.area_id as string} isClearable - isDisabled={formType === 'detail'} + isDisabled={formType != 'add'} /> { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + reset: resetFilter, + } = useTableFilter({ + initial: { + search: '', + area_id: '', + location_id: '', + warehouse_id: '', + customer_id: '', + start_date: '', + end_date: '', + marketing_type: '', + filter_by: '', + sort_by: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + area_id: 'area_id', + location_id: 'location_id', + warehouse_id: 'warehouse_id', + customer_id: 'customer_id', + start_date: 'start_date', + end_date: 'end_date', + marketing_type: 'marketing_type', + filter_by: 'filter_by', + sort_by: 'sort_by', + }, + }); + + const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`; + + const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = + useState(false); + const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false); + + const [selectedArea, setSelectedArea] = useState(null); + const { + setInputValue: setAreaInputValue, + options: areaOptions, + isLoadingOptions: isLoadingAreaOptions, + } = useSelect(AreaApi.basePath, 'id', 'name'); + + const areaChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedArea(val as OptionType); + updateFilter('area_id', val ? ((val as OptionType).value as string) : ''); + }; + + const [selectedLocation, setSelectedLocation] = useState( + null + ); + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocationOptions, + } = useSelect(LocationApi.basePath, 'id', 'name'); + + const locationChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedLocation(val as OptionType); + updateFilter( + 'location_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const [selectedWarehouse, setSelectedWarehouse] = useState( + null + ); + const { + setInputValue: setWarehouseInputValue, + options: warehouseOptions, + isLoadingOptions: isLoadingWarehouseOptions, + } = useSelect(WarehouseApi.basePath, 'id', 'name'); + + const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedWarehouse(val as OptionType); + updateFilter( + 'warehouse_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const [selectedCustomer, setSelectedCustomer] = useState( + null + ); + const { + setInputValue: setCustomerInputValue, + options: customerOptions, + isLoadingOptions: isLoadingCustomerOptions, + } = useSelect(CustomerApi.basePath, 'id', 'name'); + + const customerChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedCustomer(val as OptionType); + updateFilter( + 'customer_id', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const startDateChangeHandler = (e: React.ChangeEvent) => { + updateFilter('start_date', e.target.value ? e.target.value : ''); + }; + + const endDateChangeHandler = (e: React.ChangeEvent) => { + updateFilter('end_date', e.target.value ? e.target.value : ''); + }; + + const [selectedMarketingType, setSelectedMarketingType] = + useState(null); + const marketingTypeChangeHandler = ( + val: OptionType | OptionType[] | null + ) => { + setSelectedMarketingType(val as OptionType); + updateFilter( + 'marketing_type', + val ? ((val as OptionType).value as string) : '' + ); + }; + + const searchChangeHandler: ChangeEventHandler = (e) => { + updateFilter('search', e.target.value); + }; + + const filterByChangeHandler = (filterBy: string) => { + updateFilter('filter_by', filterBy); + }; + + const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => { + updateFilter('sort_by', sort); + }; + + const exportToExcelHandler = async () => { + setIsLoadingExportingToExcel(true); + + await MarketingReportApi.exportDailyMarketingToExcel( + getTableFilterQueryString() + ); + + setIsLoadingExportingToExcel(false); + }; + + const exportToPdfHandler = async () => { + setIsLoadingExportingToPdf(true); + + const params = new URLSearchParams(getTableFilterQueryString()); + + params.set('limit', '9999999'); + + const queryString = `?${params.toString()}`; + + try { + const dailyMarketingsReport = await httpClient< + BaseApiResponse + >(`${MarketingReportApi.basePath}${queryString}`); + + if (isResponseError(dailyMarketingsReport)) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + return; + } + + const openPdf = async () => { + const dailyMarketingReportPdfBlob = await pdf( + + ).toBlob(); + + const dailyMarketingReportPdfUrl = URL.createObjectURL( + dailyMarketingReportPdfBlob + ); + window.open(dailyMarketingReportPdfUrl, '_blank'); + }; + + const downloadPdf = async () => { + const blob = await pdf( + + ).toBlob(); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = 'laporan-penjualan-harian.pdf'; + link.click(); + + URL.revokeObjectURL(url); + }; + + await openPdf(); + } catch (error) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + } + + setIsLoadingExportingToPdf(false); + }; + + const handleReset = () => { + setSelectedArea(null); + setSelectedLocation(null); + setSelectedWarehouse(null); + setSelectedCustomer(null); + setSelectedMarketingType(null); + resetFilter(); + }; + + return ( +
    +
    +

    Penjualan Harian

    +
    + + {/* Filters */} +
    +
    + + + + + + + + + + + +
    + +
    + + +
    + + + + + + Export{' '} + + + } + > + + + + + +
    +
    +
    + + +
    + ); +}; + +export default DailyMarketingReportContent; diff --git a/src/components/pages/report/DailyMarketingReportPDF.tsx b/src/components/pages/report/DailyMarketingReportPDF.tsx new file mode 100644 index 00000000..337892b3 --- /dev/null +++ b/src/components/pages/report/DailyMarketingReportPDF.tsx @@ -0,0 +1,550 @@ +'use client'; + +import { + Document, + Image, + Page, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer'; + +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; + +interface DailyMarketingReportPDFProps { + data?: DailyMarketingReport; +} + +const DailyMarketingReportPDFStyle = StyleSheet.create({ + page: { + paddingTop: 24, + paddingBottom: 64, + paddingHorizontal: 16, // Reduce padding to fit more columns + orientation: 'landscape', + }, + + companyInfoHeader: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 8, + }, + companyLogo: { + width: 64, + height: 'auto', + }, + companyInfoHeaderDate: { + paddingTop: 8, + fontSize: 10, + }, + companyName: { + fontSize: 12, + fontWeight: 'bold', + marginBottom: 4, + }, + companyAddress: { + fontSize: 8, + maxWidth: 400, + marginBottom: 10, + }, + + title: { + marginTop: 16, + fontSize: 14, + lineHeight: '150%', + textAlign: 'center', + fontFamily: 'Times-Roman', + fontWeight: 'bold', + }, + + footer: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + + position: 'absolute', + fontSize: 8, + bottom: 30, + left: 0, + right: 0, + textAlign: 'center', + color: 'grey', + }, + + // Table Styles + table: { + width: '100%', + marginTop: 16, + borderWidth: 1, + borderColor: '#000000', + borderBottomWidth: 0, + fontSize: 7, // Smaller font for report + }, + tableRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#000000', + alignItems: 'center', + minHeight: 20, + }, + tableHeader: { + backgroundColor: '#f0f0f0', + fontWeight: 'bold', + }, + + // Columns definition (Total 100%) + colNo: { + width: '3%', + padding: 2, + textAlign: 'center', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSoDate: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colDoDate: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colAging: { + width: '3%', + padding: 2, + textAlign: 'center', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colWarehouse: { + width: '7%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colCustomer: { + width: '9%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, // Reduced slightly + colSales: { + width: '6%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colProduct: { + width: '8%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, // Reduced slightly + colDoNumber: { + width: '7%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colVehicle: { + width: '5%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colMarketingType: { + width: '5%', + padding: 2, + textAlign: 'left', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colQty: { + width: '4%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colAvgWeight: { + width: '4%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colTotalWeight: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSalesPrice: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colHppPrice: { + width: '5%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colSalesAmount: { + width: '6%', + padding: 2, + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + }, + colHppAmount: { width: '6%', padding: 2, textAlign: 'right' }, // Last column + + // Text inside columns + cellText: { + fontSize: 6, + }, + headerText: { + fontSize: 7, + fontWeight: 'bold', + textAlign: 'center', + }, + + // Utils + doubleDivider: { + width: '100%', + height: 6, + borderTop: '2px solid black', + borderBottom: '2px solid black', + }, + + // Summary + summaryContainer: { + marginTop: 12, + flexDirection: 'row', + justifyContent: 'flex-end', + width: '100%', + }, + summaryTable: { + width: '30%', + borderWidth: 1, + borderColor: '#000000', + fontSize: 8, + }, + summaryRow: { + flexDirection: 'row', + padding: 2, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + summaryLabel: { + width: '50%', + fontWeight: 'bold', + }, + summaryValue: { + width: '50%', + textAlign: 'right', + }, +}); + +const DailyMarketingReportPDF = ({ data }: DailyMarketingReportPDFProps) => { + const rows = data?.rows || []; + const summary = data?.summary; + + return ( + + + + + + + + {formatDate(Date.now(), 'DD MMMM YYYY')} + + + + + + PT LUMBUNG TELUR INDONESIA + + + SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. + Cipedes, Kec. Sukajadi, Kota Bandung 40162 + + + + + + + + Laporan Penjualan Harian + + + {/* Data Table */} + + {/* Header */} + + + No + + + + Tgl SO + + + + + Tgl DO + + + + Aging + + + + Gudang + + + + + Pelanggan + + + + Sales + + + + Produk + + + + No DO + + + + Plat No + + + + Tipe + + + Qty + + + + Rerata + + + + Berat + + + + Hrg Jual + + + + + HPP/kg + + + + + Total Jual + + + + + Total HPP + + + + + {/* Rows */} + {rows.map((row, index) => ( + + + + {index + 1} + + + + + {formatDate(row.so_date, 'DD/MM/YYYY')} + + + + + {formatDate(row.do_date, 'DD/MM/YYYY')} + + + + + {row.aging_days} + + + + + {row.warehouse?.name} + + + + + {row.customer?.name} + + + + + {row.sales} + + + + + {row.product?.name} + + + + + {row.do_number} + + + + + {row.vehicle_number} + + + + + {row.marketing_type} + + + + + {formatNumber(row.qty)} + + + + + {formatNumber(row.average_weight_kg)} + + + + + {formatNumber(row.total_weight_kg)} + + + + + {formatCurrency(row.sales_price_per_kg)} + + + + + {formatCurrency(row.hpp_price_per_kg)} + + + + + {formatCurrency(row.sales_amount)} + + + + + {formatCurrency(row.hpp_amount)} + + + + ))} + + + {/* Summary */} + + + + + Total Qty: + + + {formatNumber(summary?.total_qty ?? 0)} + + + + + Total Berat (kg): + + + {formatNumber(summary?.total_weight_kg ?? 0)} + + + + + Total Penjualan: + + + {formatCurrency(summary?.total_sales_amount ?? 0)} + + + + + Total HPP: + + + {formatCurrency(summary?.total_hpp_amount ?? 0)} + + + + + + + + `${pageNumber} / ${totalPages}` + } + fixed + /> + + + + ); +}; + +export default DailyMarketingReportPDF; diff --git a/src/components/pages/report/DailyMarketingsTable.tsx b/src/components/pages/report/DailyMarketingsTable.tsx new file mode 100644 index 00000000..d6914cf1 --- /dev/null +++ b/src/components/pages/report/DailyMarketingsTable.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { ChangeEventHandler, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { ColumnDef, SortingState } from '@tanstack/react-table'; + +import { Icon } from '@iconify/react'; +import Table from '@/components/Table'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import Card from '@/components/Card'; +import Collapse from '@/components/Collapse'; + +import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { DailyMarketingRow } from '@/types/api/report/marketing'; +import { MarketingReportApi } from '@/services/api/report/marketing-report'; + +interface DailyMarketingsTableProps { + dailyMarketingsReportUrl: string; + onSetPage: (page: number) => void; + pageSize: number; + onSetPageSize: (pageSize: number) => void; + searchValue: string; + onSearchChange: ChangeEventHandler; + onFilterByChange: (filterBy: string) => void; + onSortByChange: (sort: 'asc' | 'desc' | '') => void; +} + +const DailyMarketingsTable = ({ + dailyMarketingsReportUrl, + onSetPage, + pageSize, + onSetPageSize, + searchValue, + onSearchChange, + onFilterByChange, + onSortByChange, +}: DailyMarketingsTableProps) => { + const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR( + dailyMarketingsReportUrl, + MarketingReportApi.getAllDailyMarketingFetcher, + { + keepPreviousData: true, + } + ); + + const [open, setOpen] = useState(true); + + const [sorting, setSorting] = useState([]); + + const dailyMarketingColumns: ColumnDef[] = [ + { + header: 'No', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'so_date', + header: 'Tanggal Jual', + cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'), + footer: 'Total', + }, + { + accessorKey: 'do_date', + header: 'Tanggal DO', + cell: (props) => formatDate(props.row.original.do_date, 'DD-MMM-YYYY'), + }, + { + accessorKey: 'aging_days', + header: 'Aging', + cell: (props) => `${props.row.original.aging_days} hari`, + }, + { + accessorKey: 'warehouse.name', + header: 'Gudang', + }, + { + accessorKey: 'customer.name', + header: 'Pelanggan', + }, + { + accessorKey: 'do_number', + header: 'No. DO', + }, + { + accessorKey: 'sales', + header: 'Sales/Marketing', + }, + { + accessorKey: 'vehicle_number', + header: 'No. Polisi', + cell: (props) => ( + {props.row.original.vehicle_number} + ), + }, + { + accessorKey: 'marketing_type', + header: 'Marketing Type', + }, + { + accessorKey: 'product.name', + header: 'Produk', + }, + { + accessorKey: 'qty', + header: 'Kuantitas', + cell: (props) => formatNumber(props.row.original.qty), + footer: () => { + const totalQty = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_qty + : 0; + + return formatNumber(totalQty); + }, + }, + { + accessorKey: 'average_weight_kg', + header: 'Bobot Rata-Rata (Kg)', + cell: (props) => formatNumber(props.row.original.average_weight_kg), + }, + { + accessorKey: 'total_weight_kg', + header: 'Bobot Total (Kg)', + cell: (props) => formatNumber(props.row.original.total_weight_kg), + footer: () => { + const totalWeightKg = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_weight_kg + : 0; + + return formatNumber(totalWeightKg); + }, + }, + { + accessorKey: 'sales_price_per_kg', + header: 'Harga Jual (Rp)', + cell: (props) => formatCurrency(props.row.original.sales_price_per_kg), + }, + { + accessorKey: 'hpp_price_per_kg', + header: 'HPP (Rp)', + cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg), + }, + { + accessorKey: 'sales_amount', + header: 'Total (Rp)', + cell: (props) => formatCurrency(props.row.original.sales_amount), + footer: () => { + const totalSalesAmount = isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.summary.total_sales_amount + : 0; + + return formatCurrency(totalSalesAmount); + }, + }, + ]; + + useEffect(() => { + if (sorting.length === 1) { + onFilterByChange(sorting[0].id); + onSortByChange(sorting[0].desc ? 'desc' : 'asc'); + } else { + onFilterByChange(''); + onSortByChange(''); + } + }, [sorting]); + + useEffect(() => { + if (!open) { + setOpen( + isResponseSuccess(dailyMarketings) + ? dailyMarketings.data.rows.length > 0 + : false + ); + } + }, [dailyMarketings, isResponseSuccess]); + + return ( + + +
    Penjualan Harian
    + + +
    + } + className='w-full!' + titleClassName='w-full p-0!' + > +
    +
    +
    + +
    +
    + + + data={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.data.rows + : [] + } + columns={dailyMarketingColumns} + pageSize={pageSize} + onPageSizeChange={onSetPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(dailyMarketings) + ? dailyMarketings?.meta?.total_results + : 0 + } + onPageChange={onSetPage} + isLoading={isLoadingDailyMarketings} + sorting={sorting} + setSorting={setSorting} + renderFooter={true} + className={{ + containerClassName: cn({ + 'w-full mb-20': + isResponseSuccess(dailyMarketings) && + dailyMarketings?.data?.rows.length === 0, + }), + }} + /> +
    + + + ); +}; + +export default DailyMarketingsTable; diff --git a/src/components/pages/report/MarketingReportContent.tsx b/src/components/pages/report/MarketingReportContent.tsx new file mode 100644 index 00000000..d54c935a --- /dev/null +++ b/src/components/pages/report/MarketingReportContent.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { JSX, useState } from 'react'; + +import Tabs from '@/components/Tabs'; +import DailyMarketingReportContent from '@/components/pages/report/DailyMarketingReportContent'; +import HppPerKandangTab from './sale/tab/HppPerKandangTab'; + +type MarketingReportTabType = + | 'daily' + | 'transaction' + | 'hpp-comparison' + | 'daily-hpp'; + +const marketingReportTabs: { + id: MarketingReportTabType; + label: string; + content: JSX.Element; +}[] = [ + { + id: 'daily', + label: 'Penjualan Harian', + content: , + }, + { + id: 'daily-hpp', + label: 'HPP Harian Kandang', + content: , + }, +]; + +const MarketingReportContent = () => { + const [activeTab, setActiveTab] = useState('daily'); + + return ( +
    + +
    + ); +}; + +export default MarketingReportContent; diff --git a/src/components/pages/report/expense/ReportExpenseTable.tsx b/src/components/pages/report/expense/ReportExpenseTable.tsx new file mode 100644 index 00000000..c34072a2 --- /dev/null +++ b/src/components/pages/report/expense/ReportExpenseTable.tsx @@ -0,0 +1,867 @@ +import { useState, useMemo, useCallback } from 'react'; +import { ChangeEventHandler } from 'react'; +import useSWR from 'swr'; +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import DateInput from '@/components/input/DateInput'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; +import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; +import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; +import { cn, formatCurrency, formatDate } from '@/lib/helper'; +import { ReportExpense } from '@/types/api/report/report-expense'; +import { Icon } from '@iconify/react'; +import { ColumnDef } from '@tanstack/react-table'; +import { ReportExpenseApi } from '@/services/api/report'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import Pagination from '@/components/Pagination'; +import Dropdown from '@/components/dropdown/Dropdown'; +import Menu from '@/components/menu/Menu'; +import MenuItem from '@/components/menu/MenuItem'; +import * as XLSX from 'xlsx'; +import { generateReportExpensePDF } from './pdf/ReportExpenseExport'; +import toast from 'react-hot-toast'; + +const ReportExpenseTable = () => { + // ===== STATE MANAGEMENT ===== + const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); + const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [pdfProgress, setPdfProgress] = useState(0); + const [excelProgress, setExcelProgress] = useState(0); + const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; + + // ===== SUBMISSION STATE ===== + const [isSubmitted, setIsSubmitted] = useState(false); + + // ===== TABLE FILTER STATE ===== + const { + state: filterState, + updateFilter, + setPage, + setPageSize, + reset: resetFilterState, + toQueryString, + } = useTableFilter({ + initial: { + location_id: '', + supplier_id: '', + kandang_id: '', + nonstock_id: '', + realization_date: '', + category: '', + search: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + }, + }); + + // ===== SELECT OPTIONS ===== + const { options: optionsLocation, isLoadingOptions: isLoadingLocation } = + useSelect(`/master-data/locations`, 'id', 'name'); + const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } = + useSelect(`/master-data/suppliers`, 'id', 'name'); + const { options: optionsKandang, isLoadingOptions: isLoadingKandang } = + useSelect(`/master-data/kandangs`, 'id', 'name', '', { + location_id: filterState.location_id, + }); + const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } = + useSelect(`/master-data/nonstocks`, 'id', 'name'); + + const categoryOptions = useMemo( + () => [ + { value: 'BOP', label: 'BOP' }, + { value: 'NON-BOP', label: 'Non BOP' }, + ], + [] + ); + + // Mendapatkan value option select dari filter state + const selectedLocation = useMemo( + () => + optionsLocation.find( + (opt) => String(opt.value) === filterState.location_id + ) || null, + [optionsLocation, filterState.location_id] + ); + const selectedSupplier = useMemo( + () => + optionsSupplier.find( + (opt) => String(opt.value) === filterState.supplier_id + ) || null, + [optionsSupplier, filterState.supplier_id] + ); + const selectedKandang = useMemo( + () => + optionsKandang.find( + (opt) => String(opt.value) === filterState.kandang_id + ) || null, + [optionsKandang, filterState.kandang_id] + ); + const selectedNonstock = useMemo( + () => + optionsNonstock.find( + (opt) => String(opt.value) === filterState.nonstock_id + ) || null, + [optionsNonstock, filterState.nonstock_id] + ); + const selectedCategory = useMemo( + () => + categoryOptions.find((opt) => opt.value === filterState.category) || null, + [categoryOptions, filterState.category] + ); + + // ===== FILTER CHANGE HANDLERS ===== + const locationChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const option = val as OptionType; + updateFilter('location_id', option ? String(option.value) : ''); + updateFilter('kandang_id', ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const kandangChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const option = val as OptionType; + updateFilter('kandang_id', option ? String(option.value) : ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const supplierChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const option = val as OptionType; + updateFilter('supplier_id', option ? String(option.value) : ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const nonstockChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const option = val as OptionType; + updateFilter('nonstock_id', option ? String(option.value) : ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const categoryChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const option = val as OptionType; + updateFilter('category', option ? String(option.value) : ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const realizationDateChangeHandler = useCallback< + ChangeEventHandler + >( + (e) => { + updateFilter('realization_date', e.target.value || ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const searchChangeHandler = useCallback( + (e: React.ChangeEvent) => { + updateFilter('search', e.target.value); + setIsSubmitted(false); + }, + [updateFilter] + ); + + // ===== RESET FILTERS ===== + const resetFilters = useCallback(() => { + resetFilterState(); + setIsSubmitted(false); + }, [resetFilterState]); + + // ===== SUBMIT HANDLER ===== + const handleSubmit = useCallback(() => { + setIsSubmitted(true); + setPage(1); + }, [setPage]); + + // ===== DATA FETCHING FOR TABLE ===== + const { data: reportExpenseResponse, isLoading } = useSWR( + isSubmitted + ? () => { + return ['report-expense', toQueryString()]; + } + : null, + ([, query]) => { + const endpoint = `${ReportExpenseApi.basePath}${query}`; + return ReportExpenseApi.getAllFetcher(endpoint); + } + ); + + const data: ReportExpense[] = useMemo( + () => + isResponseSuccess(reportExpenseResponse) + ? (reportExpenseResponse?.data as ReportExpense[]) || [] + : [], + [reportExpenseResponse] + ); + + const meta = useMemo( + () => + isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta + ? reportExpenseResponse.meta + : null, + [reportExpenseResponse] + ); + + // ===== EXPORT DATA FETCHER ===== + const reportExpenseExport = useCallback(async (): Promise< + ReportExpense[] | null + > => { + const params = new URLSearchParams(toQueryString().replace('?', '')); + params.set('limit', 'limit'); + params.set('page', '1'); + + const endpoint = `${ReportExpenseApi.basePath}?${params.toString()}`; + const response = await ReportExpenseApi.getAllFetcher(endpoint); + + return isResponseSuccess(response) ? response.data : null; + }, [toQueryString]); + + // ===== EXPORT HANDLERS ===== + const handleExportPdf = useCallback(async () => { + if (isPdfExportLoading) return; + setIsPdfExportLoading(true); + setPdfProgress(0); + + await new Promise((resolve) => + requestAnimationFrame(() => resolve(undefined)) + ); + + try { + // Stage 1: Fetching data (0-20%) + setPdfProgress(10); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const allData = await reportExpenseExport(); + if (!allData || allData.length === 0) { + toast.error('Tidak ada data untuk diekspor.'); + setIsPdfExportLoading(false); + setPdfProgress(0); + return; + } + + // Stage 2: Data fetched - langsung loncat ke progress tinggi + setPdfProgress(30); + await new Promise((resolve) => setTimeout(resolve, 50)); + const progressInterval = setInterval(() => { + setPdfProgress((prev) => { + // Increment kecil dan random antara 0.5-2% + const increment = Math.random() * 1.5 + 0.5; + const newProgress = Math.min(prev + increment, 50); + return newProgress; + }); + }, 300); // Update setiap 300ms + + const pdfParams = { + location_name: selectedLocation?.label, + supplier_name: selectedSupplier?.label, + kandang_name: selectedKandang?.label, + nonstock_name: selectedNonstock?.label, + category: selectedCategory?.label, + realization_date: filterState.realization_date, + search: filterState.search, + }; + + setDropdownOpen(false); + + // Stage 3: Langsung loncat ke 80-85% untuk menghindari stuck + const baseProgress = 80 + Math.floor(Math.random() * 16); // Random 80-85% + setPdfProgress(baseProgress); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Stage 4: Berikan jeda untuk UI update + await new Promise((resolve) => + requestAnimationFrame(() => resolve(undefined)) + ); + + // Proses PDF yang sebenarnya + await generateReportExpensePDF(allData, pdfParams); + + clearInterval(progressInterval); + + // Stage 5: Finalizing (98-100%) + setPdfProgress(99); + await new Promise((resolve) => setTimeout(resolve, 100)); + + setPdfProgress(100); + toast.success('PDF berhasil dibuat dan diunduh.'); + + // Reset progress setelah selesai + setTimeout(() => setPdfProgress(0), 500); + } catch (error) { + console.error('PDF Export Error:', error); + toast.error('Gagal membuat PDF. Silakan coba lagi.'); + setPdfProgress(0); + } finally { + setIsPdfExportLoading(false); + } + }, [ + reportExpenseExport, + selectedLocation, + selectedSupplier, + selectedKandang, + selectedNonstock, + selectedCategory, + filterState.realization_date, + filterState.search, + ]); + + const handleExportExcel = useCallback(async () => { + if (isExcelExportLoading) return; + setIsExcelExportLoading(true); + setExcelProgress(0); + setDropdownOpen(false); + + await new Promise((resolve) => + requestAnimationFrame(() => resolve(undefined)) + ); + + try { + // Stage 1: Fetching data (0-20%) + setExcelProgress(15); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const allDataForExport = await reportExpenseExport(); + + if (!allDataForExport || allDataForExport.length === 0) { + toast.error('Tidak ada data untuk diekspor.'); + setIsExcelExportLoading(false); + setExcelProgress(0); + return; + } + + // Stage 2: Data fetched (20-40%) + setExcelProgress(30); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Stage 3: Grouping data (40-60%) + setExcelProgress(50); + const groupedBySupplier: Record = {}; + allDataForExport.forEach((item) => { + const supplierName = item.supplier?.name || 'Unknown Supplier'; + if (!groupedBySupplier[supplierName]) { + groupedBySupplier[supplierName] = []; + } + groupedBySupplier[supplierName].push(item); + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Stage 4: Creating workbook (60-80%) + setExcelProgress(70); + const workbook = XLSX.utils.book_new(); + + const supplierEntries = Object.entries(groupedBySupplier); + const totalSuppliers = supplierEntries.length; + + for (let i = 0; i < supplierEntries.length; i++) { + const [supplierName, supplierData] = supplierEntries[i]; + + // Update progress per supplier + const progressIncrement = (20 / totalSuppliers) * (i + 1); + setExcelProgress(70 + progressIncrement); + + const totals = supplierData.reduce( + (acc, item) => ({ + qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0), + total_pengajuan: + acc.total_pengajuan + + (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), + qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0), + total_realisasi: + acc.total_realisasi + + (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), + }), + { + qty_pengajuan: 0, + total_pengajuan: 0, + qty_realisasi: 0, + total_realisasi: 0, + } + ); + + const excelData = supplierData.map((item, index) => ({ + No: index + 1, + 'No. PO': item.po_number || '', + 'No. Referensi': item.reference_number || '', + 'Tanggal Realisasi': item.realization_date + ? formatDate(item.realization_date, 'DD MMM YYYY') + : '', + 'Tanggal Transaksi': item.transaction_date + ? formatDate(item.transaction_date, 'DD MMM YYYY') + : '', + Kategori: item.category || '', + Produk: item.pengajuan?.nonstock?.name || '', + Lokasi: item.kandang?.location?.name || '', + Kandang: item.kandang?.name || '', + 'Qty Pengajuan': item.pengajuan?.qty || 0, + 'Harga Pengajuan': item.pengajuan?.price || 0, + 'Total Pengajuan': + (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), + 'Qty Realisasi': item.realisasi?.qty || 0, + 'Harga Realisasi': item.realisasi?.price || 0, + 'Total Realisasi': + (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), + 'Status Pencairan': item.latest_approval?.step_name || '', + })); + + excelData.push({ + No: 'Total' as unknown as number, + 'No. PO': '', + 'No. Referensi': '', + 'Tanggal Realisasi': '', + 'Tanggal Transaksi': '', + Kategori: '', + Produk: '', + Lokasi: '', + Kandang: '', + 'Qty Pengajuan': totals.qty_pengajuan, + 'Harga Pengajuan': 0, + 'Total Pengajuan': totals.total_pengajuan, + 'Qty Realisasi': totals.qty_realisasi, + 'Harga Realisasi': 0, + 'Total Realisasi': totals.total_realisasi, + 'Status Pencairan': '', + }); + + const worksheet = XLSX.utils.json_to_sheet(excelData); + const colWidths = [ + { wch: 5 }, // No + { wch: 20 }, // No. PO + { wch: 20 }, // No. Referensi + { wch: 15 }, // Tanggal Realisasi + { wch: 15 }, // Tanggal Transaksi + { wch: 15 }, // Kategori + { wch: 30 }, // Produk + { wch: 20 }, // Lokasi + { wch: 15 }, // Kandang + { wch: 15 }, // Qty Pengajuan + { wch: 15 }, // Harga Pengajuan + { wch: 20 }, // Total Pengajuan + { wch: 15 }, // Qty Realisasi + { wch: 15 }, // Harga Realisasi + { wch: 20 }, // Total Realisasi + { wch: 20 }, // Status Pencairan + ]; + worksheet['!cols'] = colWidths; + + const sheetName = supplierName.slice(0, 31); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // Small delay to allow UI update + if (i < supplierEntries.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + + // Stage 5: Writing file (90-100%) + setExcelProgress(95); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`; + XLSX.writeFile(workbook, filename); + + setExcelProgress(100); + toast.success('Excel berhasil dibuat dan diunduh.'); + + // Reset progress + setTimeout(() => setExcelProgress(0), 500); + } catch (error) { + console.error('Excel Export Error:', error); + toast.error('Gagal membuat Excel. Silakan coba lagi.'); + setExcelProgress(0); + } finally { + setIsExcelExportLoading(false); + } + }, [isExcelExportLoading, reportExpenseExport]); + + // ===== PAGINATION HANDLERS ===== + const handlePageChange = (page: number) => { + setPage(page); + }; + + const handleRowChange = (pageSize: number) => { + setPageSize(pageSize); + }; + + const handleNextPage = () => { + if (meta && filterState.page < meta.total_pages) { + setPage(filterState.page + 1); + } + }; + + const handlePrevPage = () => { + if (filterState.page > 1) { + setPage(filterState.page - 1); + } + }; + + // ===== TABLE COLUMNS DEFINITION ===== + const columns = useMemo((): ColumnDef[] => { + return [ + { + header: 'No', + accessorFn: (_, index) => + (filterState.page - 1) * filterState.pageSize + index + 1, + }, + { + header: 'No. PO', + accessorKey: 'po_number', + }, + { + header: 'No. Referensi', + accessorKey: 'reference_number', + }, + { + header: 'Tanggal Realisasi', + accessorKey: 'realization_date', + cell: ({ row }) => { + return formatDate(row.original?.realization_date, 'DD MMM, YYYY'); + }, + }, + { + header: 'Tanggal Transaksi', + accessorKey: 'transaction_date', + cell: ({ row }) => { + return formatDate(row.original?.transaction_date, 'DD MMM, YYYY'); + }, + }, + { + header: 'Kategori', + accessorKey: 'category', + }, + { + header: 'Produk', + accessorFn: (row) => row.pengajuan?.nonstock?.name, + }, + { + header: 'Supplier', + accessorFn: (row) => row.supplier?.name, + }, + { + header: 'Lokasi', + accessorFn: (row) => row.kandang?.location?.name, + }, + { + header: 'Kandang', + accessorFn: (row) => row.kandang?.name, + }, + { + header: 'Pengajuan', + columns: [ + { + header: 'Qty', + id: 'qty_pengajuan', + accessorFn: (row) => row.pengajuan?.qty, + cell: ({ row }) => + row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0', + }, + { + header: 'Harga', + id: 'harga_pengajuan', + accessorFn: (row) => row.pengajuan?.price, + cell: ({ row }) => + formatCurrency(row.original.pengajuan?.price || 0), + }, + { + header: 'Total', + id: 'total_pengajuan', + accessorFn: (row) => + (row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0), + cell: ({ row }) => { + const total = + (row.original.pengajuan?.qty || 0) * + (row.original.pengajuan?.price || 0); + return formatCurrency(total); + }, + }, + ], + }, + { + header: 'Realisasi', + columns: [ + { + header: 'Qty', + id: 'qty_realisasi', + accessorFn: (row) => row.realisasi?.qty, + cell: ({ row }) => + row.original.realisasi?.qty?.toLocaleString('id-ID') || '0', + }, + { + header: 'Harga', + id: 'harga_realisasi', + accessorFn: (row) => row.realisasi?.price, + cell: ({ row }) => + formatCurrency(row.original.realisasi?.price || 0), + }, + { + header: 'Total', + id: 'total_realisasi', + accessorFn: (row) => + (row.realisasi?.qty || 0) * (row.realisasi?.price || 0), + cell: ({ row }) => { + const total = + (row.original.realisasi?.qty || 0) * + (row.original.realisasi?.price || 0); + return formatCurrency(total); + }, + }, + ], + }, + { + header: 'Status Pencairan', + cell: (props) => ( + + ), + }, + { + header: 'Status BOP', + cell: (props) => ( + + ), + }, + ]; + }, [filterState.page, filterState.pageSize]); + + // ===== RENDER ===== + return ( +
    + {isAnyExportLoading && ( +
    + + {((isPdfExportLoading && pdfProgress > 0) || + (isExcelExportLoading && excelProgress > 0)) && ( +
    +
    + {(() => { + const currentProgress = isPdfExportLoading + ? pdfProgress + : excelProgress; + const exportType = isPdfExportLoading ? 'PDF' : 'Excel'; + + if (currentProgress < 20) + return 'Mengambil data dari server...'; + if (currentProgress < 30) return 'Memproses data laporan...'; + if (currentProgress < 40) + return `Menyiapkan struktur dokumen ${exportType}...`; + if (currentProgress < 50) + return 'Mengelompokkan data per supplier...'; + if (currentProgress < 70) + return 'Merender tabel dan kalkulasi...'; + if (currentProgress < 96) + return `Memformat dokumen ${exportType}...`; + if (currentProgress < 100) + return 'Menyelesaikan dan mengunduh...'; + return 'Selesai!'; + })()}{' '} + {Math.round(isPdfExportLoading ? pdfProgress : excelProgress)}% +
    + {((isPdfExportLoading && pdfProgress >= 35 && pdfProgress < 90) || + (isExcelExportLoading && + excelProgress >= 35 && + excelProgress < 90)) && ( +
    + {(isPdfExportLoading ? pdfProgress : excelProgress) < 96 + ? 'Proses ini membutuhkan waktu lebih lama untuk data dalam jumlah besar. Mohon bersabar...' + : 'Sedang memproses baris data. Hampir selesai...'} +
    + )} +
    + )} +
    + )} + +
    +
    + + +
    +
    + { + setDropdownOpen(!dropdownOpen); + }} + > + Export + + } + align='end' + direction='bottom' + open={dropdownOpen} + > + + + + + +
    +
    +
    + } + > +
    + + + + + + + } + /> +
    + + + {/* ===== TABLE CONTENT ===== */} + {!isSubmitted ? ( +
    + Silakan pilih filter dan klik tombol Cari untuk menampilkan data. +
    + ) : isLoading ? ( +
    + +
    + ) : data.length === 0 ? ( +
    + Tidak ada data yang dapat ditampilkan... +
    + ) : ( + <> + + columns={columns} + data={data} + pageSize={10} + className={{ + containerClassName: 'mb-0', + headerRowClassName: cn( + TABLE_DEFAULT_STYLING, + 'whitespace-nowrap' + ), + bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'), + paginationClassName: 'hidden', + }} + /> + {meta && ( +
    + +
    + )} + + )} +
    + ); +}; + +export default ReportExpenseTable; diff --git a/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx b/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx new file mode 100644 index 00000000..a7ff8599 --- /dev/null +++ b/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx @@ -0,0 +1,218 @@ +import { ReportExpense } from '@/types/api/report/report-expense'; +import { formatCurrency, formatDate } from '@/lib/helper'; +import jsPDF from 'jspdf'; +import autoTable, { UserOptions } from 'jspdf-autotable'; +interface jsPDFWithAutoTable extends jsPDF { + lastAutoTable: { + finalY: number; + }; +} + +export interface PDFParams { + location_name?: string; + supplier_name?: string; + realization_date?: string; +} + +const getStatusColor = (action?: string): [number, number, number] => { + switch (action) { + case 'APPROVED': + case 'Selesai': // Berdasarkan data sumber + return [220, 252, 231]; // Hijau muda (#dcfce7) + case 'REJECTED': + return [254, 226, 226]; // Merah muda (#fee2e2) + case 'Realisasi': // Berdasarkan data sumber + return [254, 243, 199]; // Kuning/Amber muda (#fef3c7) + default: + return [255, 255, 255]; // Putih + } +}; + +export const generateReportExpensePDF = async ( + data: ReportExpense[], + params: PDFParams +): Promise => { + // Inisialisasi dokumen dengan tipe yang sudah diekstensi + const doc = new jsPDF('l', 'mm', 'a4') as jsPDFWithAutoTable; + const pageWidth: number = doc.internal.pageSize.getWidth(); + const marginX: number = 14; + + // --- HEADER SECTION --- + doc.setFont('helvetica', 'bold'); + doc.setFontSize(18); + doc.setTextColor(31, 116, 191); // #1f74bf sesuai style + doc.text('PT LUMBUNG TELUR INDONESIA', marginX, 20); + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(7); + doc.setTextColor(102, 102, 102); + doc.text( + 'SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. Cipedes, Kec. Sukajadi, Kota Bandung 40162', + marginX, + 25 + ); + + doc.setDrawColor(0); + doc.line(marginX, 28, pageWidth - marginX, 28); + + // --- TITLE & INFO SECTION --- + doc.setFontSize(18); + doc.setTextColor(31, 116, 191); + doc.text('LAPORAN BIAYA OPERASIONAL', marginX, 38); + + doc.setFontSize(7); + doc.setTextColor(0); + const infoX: number = pageWidth - marginX; + doc.text( + `Tanggal Cetak: ${formatDate(new Date(), 'DD MMM YYYY')}`, + infoX, + 35, + { align: 'right' } + ); + doc.text(`Total Data: ${data.length} transaksi`, infoX, 40, { + align: 'right', + }); + + // --- GROUPING LOGIC --- + const groupedBySupplier = data.reduce( + (acc, item) => { + const supplierName: string = item.supplier?.name || 'Unknown Supplier'; + if (!acc[supplierName]) acc[supplierName] = []; + acc[supplierName].push(item); + return acc; + }, + {} as Record + ); + + let currentY: number = 50; + + // --- RENDER TABLES PER SUPPLIER --- + Object.entries(groupedBySupplier).forEach(([supplierName, items]) => { + // Cek sisa ruang halaman sebelum cetak judul supplier + if (currentY > 180) { + doc.addPage(); + currentY = 20; + } + + doc.setFontSize(14); + doc.setTextColor(31, 116, 191); + doc.text(supplierName, marginX, currentY); + currentY += 5; + + const tableOptions: UserOptions = { + startY: currentY, + head: [ + [ + { content: 'No', rowSpan: 2 }, + { content: 'No. PO', rowSpan: 2 }, + { content: 'No. Referensi', rowSpan: 2 }, + { content: 'Tgl Realisasi', rowSpan: 2 }, + { content: 'Tgl Transaksi', rowSpan: 2 }, + { content: 'Kategori', rowSpan: 2 }, + { content: 'Produk', rowSpan: 2 }, + { content: 'Lokasi', rowSpan: 2 }, + { content: 'Kandang', rowSpan: 2 }, + { content: 'Pengajuan', colSpan: 3, styles: { halign: 'center' } }, + { content: 'Realisasi', colSpan: 3, styles: { halign: 'center' } }, + { content: 'Status BOP', rowSpan: 2 }, + ], + ['Qty', 'Harga', 'Total', 'Qty', 'Harga', 'Total'], + ], + body: items.map((item, index) => { + const pQty: number = item.pengajuan?.qty || 0; + const pPrice: number = item.pengajuan?.price || 0; + const rQty: number = item.realisasi?.qty || 0; + const rPrice: number = item.realisasi?.price || 0; + + return [ + index + 1, + item.po_number || '-', + item.reference_number || '-', + formatDate(item.realization_date, 'DD MMM YY'), + formatDate(item.transaction_date, 'DD MMM YY'), + item.category?.replace('-', ' ') || '-', + item.pengajuan?.nonstock?.name || '-', + item.kandang?.location?.name || '-', + item.kandang?.name || '-', + pQty.toLocaleString('id-ID'), + formatCurrency(pPrice), + formatCurrency(pQty * pPrice), + rQty.toLocaleString('id-ID'), + formatCurrency(rPrice), + formatCurrency(rQty * rPrice), + item.latest_approval?.step_name || '-', + ]; + }), + theme: 'grid', + styles: { fontSize: 6, cellPadding: 1.5, overflow: 'linebreak' }, + headStyles: { + fillColor: [245, 245, 245], + textColor: 0, + fontStyle: 'bold', + lineWidth: 0.1, + }, + // HOOK UNTUK BADGE: + didParseCell: (dataCell) => { + // Index kolom 15 adalah Status BOP (berdasarkan array di atas) + if (dataCell.section === 'body' && dataCell.column.index === 15) { + const statusText = dataCell.cell.raw as string; + + // Berikan warna latar belakang sel sesuai status + dataCell.cell.styles.fillColor = getStatusColor(statusText); + dataCell.cell.styles.textColor = [0, 0, 0]; // Teks hitam agar terbaca + dataCell.cell.styles.fontStyle = 'bold'; + dataCell.cell.styles.halign = 'center'; + } + }, + margin: { left: marginX, right: marginX }, + }; + + autoTable(doc, tableOptions); + currentY = doc.lastAutoTable.finalY + 10; + }); + + // --- GRAND TOTAL SECTION --- + const grandTotals = data.reduce( + (acc, item) => { + const pTotal = (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0); + const rTotal = (item.realisasi?.qty || 0) * (item.realisasi?.price || 0); + return { + pengajuan: acc.pengajuan + pTotal, + realisasi: acc.realisasi + rTotal, + }; + }, + { pengajuan: 0, realisasi: 0 } + ); + + if (currentY > 250) { + doc.addPage(); + currentY = 20; + } + + autoTable(doc, { + startY: currentY, + body: [ + ['GRAND TOTAL PENGAJUAN', formatCurrency(grandTotals.pengajuan)], + ['GRAND TOTAL REALISASI', formatCurrency(grandTotals.realisasi)], + ], + styles: { fontSize: 8, fontStyle: 'bold' }, + columnStyles: { + 0: { cellWidth: 50, fillColor: [245, 245, 245] }, + 1: { cellWidth: 50 }, + }, + theme: 'grid', + margin: { left: marginX }, + }); + + // --- FOOTER --- + const finalY: number = doc.lastAutoTable.finalY + 20; + doc.setFontSize(14); + doc.setTextColor(31, 116, 191); + doc.text('PT LUMBUNG TELUR INDONESIA', pageWidth - marginX, finalY, { + align: 'right', + }); + + // Download File + const fileName: string = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`; + doc.save(fileName); +}; diff --git a/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx b/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx new file mode 100644 index 00000000..65505a5f --- /dev/null +++ b/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx @@ -0,0 +1,365 @@ +import { StyleSheet } from '@react-pdf/renderer'; + +const pdfStyles = StyleSheet.create({ + page: { + fontSize: 18, + fontFamily: 'Helvetica', + padding: 20, + backgroundColor: '#FFFFFF', + }, + header: { + marginBottom: 20, + }, + logo: { + width: 120, + height: 30, + marginBottom: 8, + }, + companyInfo: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 4, + color: '#1f74bf', + }, + address: { + fontSize: 7, + color: '#666666', + maxWidth: 400, + marginBottom: 10, + }, + divider: { + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + marginBottom: 15, + }, + titleSection: { + flexDirection: 'row', + marginBottom: 20, + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + title: { + fontSize: 18, + fontWeight: 'bold', + flex: 3, + color: '#1f74bf', + }, + poInfo: { + flex: 1, + fontSize: 7, + textAlign: 'right', + }, + sectionTitle: { + fontSize: 14, + fontWeight: 'bold', + marginBottom: 8, + color: '#1f74bf', + }, + table: { + borderWidth: 1, + borderColor: '#000000', + marginBottom: 15, + }, + tableRow: { + flexDirection: 'row', + }, + tableHeader: { + backgroundColor: '#F5F5F5', + }, + tableCell: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellLast: { + flex: 1, + padding: 3, + fontSize: 7, + }, + tableCellHeader: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellHeaderLast: { + flex: 1, + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellRight: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableCellRightLast: { + flex: 1, + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableCellNarrow: { + width: '1%', + minWidth: 20, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'center', + }, + tableCellNarrowHeader: { + width: '1%', + minWidth: 20, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + textAlign: 'center', + }, + tableCellWrap: { + flex: 1, + maxWidth: 80, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + flexWrap: 'wrap', + }, + tableCellWrapHeader: { + flex: 1, + maxWidth: 80, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + // Nested header styles + tableHeaderGroup: { + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + backgroundColor: '#F5F5F5', + }, + tableHeaderGroupLast: { + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + backgroundColor: '#F5F5F5', + }, + tableHeaderGroupTitle: { + padding: 3, + fontSize: 7, + fontWeight: 'bold', + textAlign: 'center', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + }, + tableSubHeaderRow: { + flexDirection: 'row', + }, + // Specific width columns + tableCellXSmall: { + width: 30, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellXSmallHeader: { + width: 30, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellSmall: { + width: 40, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellSmallHeader: { + width: 40, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellMedium: { + width: 60, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + tableCellMediumHeader: { + width: 60, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + }, + tableCellRightXSmall: { + width: 30, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableCellRightSmall: { + width: 40, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableCellRightMedium: { + width: 60, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + tableBorderBottom: { + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + }, + grandTotalRow: { + flexDirection: 'row', + borderTopWidth: 1, + borderTopColor: '#000000', + borderTopStyle: 'solid', + }, + grandTotalLabel: { + flex: 3, + padding: 3, + fontSize: 7, + fontWeight: 'bold', + textAlign: 'right', + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + }, + grandTotalValue: { + flex: 1, + padding: 3, + fontSize: 7, + fontWeight: 'bold', + textAlign: 'right', + borderRightWidth: 0, + }, + allocationSection: { + marginBottom: 8, + }, + allocationTable: { + borderWidth: 1, + borderColor: '#000000', + }, + innerTable: { + marginTop: 5, + borderWidth: 1, + borderColor: '#000000', + }, + innerRow: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + }, + innerCell: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + }, + innerCellLast: { + flex: 1, + padding: 3, + fontSize: 7, + }, + innerCellRight: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + innerCellRightLast: { + flex: 1, + padding: 3, + fontSize: 7, + textAlign: 'right', + }, + footer: { + marginTop: 30, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + footerCompany: { + fontSize: 18, + fontWeight: 'bold', + textAlign: 'right', + flex: 1, + color: '#1f74bf', + }, + specialInstructionTable: { + width: '60%', + maxWidth: 300, + borderWidth: 1, + borderColor: '#000000', + flex: 1, + }, +}); + +export default pdfStyles; diff --git a/src/components/pages/report/sale/SaleReportTabs.tsx b/src/components/pages/report/sale/SaleReportTabs.tsx new file mode 100644 index 00000000..988c16b2 --- /dev/null +++ b/src/components/pages/report/sale/SaleReportTabs.tsx @@ -0,0 +1,37 @@ +'use client'; + +import Tabs from '@/components/Tabs'; +import HppPerKandangTab from '@/components/pages/report/sale/tab/HppPerKandangTab'; + +const SaleReportTabs = () => { + const tabs = [ + // { + // id: '1', + // label: 'Penjualan Harian', + // content: 'Penjualan Harian Tab', + // }, + // { + // id: '2', + // label: 'Transaksi Penjualan DO', + // content: 'Transaksi Penjualan DO Tab', + // }, + // { + // id: '3', + // label: 'Perbandingan HPP Per Rentang BW', + // content: 'Perbandingan HPP Per Rentang BW Tab', + // }, + { + id: '4', + label: 'HPP Harian Kandang', + content: , + }, + ]; + + return ( +
    + +
    + ); +}; + +export default SaleReportTabs; diff --git a/src/components/pages/report/sale/export/HppPerkandangExport.tsx b/src/components/pages/report/sale/export/HppPerkandangExport.tsx new file mode 100644 index 00000000..0a712a6c --- /dev/null +++ b/src/components/pages/report/sale/export/HppPerkandangExport.tsx @@ -0,0 +1,497 @@ +'use client'; + +import { + Page, + Text, + View, + Document, + StyleSheet, + Font, + pdf, +} from '@react-pdf/renderer'; +import { + HppPerKandangReport, + HppPerKandangRow, + HppPerKandangPerWeightRange, +} from '@/types/api/report/hpp-per-kandang'; +import { formatDate, formatNumber, formatCurrency } from '@/lib/helper'; + +Font.register({ + family: 'Helvetica', + src: 'helvetica', +}); + +const pdfStyles = StyleSheet.create({ + page: { + fontSize: 10, + fontFamily: 'Helvetica', + padding: 20, + backgroundColor: '#FFFFFF', + }, + titleSection: { + marginBottom: 10, + }, + mainTitle: { + fontSize: 14, + fontWeight: 'bold', + marginBottom: 5, + color: '#1f74bf', + }, + supplierTitle: { + fontSize: 12, + fontWeight: 'bold', + marginBottom: 8, + color: '#1f74bf', + }, + table: { + borderWidth: 1, + borderColor: '#000000', + marginBottom: 15, + }, + tableRow: { + flexDirection: 'row', + }, + tableHeader: { + backgroundColor: '#F5F5F5', + }, + tableCell: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 4, + fontSize: 8, + textAlign: 'left', + }, + tableCellHeader: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 4, + fontSize: 8, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + paddingVertical: 12, + textAlign: 'center', + }, + tableCellHeaderRight: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 4, + fontSize: 8, + fontWeight: 'bold', + backgroundColor: '#F5F5F5', + textAlign: 'right', + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + paddingVertical: 12, + }, + tableCellRight: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 4, + fontSize: 8, + textAlign: 'right', + }, + tableCellCenter: { + flex: 1, + borderRightWidth: 1, + borderRightColor: '#000000', + borderRightStyle: 'solid', + padding: 4, + fontSize: 8, + textAlign: 'center', + }, + tableBorderBottom: { + borderBottomWidth: 1, + borderBottomColor: '#000000', + borderBottomStyle: 'solid', + }, + supplierSection: { + marginBottom: 10, + }, + supplierSectionBreak: { + marginBottom: 15, + }, + parameterBadge: { + backgroundColor: '#F5F5F5', + color: '#333333', + padding: 4, + borderRadius: 4, + fontSize: 8, + marginRight: 8, + marginBottom: 4, + }, + parameterContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + marginBottom: 8, + }, +}); + +interface HppPerKandangExportParams { + data: HppPerKandangReport; + params: { + area_name?: string; + location_name?: string; + kandang_name?: string; + period?: string; + weight_min?: string; + weight_max?: string; + show_unrecorded?: string; + sort_by?: string; + }; +} + +const getParameterText = (params: HppPerKandangExportParams['params']) => { + const paramsText = []; + + if (params.area_name && params.area_name !== 'Semua Area') { + paramsText.push(`Area: ${params.area_name}`); + } + + if (params.location_name && params.location_name !== 'Semua Lokasi') { + paramsText.push(`Lokasi: ${params.location_name}`); + } + + if (params.kandang_name && params.kandang_name !== 'Semua Kandang') { + paramsText.push(`Kandang: ${params.kandang_name}`); + } + + if (params.period) { + const formattedDate = formatDate(params.period, 'DD MMM YYYY'); + paramsText.push(`Tanggal: ${formattedDate}`); + } + + if (params.weight_min || params.weight_max) { + const weightRange = + params.weight_min && params.weight_max + ? `${params.weight_min} - ${params.weight_max} kg` + : params.weight_min + ? `≥ ${params.weight_min} kg` + : `≤ ${params.weight_max} kg`; + paramsText.push(`Rentang Bobot: ${weightRange}`); + } + + if (params.show_unrecorded === 'true') { + paramsText.push('Tampilkan: Tanpa Recording'); + } + + const currentDate = formatDate(new Date().toISOString(), 'DD MMM YYYY HH:mm'); + paramsText.push(`Dicetak: ${currentDate}`); + + return paramsText; +}; + +const createPDFDocument = ( + data: HppPerKandangExportParams['data'], + params: HppPerKandangExportParams['params'] +) => { + const rekapitulasiByWeightRange = data.summary?.per_weight_range || []; + + return ( + + + {/* Title and Parameters */} + + + Laporan > HPP Harian Kandang + + + {getParameterText(params).map((param, index) => ( + + {param} + + ))} + + + + {/* Rekapitulasi Section */} + + Rekapitulasi + + + {/* Table Header */} + + + Rentang BW + + + Sisa Ekor + + + Sisa Kg + + + Rata-Rata Bobot (Kg) + + + Produksi Telur (Butir) + + + Produksi Telur (Kg) + + + Feed (Supplier) + + + DOC (Supplier) + + + Rata-Rata Harga DOC + + + Nilai Nominal Telur + + + HPP Ayam + + + HPP Telur (RP/KG) + + + Nominal Sisa + + + + {/* Table Body - Rekapitulasi */} + {rekapitulasiByWeightRange.map( + (group: HppPerKandangPerWeightRange, index: number) => ( + + + {group.label} + + + {formatNumber(group.remaining_chicken_birds)} + + + + {formatNumber(group.remaining_chicken_weight_kg)} + + + + {formatNumber(group.avg_weight_kg)} + + + {formatNumber(group.egg_production_pieces)} + + + {formatNumber(group.egg_production_kg)} + + + + {group.feed_suppliers + ?.map( + (s: { alias?: string; name: string }) => + s.alias || s.name + ) + .join(' | ') || '-'} + + + + + {group.doc_suppliers + ?.map( + (s: { alias?: string; name: string }) => + s.alias || s.name + ) + .join(' | ') || '-'} + + + + {formatCurrency(group.average_doc_price_rp)} + + + {formatCurrency(group.egg_value_rp)} + + + {formatCurrency(group.hpp_rp)} + + + {formatCurrency(group.egg_hpp_rp_per_kg)} + + + {formatCurrency(group.remaining_value_rp)} + + + ) + )} + + + + {/* Detail Per Kandang Section */} + + Detail Per Kandang + + + {/* Table Header */} + + + No + + + Kandang + + + Rentang BW + + + Rata-Rata Bobot (Kg) + + + Sisa Ekor + + + Sisa Kg (Ayam) + + + Produksi Telur (Butir) + + + Produksi Telur (Kg) + + + Feed (Supplier) + + + DOC (Supplier) + + + Rata-Rata Harga DOC + + + Nilai Nominal Telur + + + HPP Ayam + + + HPP Telur (RP/KG) + + + Nominal Sisa + + + + {/* Table Body - Detail Per Kandang */} + {data.rows.map((item: HppPerKandangRow, index: number) => ( + + + {index + 1} + + + {item.kandang?.name || '-'} + + + + {item.weight_range.weight_min.toFixed(2)} -{' '} + {item.weight_range.weight_max.toFixed(2)} + + + + {formatNumber(item.avg_weight_kg)} + + + {formatNumber(item.remaining_chicken_birds)} + + + {formatNumber(item.remaining_chicken_weight_kg)} + + + {formatNumber(item.egg_production_pieces)} + + + {formatNumber(item.egg_production_kg)} + + + + {item.feed_suppliers + ?.map( + (s: { alias?: string; name: string }) => + s.alias || s.name + ) + .join(' | ')} + + + + + {item.doc_suppliers + ?.map( + (s: { alias?: string; name: string }) => + s.alias || s.name + ) + .join(' | ')} + + + + {formatCurrency(item.average_doc_price_rp)} + + + {formatCurrency(item.egg_value_rp)} + + + {formatCurrency(item.hpp_rp)} + + + {formatCurrency(item.egg_hpp_rp_per_kg)} + + + {formatCurrency(item.remaining_value_rp)} + + + ))} + + + + + ); +}; + +export const generateHppPerKandangPDF = async ( + data: HppPerKandangExportParams['data'], + params: HppPerKandangExportParams['params'] +): Promise => { + const PDFDocument = createPDFDocument(data, params); + + try { + const blob = await pdf(PDFDocument).toBlob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + + const period = params.period || formatDate(new Date(), 'YYYY-MM-DD'); + link.download = `laporan-hpp-harian-kandang-periode-${period}.pdf`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + throw error; + } +}; diff --git a/src/components/pages/report/sale/tab/HppPerKandangTab.tsx b/src/components/pages/report/sale/tab/HppPerKandangTab.tsx new file mode 100644 index 00000000..7d6f0951 --- /dev/null +++ b/src/components/pages/report/sale/tab/HppPerKandangTab.tsx @@ -0,0 +1,959 @@ +import { useState, useMemo, useCallback } from 'react'; +import { ChangeEventHandler } from 'react'; +import useSWR from 'swr'; +import Card from '@/components/Card'; +import SelectInput, { + useSelect, + OptionType, +} from '@/components/input/SelectInput'; +import DateInput from '@/components/input/DateInput'; +import NumberInput from '@/components/input/NumberInput'; +import { AreaApi } from '@/services/api/master-data'; +import { LocationApi } from '@/services/api/master-data'; +import { KandangApi } from '@/services/api/master-data'; +import { SaleReportApi } from '@/services/api/report/marketing-sale'; +import Table from '@/components/Table'; +import { ColumnDef, Row, flexRender } from '@tanstack/react-table'; +import { formatCurrency, formatNumber } from '@/lib/helper'; +import { + HppPerKandangReport, + HppPerKandangRow, + HppPerKandangPerWeightRange, +} from '@/types/api/report/hpp-per-kandang'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import Button from '@/components/Button'; +import Dropdown from '@/components/Dropdown'; +import MenuItem from '@/components/menu/MenuItem'; +import Menu from '@/components/menu/Menu'; +import { generateHppPerKandangPDF } from '../export/HppPerkandangExport'; +import toast from 'react-hot-toast'; +import * as XLSX from 'xlsx'; +import { Icon } from '@iconify/react'; + +const HppPerKandangTab = () => { + // ===== STATE MANAGEMENT ===== + const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); + const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); + const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; + + // ===== SUBMISSION STATE ===== + const [isSubmitted, setIsSubmitted] = useState(false); + + // ===== TABLE FILTER STATE ===== + const { state: tableFilterState, updateFilter } = useTableFilter({ + initial: { + area_id: [] as string[], + location_id: [] as string[], + kandang_id: [] as string[], + weight_min: '', + weight_max: '', + period: '', + sort_by: '', + show_unrecorded: false, + }, + paramMap: { + page: 'page', + pageSize: 'limit', + }, + }); + + const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( + AreaApi.basePath, + 'id', + 'name', + 'search' + ); + + const { options: locationOptions, isLoadingOptions: isLoadingLocations } = + useSelect(LocationApi.basePath, 'id', 'name', 'search'); + + const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } = + useSelect(KandangApi.basePath, 'id', 'name', 'search'); + + const showUnrecordedOptions: OptionType[] = [ + { value: 'false', label: 'Sembunyikan' }, + { value: 'true', label: 'Tampilkan' }, + ]; + + const areaChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const arr = Array.isArray(val) ? val : val ? [val] : []; + updateFilter( + 'area_id', + arr.map((v) => String((v as OptionType).value)) + ); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const locationChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const arr = Array.isArray(val) ? val : val ? [val] : []; + updateFilter( + 'location_id', + arr.map((v) => String((v as OptionType).value)) + ); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const kandangChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const arr = Array.isArray(val) ? val : val ? [val] : []; + updateFilter( + 'kandang_id', + arr.map((v) => String((v as OptionType).value)) + ); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const weightMinChangeHandler = useCallback< + ChangeEventHandler + >( + (e) => { + const val = e.target.value; + updateFilter('weight_min', val ? String(parseFloat(val) || 0) : ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const weightMaxChangeHandler = useCallback< + ChangeEventHandler + >( + (e) => { + const val = e.target.value; + updateFilter('weight_max', val ? String(parseFloat(val) || 0) : ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const periodChangeHandler = useCallback>( + (e) => { + const val = e.target.value; + updateFilter('period', val || ''); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const showUnrecordedChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + updateFilter('show_unrecorded', newVal?.value === 'true'); + setIsSubmitted(false); + }, + [updateFilter] + ); + + const resetFilters = useCallback(() => { + updateFilter('area_id', []); + updateFilter('location_id', []); + updateFilter('kandang_id', []); + updateFilter('weight_min', ''); + updateFilter('weight_max', ''); + updateFilter('period', ''); + updateFilter('sort_by', ''); + updateFilter('show_unrecorded', false); + setIsSubmitted(false); + }, [updateFilter]); + + const handleSubmit = useCallback(() => { + if (!tableFilterState.period) { + toast.error('Periode wajib diisi'); + return; + } + setIsSubmitted(true); + }, [tableFilterState.period]); + + // ===== DATA FETCHING ===== + const { data: hppPerKandang, isLoading } = useSWR( + isSubmitted + ? () => { + const params = { + area_id: + tableFilterState.area_id.length > 0 + ? tableFilterState.area_id.join(',') + : undefined, + location_id: + tableFilterState.location_id.length > 0 + ? tableFilterState.location_id.join(',') + : undefined, + kandang_id: + tableFilterState.kandang_id.length > 0 + ? tableFilterState.kandang_id.join(',') + : undefined, + weight_min: tableFilterState.weight_min || undefined, + weight_max: tableFilterState.weight_max || undefined, + period: tableFilterState.period || undefined, + sort_by: tableFilterState.sort_by || undefined, + show_unrecorded: tableFilterState.show_unrecorded, + }; + + return ['hpp-per-kandang-report', params]; + } + : null, + ([, params]) => + SaleReportApi.getHppPerKandangReport( + params.area_id, + params.location_id, + params.kandang_id, + params.weight_min, + params.weight_max, + params.period, + params.sort_by, + params.show_unrecorded + ) + ); + + const data: HppPerKandangReport['rows'] = useMemo( + () => + isResponseSuccess(hppPerKandang) + ? (hppPerKandang?.data?.rows as HppPerKandangReport['rows']) || [] + : [], + [hppPerKandang] + ); + + const summaryTotal = + isResponseSuccess(hppPerKandang) && hppPerKandang?.data?.summary?.total + ? hppPerKandang.data.summary.total + : undefined; + + const perWeightRangeSummary = useMemo( + () => + isResponseSuccess(hppPerKandang) && + hppPerKandang?.data?.summary?.per_weight_range + ? hppPerKandang.data.summary.per_weight_range + : [], + [hppPerKandang] + ); + + const period = + isResponseSuccess(hppPerKandang) && hppPerKandang?.data?.period + ? hppPerKandang.data.period + : undefined; + + // ===== EXPORT DATA FETCHER ===== + const hppPerKandangExport = + useCallback(async (): Promise => { + const params = { + area_id: + tableFilterState.area_id.length > 0 + ? tableFilterState.area_id.join(',') + : undefined, + location_id: + tableFilterState.location_id.length > 0 + ? tableFilterState.location_id.join(',') + : undefined, + kandang_id: + tableFilterState.kandang_id.length > 0 + ? tableFilterState.kandang_id.join(',') + : undefined, + weight_min: tableFilterState.weight_min || undefined, + weight_max: tableFilterState.weight_max || undefined, + period: tableFilterState.period || undefined, + sort_by: tableFilterState.sort_by || undefined, + show_unrecorded: tableFilterState.show_unrecorded, + limit: 10000, + page: 1, + }; + + const response = await SaleReportApi.getHppPerKandangReport( + params.area_id, + params.location_id, + params.kandang_id, + params.weight_min, + params.weight_max, + params.period, + params.sort_by, + params.show_unrecorded + ); + + return isResponseSuccess(response) ? response.data : null; + }, [tableFilterState]); + + // ===== TABLE COLUMNS DEFINITION ===== + const allFeedSuppliers = useMemo(() => { + const suppliers = new Set(); + data.forEach((item: HppPerKandangRow) => { + item.feed_suppliers?.forEach((s: { alias?: string; name: string }) => { + suppliers.add(s.alias || s.name); + }); + }); + return Array.from(suppliers).join(' | '); + }, [data]); + + const allDocSuppliers = useMemo(() => { + const suppliers = new Set(); + data.forEach((item: HppPerKandangRow) => { + item.doc_suppliers?.forEach((s: { alias?: string; name: string }) => { + suppliers.add(s.alias || s.name); + }); + }); + return Array.from(suppliers).join(' | '); + }, [data]); + + // ===== EXPORT HANDLERS ===== + const handleExportExcel = useCallback(async () => { + setIsExcelExportLoading(true); + try { + const allDataForExport = await hppPerKandangExport(); + + if ( + !allDataForExport || + !allDataForExport?.rows || + allDataForExport.rows.length === 0 + ) { + toast.error('Tidak ada data untuk diekspor.'); + return; + } + + const allExportData = + allDataForExport.rows as HppPerKandangReport['rows']; + + const summaryTotal = allDataForExport.summary.total; + + const excelData: { [key: string]: string | number }[] = allExportData.map( + (item: HppPerKandangRow, index: number) => ({ + No: index + 1, + Kandang: item.kandang?.name || '', + 'Rentang Bobot': item.weight_range + ? `${formatNumber(item.weight_range.weight_min)} - ${formatNumber(item.weight_range.weight_max)}` + : '', + 'Rata-Rata Bobot (KG)': item.avg_weight_kg || 0, + 'Sisa Ayam (Ekor)': item.remaining_chicken_birds || 0, + 'Sisa Ayam (KG)': item.remaining_chicken_weight_kg || 0, + 'Produksi Telur (Butir)': item.egg_production_pieces || 0, + 'Produksi Telur (KG)': item.egg_production_kg || 0, + 'Feed (Supplier)': + item.feed_suppliers + ?.map((s: { alias?: string; name: string }) => s.alias || s.name) + .join(' | ') || '', + 'DOC (Supplier)': + item.doc_suppliers + ?.map((s: { alias?: string; name: string }) => s.alias || s.name) + .join(' | ') || '', + 'Rata-Rata Harga DOC (RP)': item.average_doc_price_rp || 0, + 'Nilai Nominal Telur (RP)': item.egg_value_rp || 0, + 'HPP Ayam (RP)': item.hpp_rp || 0, + 'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0, + 'Nilai Nominal Sisa Ayam (RP)': item.remaining_value_rp || 0, + }) + ); + + excelData.push({ + No: 'TOTAL', + Kandang: 'ALL', + 'Rentang Bobot': '-', + 'Rata-Rata Bobot (KG)': summaryTotal?.average_weight_kg || 0, + 'Sisa Ayam (Ekor)': summaryTotal?.total_remaining_chicken_birds || 0, + 'Sisa Ayam (KG)': summaryTotal?.total_remaining_chicken_weight_kg || 0, + 'Produksi Telur (Butir)': + summaryTotal?.total_egg_production_pieces || 0, + 'Produksi Telur (KG)': summaryTotal?.total_egg_production_kg || 0, + 'Feed (Supplier)': allFeedSuppliers, + 'DOC (Supplier)': allDocSuppliers, + 'Rata-Rata Harga DOC (RP)': + summaryTotal?.total_average_doc_price_rp || 0, + 'Nilai Nominal Telur (RP)': summaryTotal?.total_egg_value_rp || 0, + 'HPP Ayam (RP)': summaryTotal?.total_hpp_rp || 0, + 'HPP Telur (RP/KG)': summaryTotal?.average_egg_hpp_rp_per_kg || 0, + 'Nilai Nominal Sisa Ayam (RP)': + summaryTotal?.total_remaining_value_rp || 0, + }); + + const worksheet = XLSX.utils.json_to_sheet(excelData); + + const colWidths = [ + { wch: 5 }, // No + { wch: 30 }, // Kandang + { wch: 15 }, // Rentang Bobot + { wch: 18 }, // Rata-Rata Bobot (KG) + { wch: 15 }, // Sisa Ayam (Ekor) + { wch: 15 }, // Sisa Ayam (KG) + { wch: 18 }, // Produksi Telur (Butir) + { wch: 18 }, // Produksi Telur (KG) + { wch: 20 }, // Feed (Supplier) + { wch: 20 }, // DOC (Supplier) + { wch: 20 }, // Rata-Rata Harga DOC (RP) + { wch: 20 }, // Nilai Nominal Telur (RP) + { wch: 15 }, // HPP Ayam (RP) + { wch: 18 }, // HPP Telur (RP/KG) + { wch: 25 }, // Nilai Nominal Sisa Ayam (RP) + ]; + worksheet['!cols'] = colWidths; + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, 'HPP Per Kandang'); + + const filename = `laporan-hpp-harian-kandang-periode-${tableFilterState.period}.xlsx`; + + XLSX.writeFile(workbook, filename); + toast.success('Excel berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat Excel. Silakan coba lagi.'); + } finally { + setIsExcelExportLoading(false); + } + }, [ + hppPerKandangExport, + tableFilterState, + areaOptions, + locationOptions, + kandangOptions, + ]); + + const handleExportPDF = useCallback(async () => { + setIsPdfExportLoading(true); + try { + const allDataForExport = await hppPerKandangExport(); + + if ( + !allDataForExport || + !allDataForExport?.rows || + allDataForExport.rows.length === 0 + ) { + toast.error('Tidak ada data untuk diekspor.'); + return; + } + + const areaName = + tableFilterState.area_id.length > 0 + ? tableFilterState.area_id + .map( + (id) => + areaOptions.find((opt) => opt.value === Number(id))?.label + ) + .filter(Boolean) + .join(', ') || 'Semua Area' + : 'Semua Area'; + + const locationName = + tableFilterState.location_id.length > 0 + ? tableFilterState.location_id + .map( + (id) => + locationOptions.find((opt) => opt.value === Number(id))?.label + ) + .filter(Boolean) + .join(', ') || 'Semua Lokasi' + : 'Semua Lokasi'; + + const kandangName = + tableFilterState.kandang_id.length > 0 + ? tableFilterState.kandang_id + .map( + (id) => + kandangOptions.find((opt) => opt.value === Number(id))?.label + ) + .filter(Boolean) + .join(', ') || 'Semua Kandang' + : 'Semua Kandang'; + + await generateHppPerKandangPDF(allDataForExport, { + area_name: areaName, + location_name: locationName, + kandang_name: kandangName, + period: tableFilterState.period, + weight_min: tableFilterState.weight_min, + weight_max: tableFilterState.weight_max, + show_unrecorded: tableFilterState.show_unrecorded.toString(), + sort_by: tableFilterState.sort_by, + }); + + toast.success('PDF berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat PDF. Silakan coba lagi.'); + } finally { + setIsPdfExportLoading(false); + } + }, [ + hppPerKandangExport, + tableFilterState, + areaOptions, + locationOptions, + kandangOptions, + ]); + + const getTableColumns = (): ColumnDef[] => { + const tableColumns: ColumnDef[] = [ + { + id: 'no', + header: 'No', + cell: (props) => props.row.index + 1, + footer: () =>
    TOTAL
    , + }, + { + id: 'kandang_name', + header: 'Kandang', + accessorKey: 'kandang.name', + cell: (props) => { + const kandang = props.row.original.kandang; + return kandang?.name || '-'; + }, + footer: () =>
    ALL
    , + }, + { + id: 'weight_range', + header: 'Rentang Bobot', + accessorKey: 'weight_range', + cell: (props) => { + const weightRange = props.row.original.weight_range; + return weightRange + ? `${formatNumber(weightRange.weight_min)} - ${formatNumber(weightRange.weight_max)}` + : '-'; + }, + footer: () =>
    -
    , + }, + { + id: 'avg_weight_kg', + header: 'Rata-Rata Bobot (KG)', + accessorKey: 'avg_weight_kg', + cell: (props) => { + const value = props.row.original.avg_weight_kg; + return
    {formatNumber(value)}
    ; + }, + footer: () => ( +
    + {formatNumber(summaryTotal?.average_weight_kg || 0)} +
    + ), + }, + { + id: 'remaining_chicken_birds', + header: 'Sisa Ayam (Ekor)', + accessorKey: 'remaining_chicken_birds', + cell: (props) => { + const value = props.row.original.remaining_chicken_birds; + return
    {formatNumber(value)}
    ; + }, + footer: () => ( +
    + {formatNumber(summaryTotal?.total_remaining_chicken_birds || 0)} +
    + ), + }, + { + id: 'remaining_chicken_weight_kg', + header: 'Sisa Ayam (KG)', + accessorKey: 'remaining_chicken_weight_kg', + cell: (props) => { + const value = props.row.original.remaining_chicken_weight_kg; + return
    {formatNumber(value)}
    ; + }, + footer: () => ( +
    + {formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)} +
    + ), + }, + { + id: 'egg_production_pieces', + header: 'Produksi Telur (Butir)', + accessorKey: 'egg_production_pieces', + cell: (props) => { + const value = props.row.original.egg_production_pieces; + return
    {formatNumber(value)}
    ; + }, + footer: () => ( +
    + {formatNumber(summaryTotal?.total_egg_production_pieces || 0)} +
    + ), + }, + { + id: 'egg_production_kg', + header: 'Produksi Telur (KG)', + accessorKey: 'egg_production_kg', + cell: (props) => { + const value = props.row.original.egg_production_kg; + return
    {formatNumber(value)}
    ; + }, + footer: () => ( +
    + {formatNumber(summaryTotal?.total_remaining_chicken_weight_kg || 0)} +
    + ), + }, + { + id: 'feed_suppliers', + header: 'Feed (Supplier)', + accessorKey: 'feed_suppliers', + cell: (props) => { + const suppliers = props.row.original.feed_suppliers; + return ( + suppliers + ?.map((s: { alias?: string; name: string }) => s.alias || s.name) + .join(' | ') || '-' + ); + }, + footer: () => ( +
    + {allFeedSuppliers || '-'} +
    + ), + }, + { + id: 'doc_suppliers', + header: 'DOC (Supplier)', + accessorKey: 'doc_suppliers', + cell: (props) => { + const suppliers = props.row.original.doc_suppliers; + return ( + suppliers + ?.map((s: { alias?: string; name: string }) => s.alias || s.name) + .join(' | ') || '-' + ); + }, + footer: () => ( +
    + {allDocSuppliers || '-'} +
    + ), + }, + { + id: 'average_doc_price_rp', + header: 'Rata-Rata Harga DOC (RP)', + accessorKey: 'average_doc_price_rp', + cell: (props) => { + const value = props.row.original.average_doc_price_rp; + return
    {formatCurrency(value)}
    ; + }, + footer: () => ( +
    + {formatCurrency(summaryTotal?.total_average_doc_price_rp || 0)} +
    + ), + }, + { + id: 'egg_value_rp', + header: 'Nilai Nominal Telur (RP)', + accessorKey: 'egg_value_rp', + cell: (props) => { + const value = props.row.original.egg_value_rp; + return
    {formatCurrency(value)}
    ; + }, + footer: () => ( +
    + {formatCurrency(summaryTotal?.total_egg_value_rp || 0)} +
    + ), + }, + { + id: 'hpp_rp', + header: 'HPP Ayam (RP)', + accessorKey: 'hpp_rp', + cell: (props) => { + const value = props.row.original.hpp_rp; + return
    {formatCurrency(value)}
    ; + }, + footer: () => ( +
    + {formatCurrency(summaryTotal?.total_hpp_rp || 0)} +
    + ), + }, + { + id: 'egg_hpp_rp_per_kg', + header: 'HPP Telur (RP/KG)', + accessorKey: 'egg_hpp_rp_per_kg', + cell: (props) => { + const value = props.row.original.egg_hpp_rp_per_kg; + return
    {formatCurrency(value)}
    ; + }, + footer: () => ( +
    + {formatCurrency(summaryTotal?.average_egg_hpp_rp_per_kg || 0)} +
    + ), + }, + { + id: 'remaining_value_rp', + header: 'Nilai Nominal Sisa Ayam (RP)', + accessorKey: 'remaining_value_rp', + cell: (props) => { + const value = props.row.original.remaining_value_rp; + return
    {formatCurrency(value)}
    ; + }, + footer: () => ( +
    + {formatCurrency(summaryTotal?.total_remaining_value_rp || 0)} +
    + ), + }, + ]; + return tableColumns; + }; + + // ===== CUSTOM ROW RENDERER ===== + const renderCustomRow = useCallback( + (row: Row) => { + if (row.index === data.length - 1) { + const defaultRow = ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + + const customRows = [ + + + Rekapitulasi per rentang bobot + + , + ]; + + if (perWeightRangeSummary.length > 0) { + perWeightRangeSummary.forEach( + (item: HppPerKandangPerWeightRange, index = 0) => { + customRows.push( + + {index + 1} + ALL + {item.label} + + {formatNumber(item.avg_weight_kg)} + + + {formatNumber(item.remaining_chicken_birds)} + + + {formatNumber(item.remaining_chicken_weight_kg)} + + + {formatNumber(item.egg_production_pieces)} + + + {formatNumber(item.egg_production_kg)} + + + {item.feed_suppliers + ?.map((s) => s.alias || s.name) + .join(' | ') || '-'} + + + {item.doc_suppliers + ?.map((s) => s.alias || s.name) + .join(' | ') || '-'} + + + {formatCurrency(item.average_doc_price_rp)} + + + {formatCurrency(item.egg_value_rp)} + + {formatCurrency(item.hpp_rp)} + + {formatCurrency(item.egg_hpp_rp_per_kg)} + + + {formatCurrency(item.remaining_value_rp)} + + + ); + } + ); + } + + return [defaultRow, ...customRows]; + } + + return null; + }, + [data, perWeightRangeSummary] + ); + + return ( +
    + HPP Harian Kandang (${period})` + : 'Laporan > HPP Harian Kandang' + } + className={{ wrapper: 'w-full', body: 'p-1!' }} + > +
    + + (tableFilterState.area_id || []) + .map(String) + .includes(String(opt.value)) + )} + onChange={areaChangeHandler} + isLoading={isLoadingAreas} + isClearable + /> + + (tableFilterState.location_id || []) + .map(String) + .includes(String(opt.value)) + )} + onChange={locationChangeHandler} + isLoading={isLoadingLocations} + isClearable + /> + + (tableFilterState.kandang_id || []) + .map(String) + .includes(String(opt.value)) + )} + onChange={kandangChangeHandler} + isLoading={isLoadingKandangs} + isClearable + /> +
    + +
    +
    + + +
    + + opt.value === 'true') || + null + : showUnrecordedOptions.find((opt) => opt.value === 'false') || + null + } + onChange={showUnrecordedChangeHandler} + /> +
    + +
    + + + + Export + + + } + align='end' + > + + + + + +
    + +
    + + {!isSubmitted ? ( +
    + Silakan pilih filter dan klik tombol Cari untuk menampilkan data. +
    + ) : isLoading ? ( +
    + +
    + ) : data.length === 0 ? ( +
    + Tidak ada data yang dapat ditampilkan... +
    + ) : ( + 0} + renderCustomRow={renderCustomRow} + className={{ + containerClassName: 'w-full mt-6', + tableWrapperClassName: 'overflow-x-auto mt-4', + tableClassName: 'w-full table-auto text-sm', + headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', + headerColumnClassName: + 'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200', + 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 HppPerKandangTab; diff --git a/src/config/approval-line.ts b/src/config/approval-line.ts index 5333c016..fad098eb 100644 --- a/src/config/approval-line.ts +++ b/src/config/approval-line.ts @@ -16,10 +16,10 @@ export const PROJECT_FLOCK_APPROVAL_LINE: ApprovalLine = [ ] as const; export const PROJECT_FLOCK_KANDANGS_APPROVAL_LINE: ApprovalLine = [ - { - step_number: 1, - step_name: 'Pengajuan', - }, + // { + // step_number: 1, + // step_name: 'Pengajuan', + // }, { step_number: 2, step_name: 'Disetujui', diff --git a/src/config/constant.ts b/src/config/constant.ts index 96fc8401..ebb890a2 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -45,13 +45,32 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [ link: '/closing', icon: 'heroicons-outline:presentation-chart-bar', }, + { + text: 'Laporan', + link: '/report', + icon: 'mdi:chart-box-outline', + submenu: [ + { + text: 'Logistik & Persediaan', + link: '/report/logistic-stock', + }, + { + text: 'Biaya Operasional', + link: '/report/expense', + }, + { + text: 'Penjualan', + link: '/report/marketing', + }, + ], + }, { text: 'Persediaan', link: '/inventory', icon: 'heroicons-outline:folder', submenu: [ { - text: 'Produk', + text: 'Stok Produk', link: '/inventory/product', }, { @@ -251,3 +270,29 @@ export const ACCEPTED_FILE_TYPE = { 'image/*': [], }, }; + +export const FILTER_TYPE_OPTIONS = [ + { + label: 'Tanggal Realisasi', + value: 'REALIZATION_DATE', + }, + { + label: 'Tanggal DO', + value: 'DO_DATE', + }, +]; + +export const MARKETING_TYPE_OPTIONS = [ + { + label: 'Ayam', + value: 'ayam', + }, + { + label: 'Telur', + value: 'telur', + }, + { + label: 'Trading', + value: 'trading', + }, +]; diff --git a/src/dummy/closing.dummy.ts b/src/dummy/closing.dummy.ts index 3a20cdaf..3b9a9a7b 100644 --- a/src/dummy/closing.dummy.ts +++ b/src/dummy/closing.dummy.ts @@ -83,6 +83,7 @@ import { ClosingIncomingSapronak, ClosingOutgoingSapronak, ClosingOverhead, + ClosingProductionData, ClosingSapronakCalculation, } from '@/types/api/closing'; import { CreatedUser, BaseApiResponse } from '@/types/api/api-general'; @@ -1134,3 +1135,41 @@ export const dummyGetOverhead = async ( data: dummyOverhead, }; }; + +export const dummyClosingProductionData: ClosingProductionData = { + purchase: { + initial_population: 12000, + claim_culling: 150, + final_population: 11850, + feed_in: 24000, + feed_used: 22500, + feed_used_per_head: 1.9, + }, + + sales: { + chicken: { + sales_population: 10500, + sales_weight: 21000, + average_weight: 2.0, + chicken_average_selling_price: 28500, + }, + egg: { + egg_pieces: 185000, + egg_mass_kg: 9250, + average_egg_weight_kg: 0.05, + egg_average_selling_price: 1800, + }, + }, + + performance: { + depletion: 150, + age_day: 35, + mortality_std: 3.5, + mortality_act: 4.2, + deff_mortality: 0.7, + fcr_std: 1.6, + fcr_act: 1.72, + deff_fcr: 0.12, + awg: 60, + }, +}; diff --git a/src/dummy/marketing.dummy.ts b/src/dummy/marketing.dummy.ts deleted file mode 100644 index 35e65e8c..00000000 --- a/src/dummy/marketing.dummy.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { format } from 'date-fns'; -import { Area } from '@/types/api/master-data/area'; -import { Location } from '@/types/api/master-data/location'; -import { Kandang } from '@/types/api/master-data/kandang'; -import { Warehouse } from '@/types/api/master-data/warehouse'; -import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; -import { - BaseMarketing, - Marketing, - BaseSalesOrder, - BaseDeliveryOrder, - BaseDelivery, -} from '@/types/api/marketing/marketing'; -import { - CreatedUser, - BaseApproval, - BaseMetadata, -} from '@/types/api/api-general'; -import { Product } from '@/types/api/master-data/product'; -import { Customer } from '@/types/api/master-data/customer'; -import { Uom } from '@/types/api/master-data/uom'; -import { ProductCategory } from '@/types/api/master-data/product-category'; -import { Supplier } from '@/types/api/master-data/supplier'; - -// Waktu saat ini untuk created_at/updated_at -const now = format(new Date(), 'yyyy-MM-dd HH:mm:ss'); -const today = format(new Date(), 'yyyy-MM-dd'); -const tomorrow = format( - new Date().setDate(new Date().getDate() + 1), - 'yyyy-MM-dd' -); - -// ====================== -// 👤 Created User & Helper Data -// ====================== -export const createdUser: CreatedUser = { - id: 1, - id_user: 1, - email: 'admin@example.com', - name: 'Admin Utama', -}; - -const dummyProductBase: Product = { - id: 101, - name: 'Pakan Ayam Premium', - brand: 'Brand Hebat', - sku: 'PAK-001', - product_price: 15000, - selling_price: 18000, - tax: 0.1, - expiry_period: 365, - uom: { id: 1, name: 'Sak' } as Uom, - product_category: { id: 1, name: 'Pakan' } as ProductCategory, - suppliers: [{ id: 1, name: 'Supplier A' } as Supplier], - flags: ['PAKAN'], - created_user: createdUser, - created_at: now, - updated_at: now, -}; - -// ====================== -// 📍 Area Dummy -// ====================== -export const dummyAreas: Area[] = [ - { - id: 1, - name: 'Bandung Barat', - created_user: createdUser, - created_at: now, - updated_at: now, - }, - { - id: 2, - name: 'Cimahi Utara', - created_user: createdUser, - created_at: now, - updated_at: now, - }, -]; - -// ====================== -// 🏢 Location Dummy -// ====================== -export const dummyLocations: Location[] = [ - { - id: 1, - name: 'Gudang A', - address: 'Jl. Sukajadi No. 12', - area: dummyAreas[0], - created_user: createdUser, - created_at: now, - updated_at: now, - }, - { - id: 2, - name: 'Gudang B', - address: 'Jl. Setiabudi No. 45', - area: dummyAreas[1], - created_user: createdUser, - created_at: now, - updated_at: now, - }, -]; - -// ====================== -// 🐔 Kandang Dummy -// ====================== -export const dummyKandangs: Kandang[] = [ - { - id: 1, - name: 'Kandang Ayam Layer 1', - status: 'AKTIF', - capacity: 500, - location: dummyLocations[0], - pic: createdUser, - created_user: createdUser, - created_at: now, - updated_at: now, - }, - { - id: 2, - name: 'Kandang Ayam Broiler 2', - status: 'NONAKTIF', - capacity: 300, - location: dummyLocations[1], - pic: createdUser, - created_user: createdUser, - created_at: now, - updated_at: now, - }, -]; - -// ====================== -// 🏭 Warehouse Dummy -// ====================== -export const dummyWarehouses: Warehouse[] = [ - { - id: 1, - type: 'AREA', - name: 'Gudang Wilayah Bandung Barat', - area: dummyAreas[0], - created_user: createdUser, - created_at: now, - updated_at: now, - } as Warehouse, - { - id: 2, - type: 'LOKASI', - name: 'Gudang Produksi Sukajadi', - area: dummyAreas[0], - location: { ...dummyLocations[0], area: dummyAreas[0] }, - created_user: createdUser, - created_at: now, - updated_at: now, - } as Warehouse, - { - id: 3, - type: 'KANDANG', - name: 'Gudang Kandang Layer 1', - area: dummyAreas[0], - location: { ...dummyLocations[0], area: dummyAreas[0] }, - kandang: { - ...dummyKandangs[0], - location: dummyLocations[0], - pic: createdUser, - }, - created_user: createdUser, - created_at: now, - updated_at: now, - } as Warehouse, -]; - -// ====================== -// 📦 Product Warehouse Dummy -// ====================== -export const dummyProductWarehouses: ProductWarehouse[] = [ - { - id: 1, - product_id: 101, - warehouse_id: 1, - quantity: 1000, - product: dummyProductBase, - warehouse: dummyWarehouses[0], - created_user: createdUser, - created_at: now, - updated_at: now, - }, - { - id: 2, - product_id: 102, - warehouse_id: 2, - quantity: 500, - product: { - ...dummyProductBase, - id: 102, - name: 'Vitamin Ayam Super', - sku: 'VIT-002', - flags: ['VITAMIN'], - selling_price: 25000, - }, - warehouse: dummyWarehouses[1], - created_user: createdUser, - created_at: now, - updated_at: now, - }, -]; - -// ====================== -// 💼 Marketing Dummy -// ====================== - -// Helper untuk Sales Order (SO) Item -const soItem1: BaseSalesOrder = { - vehicle_number: 'B 1234 ABC', - id: 101, - marketing_id: 1, - product_warehouse_id: 1, - qty: 100, - unit_price: 18000, // Harga jual - avg_weight: 1.0, - total_weight: 100 * 1.0, - total_price: 100 * 18000, - product_warehouse: dummyProductWarehouses[0] as ProductWarehouse, -}; -const soItem2: BaseSalesOrder = { - vehicle_number: 'D 5678 EFG', - id: 102, - marketing_id: 2, - product_warehouse_id: 2, - qty: 50, - unit_price: 25000, - avg_weight: 0.5, - total_weight: 50 * 0.5, - total_price: 50 * 25000, - product_warehouse: dummyProductWarehouses[1] as ProductWarehouse, -}; - -// Helper untuk Delivery Item (DO) Detail -const doDelivery1: BaseDelivery[] = [ - { - product_warehouse: dummyProductWarehouses[0] as ProductWarehouse, - qty: soItem1.qty, - unit_price: soItem1.unit_price, - total_weight: soItem1.total_weight, - avg_weight: soItem1.avg_weight, - total_price: soItem1.total_price, - vehicle_number: 'B 1234 ABC', - }, -]; - -const doDelivery2: BaseDelivery[] = [ - { - product_warehouse: dummyProductWarehouses[1] as ProductWarehouse, - qty: soItem2.qty, - unit_price: soItem2.unit_price, - total_weight: soItem2.total_weight, - avg_weight: soItem2.avg_weight, - total_price: soItem2.total_price, - vehicle_number: 'D 5678 EFG', - }, -]; - -// Helper untuk Delivery Order (DO) Header -const deliveryOrder1: BaseDeliveryOrder[] = [ - { - id: 1, - marketing_id: 3, - do_number: 'DO-003-2025', - delivery_date: tomorrow, - warehouse: dummyWarehouses[0], - deliveries: doDelivery1, - }, -]; - -export const dummyMarketings: Marketing[] = [ - // 1. Pengajuan Order (Langkah Pertama/Awal) - { - id: 1, - status: 'DRAFT', - // name: 'SO-001-2025', // `name` is not part of BaseMarketing - so_number: 'SO-001-2025', - so_date: today, - customer: { - id: 1, - name: 'PT Maju Jaya', - pic_id: 1, - pic: createdUser, - type: 'Distributor', - address: 'Jl. Merdeka No. 1', - phone: '081212121212', - email: 'contact@majujaya.com', - account_number: '1234567890', - created_user: createdUser, - created_at: now, - updated_at: now, - } as Customer, - sales_person: createdUser, - notes: 'Pengajuan Order Awal, menunggu persetujuan harga.', - latest_approval: { - step_number: 1, - step_name: 'Pengajuan Order', - action: 'CREATED', - action_by: createdUser, - action_at: now, - } as BaseApproval, - sales_order: [soItem1], - delivery_order: [], - created_user: createdUser, - created_at: now, - updated_at: now, - } as Marketing, - - // 2. Sales Order (Disetujui dan Siap DO) - { - id: 2, - status: 'APPROVED', - // name: 'SO-002-2025', // `name` is not part of BaseMarketing - so_number: 'SO-002-2025', - so_date: today, - customer: { - id: 2, - name: 'CV Sumber Sehat', - pic_id: 2, - pic: createdUser, - type: 'Retail', - address: 'Jl. Cihampelas No. 5', - phone: '082222222222', - email: 'info@sumbersehat.com', - account_number: '9876543210', - created_user: createdUser, - created_at: now, - updated_at: now, - } as Customer, - sales_person: createdUser, - notes: 'Sales Order telah disetujui oleh Supervisor.', - latest_approval: { - id: 2, - step_number: 2, - step_name: 'Sales Order', - action: 'APPROVED', - action_by: createdUser, - action_at: now, - } as BaseApproval, - sales_order: [soItem2], - delivery_order: [], // Belum ada pengiriman (DO) yang dibuat - created_user: createdUser, - created_at: now, - updated_at: now, - } as Marketing, - - // 3. Delivery Order (Proses Pengiriman telah dibuat) - { - id: 3, - status: 'DELIVERED', // Asumsi status DELIVERED berarti DO sudah selesai/terbuat - // name: 'SO-003-2025', // `name` is not part of BaseMarketing - so_number: 'SO-003-2025', - so_date: today, - customer: { - id: 3, - name: 'UD Ternak Sejahtera', - pic_id: 3, - pic: createdUser, - type: 'Reseller', - address: 'Jl. Pasteur No. 88', - phone: '083333333333', - email: 'halo@ternaksejahtera.com', - account_number: '1122334455', - created_user: createdUser, - created_at: now, - updated_at: now, - } as Customer, - sales_person: createdUser, - notes: 'Pengiriman barang telah berhasil dilakukan.', - latest_approval: { - id: 3, - step_number: 3, - step_name: 'Delivery Order', - action: 'COMPLETED', - action_by: createdUser, - action_at: now, - } as BaseApproval, - sales_order: [soItem1, soItem2], - delivery_order: deliveryOrder1, // DO sudah terbuat - created_user: createdUser, - created_at: now, - updated_at: now, - } as Marketing, -]; diff --git a/src/dummy/report/marketing-report.dummy.ts b/src/dummy/report/marketing-report.dummy.ts new file mode 100644 index 00000000..ea5af398 --- /dev/null +++ b/src/dummy/report/marketing-report.dummy.ts @@ -0,0 +1,139 @@ +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; + +// TODO: delete this later +export const DAILY_MARKETING_DUMMY_DATA: BaseApiResponse = + { + code: 200, + status: 'success', + message: 'Get daily marketing report successfully', + meta: { + page: 1, + limit: 10, + total_pages: 1, + total_results: 2, + }, + data: { + rows: [ + { + // metadata + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-12-01T08:00:00Z', + updated_at: '2025-12-01T08:00:00Z', + + // row data + no: 1, + so_date: '2025-12-01', + do_date: '2025-12-08', + aging_days: 7, + + warehouse: { + id: 1, + name: 'Warehouse Kandang A', + type: 'KANDANG', + area: { + id: 1, + name: 'Area Barat', + }, + location: { + id: 1, + name: 'Farm Bandung', + address: 'Jl. Raya Farm No. 1', + area: null, + }, + kandang: { + id: 1, + name: 'Kandang A1', + status: 'ACTIVE', + capacity: 5000, + location: null, + pic: null, + }, + }, + + customer: { + id: 1, + name: 'PT Maju Jaya', + pic_id: 10, + pic: { + id: 10, + id_user: 210, + email: 'pic@majujaya.com', + name: 'Budi Santoso', + }, + type: 'BROILER', + address: 'Jl. Industri No. 10', + phone: '08123456789', + email: 'contact@majujaya.com', + account_number: '1234567890', + }, + + sales: 'Andi Wijaya', + + product: { + id: 1, + name: 'Live Chicken', + brand: 'LTI Farm', + sku: 'LC-001', + product_price: 18_000, + selling_price: 20_000, + tax: 0, + expiry_period: 0, + uom: { + id: 1, + name: 'Kg', + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + product_category: { + id: 1, + code: 'BROILER', + name: 'Broiler Chicken', + created_user: { + id: 1, + id_user: 101, + email: 'admin@example.com', + name: 'Admin User', + }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }, + suppliers: [], + flags: ['LIVE'], + }, + + do_number: 'DO-2025-0001', + vehicle_number: 'B 1234 CD', + marketing_type: 'REGULAR', + + qty: 1000, + average_weight_kg: 1.8, + total_weight_kg: 1800, + + sales_price_per_kg: 20_000, + hpp_price_per_kg: 18_000, + + sales_amount: 36_000_000, + hpp_amount: 32_400_000, + }, + ], + + summary: { + total_qty: 1000, + total_weight_kg: 1800, + total_sales_amount: 36_000_000, + total_hpp_amount: 32_400_000, + }, + }, + }; diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index e0b0bf89..9513760c 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -3,15 +3,20 @@ import axios from 'axios'; import { BaseApiService } from '@/services/api/base'; import { Closing, + ClosingFinance, ClosingGeneralInformation, ClosingIncomingSapronak, ClosingOutgoingSapronak, ClosingOverhead, ClosingSapronakCalculation, + ClosingProductionData, ClosingHppExpedition, } from '@/types/api/closing'; -import { httpClient, httpClientFetcher } from '@/services/http/client'; import { BaseApiResponse } from '@/types/api/api-general'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { ClosingSales } from '@/types/api/closing'; + +// TODO: delete these dummy data later import { dummyGetAllFetcher, dummyGetSingle, @@ -20,47 +25,15 @@ import { dummyGetGeneralInfo, dummyGetPerhitunganSapronak, dummyGetOverhead, + dummyClosingProductionData, } from '@/dummy/closing.dummy'; -import { ClosingSales } from '@/types/api/closing'; +import { sleep } from '@/lib/helper'; export class ClosingApiService extends BaseApiService { constructor(basePath: string) { super(basePath); } - async getAllFetcher(endpoint: string): Promise> { - // TODO: Remove this block when backend is ready - // return await dummyGetAllFetcher(); - - // Uncomment this when backend is ready - return await httpClientFetcher>(endpoint); - } - - async getSingle(id: number): Promise | undefined> { - // TODO: Remove this block when backend is ready - // try { - // return await dummyGetSingle(id); - // } catch (error) { - // if (axios.isAxiosError>(error)) { - // return error.response?.data; - // } - // return undefined; - // } - - // Uncomment this when backend is ready - try { - const getSinglePath = `${this.basePath}/${id}`; - const getSingleRes = - await httpClient>(getSinglePath); - return getSingleRes; - } catch (error) { - if (axios.isAxiosError>(error)) { - return error.response?.data; - } - return undefined; - } - } - async getPenjualan( id: number ): Promise | undefined> { @@ -81,10 +54,6 @@ export class ClosingApiService extends BaseApiService { async getAllIncomingSapronakFetcher( endpoint: string ): Promise> { - // TODO: Remove this block when backend is ready - // return await dummyGetAllIncomingSapronakFetcher(); - - // Uncomment this when backend is ready return await httpClientFetcher>( endpoint ); @@ -93,31 +62,14 @@ export class ClosingApiService extends BaseApiService { async getAllOutgoingSapronakFetcher( endpoint: string ): Promise> { - // TODO: Remove this block when backend is ready - return await dummyGetAllOutgoingSapronakFetcher(); - - // Uncomment this when backend is ready - // return await httpClientFetcher>( - // endpoint - // ); + return await httpClientFetcher>( + endpoint + ); } async getGeneralInfo( id: number ): Promise | undefined> { - // TODO: Remove this block when backend is ready - // try { - // return await dummyGetGeneralInfo(id); - // } catch (error) { - // if ( - // axios.isAxiosError>(error) - // ) { - // return error.response?.data; - // } - // return undefined; - // } - - // Uncomment this when backend is ready try { const getGeneralInfoPath = `${this.basePath}/${id}`; const getGeneralInfoRes = @@ -135,22 +87,27 @@ export class ClosingApiService extends BaseApiService { } } + async getProductionData( + id: number + ): Promise | undefined> { + try { + const getProductionDataPath = `${this.basePath}/${id}/production-data`; + const getProductionDataRes = await httpClient< + BaseApiResponse + >(getProductionDataPath); + + return getProductionDataRes; + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + async getPerhitunganSapronak( id: number ): Promise | undefined> { - // TODO: Remove this block when backend is ready - // try { - // return await dummyGetPerhitunganSapronak(id); - // } catch (error) { - // if ( - // axios.isAxiosError>(error) - // ) { - // return error.response?.data; - // } - // return undefined; - // } - - // Uncomment this when backend is ready try { const path = `${this.basePath}/${id}/perhitungan_sapronak`; return await httpClient>( @@ -172,17 +129,6 @@ export class ClosingApiService extends BaseApiService { async getOverhead( id: number ): Promise | undefined> { - // TODO: Remove this block when backend is ready - // try { - // return await dummyGetOverhead(id); - // } catch (error) { - // if (axios.isAxiosError>(error)) { - // return error.response?.data; - // } - // return undefined; - // } - - // Uncomment this when backend is ready try { const path = `${this.basePath}/${id}/overhead`; return await httpClient>(path, { @@ -196,6 +142,22 @@ export class ClosingApiService extends BaseApiService { } } + async getFinance( + id: number + ): Promise | undefined> { + try { + const path = `${this.basePath}/${id}/keuangan`; + return await httpClient>(path, { + method: 'GET', + }); + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + async getHppEkspedisi( id: number ): Promise | undefined> { diff --git a/src/services/api/marketing/marketing.ts b/src/services/api/marketing/marketing.ts index c2b5d018..59b9b4c8 100644 --- a/src/services/api/marketing/marketing.ts +++ b/src/services/api/marketing/marketing.ts @@ -1,5 +1,3 @@ -import { dummyMarketings } from '@/dummy/marketing.dummy'; -import { sleep } from '@/lib/helper'; import { BaseApiService } from '@/services/api/base'; import { httpClient } from '@/services/http/client'; import { BaseApiResponse } from '@/types/api/api-general'; @@ -31,41 +29,6 @@ export class SalesOrderService extends BaseApiService< super(basePath); } - // /** - // * Override: Mengambil semua data Marketing dari dummyMarketings - // */ - // async getAllFetcher(endpoint: string): Promise> { - // // Simulasi delay jaringan - // await sleep(500); - - // // Filter data marketing yang valid (jika menggunakan BaseMarketing[]) - // const data = dummyMarketings as Marketing[]; - - // return createDummyResponse(data); - // } - - // /** - // * Override: Mengambil satu data Marketing berdasarkan ID dari dummyMarketings - // */ - // async getSingle(id: number): Promise | undefined> { - // // Simulasi delay jaringan - // await sleep(300); - - // const foundData = dummyMarketings.find((m) => m.id == id); - - // if (foundData) { - // // Data ditemukan, kembalikan respons sukses - // return createDummyResponse(foundData as Marketing); - // } else { - // // Data tidak ditemukan, simulasi respons error - // return { - // code: 404, - // status: 'error', - // message: 'Marketing data not found (MOCK)', - // }; - // } - // } - /** * Approve single marketing data */ diff --git a/src/services/api/report.ts b/src/services/api/report.ts new file mode 100644 index 00000000..d5061d33 --- /dev/null +++ b/src/services/api/report.ts @@ -0,0 +1,23 @@ +import { BaseApiService } from '@/services/api/base'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { ReportExpense } from '@/types/api/report/report-expense'; +import axios from 'axios'; + +export class ReportExpenseApiService extends BaseApiService< + ReportExpense, + unknown, + unknown +> { + constructor(basePath: string) { + super(basePath); + } + + async getAllFetcher( + endpoint: string + ): Promise> { + return await httpClientFetcher>(endpoint); + } +} + +export const ReportExpenseApi = new ReportExpenseApiService('/reports/expense'); diff --git a/src/services/api/report/marketing-report.ts b/src/services/api/report/marketing-report.ts new file mode 100644 index 00000000..b1bcafae --- /dev/null +++ b/src/services/api/report/marketing-report.ts @@ -0,0 +1,75 @@ +import * as XLSX from 'xlsx'; +import toast from 'react-hot-toast'; + +import { BaseApiService } from '@/services/api/base'; +import { httpClient, httpClientFetcher } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { DailyMarketingReport } from '@/types/api/report/marketing'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { formatDate, sleep } from '@/lib/helper'; + +export class MarketingReportApiService extends BaseApiService< + DailyMarketingReport, + unknown, + unknown +> { + constructor(basePath: string = '/reports/marketings/daily-marketing') { + super(basePath); + } + + async getAllDailyMarketingFetcher( + endpoint: string + ): Promise> { + return await httpClientFetcher>( + endpoint + ); + } + + async exportDailyMarketingToExcel(initialQueryString: string) { + const params = new URLSearchParams(initialQueryString); + + params.set('limit', '9999999'); + + const queryString = `?${params.toString()}`; + + try { + const dailyMarketingsReport = await httpClientFetcher< + BaseApiResponse + >(`${this.basePath}${queryString}`); + + if (isResponseError(dailyMarketingsReport)) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + return; + } + + const rows = dailyMarketingsReport.data.rows; + + const formattedRows = []; + + for (let i = 0; i < rows.length; i++) { + formattedRows.push({ + ...rows[i], + created_user: rows[i].created_user.name, + created_at: formatDate(rows[i].created_at, 'YYYY-MM-DD'), + updated_at: formatDate(rows[i].updated_at, 'YYYY-MM-DD'), + warehouse: rows[i].warehouse.name, + customer: rows[i].customer.name, + product: rows[i].product.name, + }); + } + + const ws = XLSX.utils.json_to_sheet(formattedRows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'laporan-penjualan-harian'); + + // triggers download in browser + XLSX.writeFile(wb, 'laporan-penjualan-harian.xlsx'); + } catch (error) { + toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + } + } +} + +export const MarketingReportApi = new MarketingReportApiService( + '/reports/marketings/daily-marketing' +); diff --git a/src/services/api/report/marketing-sale.ts b/src/services/api/report/marketing-sale.ts new file mode 100644 index 00000000..bb9c1f49 --- /dev/null +++ b/src/services/api/report/marketing-sale.ts @@ -0,0 +1,53 @@ +import { BaseApiService } from '@/services/api/base'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang'; + +export class MarketingSaleReportService extends BaseApiService< + HppPerKandangReport, + unknown, + unknown +> { + constructor(basePath: string) { + super(basePath); + } + + async getHppPerKandangReport( + area_id?: string, + location_id?: string, + kandang_id?: string, + weight_min?: string, + weight_max?: string, + period?: string, + sort_by?: string, + show_unrecorded?: boolean, + page?: number, + limit?: number + ): Promise | undefined> { + return await this.customRequest>( + `hpp-per-kandang`, + { + method: 'GET', + params: { + area_id: area_id, + location_id: location_id, + kandang_id: kandang_id, + weight_min: weight_min, + weight_max: weight_max, + period: period, + sort_by: sort_by, + show_unrecorded: show_unrecorded, + page: page, + limit: limit, + }, + } + ); + } +} + +export const SaleReportApi = new MarketingSaleReportService( + 'reports/marketings' +); + +// export const SaleReportApi = new MarketingSaleReportService( +// 'http://localhost:4010/api/reports/marketings' +// ); diff --git a/src/types/api/closing.d.ts b/src/types/api/closing.d.ts index bc3cb0bc..c23354f8 100644 --- a/src/types/api/closing.d.ts +++ b/src/types/api/closing.d.ts @@ -23,6 +23,33 @@ export type BaseSales = { payment_status: string; }; +export type BaseClosingSales = { + project_type: string; + flock_id: number; + period: number; + sales: BaseSales[]; +}; +import { Kandang } from '@/types/api/master-data/kandang'; +import { Product } from '@type/api/master-data/product'; +import { Customer } from '@type/api/master-data/customer'; +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseSales = { + id: number; + realization_date: string; + age: number; + do_number: string; + product: Product; + customer: Customer; + qty: number; + weight: number; + avg_weight: number; + price: number; + total_price: number; + kandang: Kandang; + payment_status: string; +}; + export type BaseClosingSales = { project_type: string; flock_id: number; @@ -78,7 +105,44 @@ export type ClosingIncomingSapronak = { }; export type ClosingOutgoingSapronak = ClosingIncomingSapronak; -export type ClosingSales = BaseMetadata & BaseClosingSales; + +export type ClosingProductionData = { + purchase: { + initial_population: number; + claim_culling: number; + final_population: number; + feed_in: number; + feed_used: number; + feed_used_per_head: number; + }; + + sales: { + chicken: { + sales_population: number; + sales_weight: number; + average_weight: number; + chicken_average_selling_price: number; + }; + egg?: { + egg_pieces: number; + egg_mass_kg: number; + average_egg_weight_kg: number; + egg_average_selling_price: number; + }; + }; + + performance: { + depletion: number; + age_day: number; + mortality_std: number; + mortality_act: number; + deff_mortality: number; + fcr_std: number; + fcr_act: number; + deff_fcr: number; + awg: number; + }; +}; // ====== PERHITUNGAN SAPRONAK ====== @@ -143,6 +207,83 @@ export type OverheadTotal = { cost_per_bird: number; }; +export type ClosingSales = BaseMetadata & BaseClosingSales; + +// ====== FINANCE ====== +export interface ClosingFinance { + project_flock_id: number; + period: number; + project_type: string; + volume_base: ClosingFinanceVolumeBase; + hpp_purchases: ClosingFinanceHppPurchases; + profit_loss: ClosingFinanceProfitLoss; +} + +export interface ClosingFinanceProfitLoss { + title: string; + data: ProfitLossData; +} + +export interface ClosingFinanceHppPurchases { + title: string; + hpp: GroupHppPurchase[]; + summary_hpp: HppPurchasesSummary; +} + +export interface ClosingFinanceVolumeBase { + total_birds: number; + total_weight_kg: number; +} + +export interface ProfitLossData { + penjualan: ProfitLossDataAmount[]; + pembelian: ProfitLossDataAmount[]; + summary: ProfitLossDataSummary; +} + +export interface GroupHppPurchase { + group_name: string; + data: HppPurchaseData[]; +} + +export interface ProfitLossDataSummary { + gross_profit: DataSummarySubTotal; + sub_total: DataSummarySubTotal; + net_profit: DataSummarySubTotal; +} + +export interface ProfitLossDataAmount { + type: string; + rp_per_bird: number; + rp_per_kg: number; + amount: number; +} + +export interface HppPurchasesSummary { + label: string; + budgeting: HppPurchaseDataAmount; + realization: HppPurchaseDataAmount; +} + +export interface HppPurchaseData { + type: string; + budgeting: HppPurchaseDataAmount; + realization: HppPurchaseDataAmount; +} + +export interface HppPurchaseDataAmount { + rp_per_bird: number; + rp_per_kg: number; + amount: number; +} + +export interface DataSummarySubTotal { + label: string; + rp_per_bird: number; + rp_per_kg: number; + amount: number; +} + export type BaseExpeditionCost = { id: number; expedition_vendor_name: string; diff --git a/src/types/api/inventory/adjustment.d.ts b/src/types/api/inventory/adjustment.d.ts index d6c0e078..90ef8ff8 100644 --- a/src/types/api/inventory/adjustment.d.ts +++ b/src/types/api/inventory/adjustment.d.ts @@ -4,10 +4,8 @@ import { BaseMetadata } from '@/types/api/api-general'; export type BaseInventoryAdjustment = { id: number; - transaction_type: string; - quantity: number; - before_quantity: number; - after_quantity: number; + increase: number; + decrease: number; note: string; product_warehouse_id: number; product_warehouse: { diff --git a/src/types/api/master-data/kandang.d.ts b/src/types/api/master-data/kandang.d.ts index c9c14882..eafa0334 100644 --- a/src/types/api/master-data/kandang.d.ts +++ b/src/types/api/master-data/kandang.d.ts @@ -10,7 +10,6 @@ export type BaseKandang = { capacity: number; pic: BaseUser; project_flock_kandang_id?: number; - capacity: number; }; export type Kandang = BaseMetadata & BaseKandang; diff --git a/src/types/api/report/hpp-per-kandang.d.ts b/src/types/api/report/hpp-per-kandang.d.ts new file mode 100644 index 00000000..824a3837 --- /dev/null +++ b/src/types/api/report/hpp-per-kandang.d.ts @@ -0,0 +1,69 @@ +import { BaseMetadata } from '@types/api/base-metadata'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Kandang } from '@/types/api/master-data/kandang'; + +export type HppPerKandangRow = { + id: number; + kandang: Kandang; + weight_range: { + weight_min: number; + weight_max: number; + }; + remaining_chicken_birds: number; + remaining_chicken_weight_kg: number; + avg_weight_kg: number; + egg_production_pieces: number; + egg_production_kg: number; + egg_hpp_rp_per_kg: number; + egg_value_rp: number; + feed_suppliers: Supplier[]; + doc_suppliers: Supplier[]; + average_doc_price_rp: number; + hpp_rp: number; + remaining_value_rp: number; +}; + +export type HppPerKandangSummaryTotal = { + total_remaining_chicken_birds: number; + total_remaining_chicken_weight_kg: number; + average_weight_kg: number; + total_remaining_value_rp: number; + total_egg_production_pieces: number; + total_egg_production_kg: number; + average_egg_hpp_rp_per_kg: number; + total_egg_value_rp: number; + total_hpp_rp: number; + total_average_doc_price_rp: number; +}; + +export type HppPerKandangPerWeightRange = { + id: number; + weight_range: { + weight_min: number; + weight_max: number; + }; + label: string; + remaining_chicken_birds: number; + remaining_chicken_weight_kg: number; + avg_weight_kg: number; + egg_production_pieces: number; + egg_production_kg: number; + egg_hpp_rp_per_kg: number; + egg_value_rp: number; + feed_suppliers: Supplier[]; + doc_suppliers: Supplier[]; + average_doc_price_rp: number; + hpp_rp: number; + remaining_value_rp: number; +}; + +export type HppPerKandangSummary = { + per_weight_range: HppPerKandangPerWeightRange[]; + total: HppPerKandangSummaryTotal; +}; + +export type HppPerKandangReport = BaseMetadata & { + period: string; + rows: HppPerKandangRow[]; + summary: HppPerKandangSummary; +}; diff --git a/src/types/api/report/marketing.d.ts b/src/types/api/report/marketing.d.ts new file mode 100644 index 00000000..d1e81f77 --- /dev/null +++ b/src/types/api/report/marketing.d.ts @@ -0,0 +1,61 @@ +import { BaseMetadata } from '@/types/api/api-general'; +import { BaseCustomer, Customer } from '@/types/api/master-data/customer'; +import { + BaseWarehouseArea, + BaseWarehouseKandang, + BaseWarehouseLocation, + Warehouse, +} from '@/types/api/master-data/warehouse'; +import { Location } from '@/types/api/master-data/location'; +import { Area } from '@/types/api/master-data/area'; +import { BaseProduct } from '@/types/api/master-data/product'; + +export type BaseDailyMarketingRow = { + no: number; + so_date: string; // e.g. "01-Dec-2025" + do_date: string; // e.g. "08-Dec-2025" + aging_days: number; + + warehouse: BaseWarehouseArea | BaseWarehouseLocation | BaseWarehouseKandang; + customer: BaseCustomer; + sales: string; + product: BaseProduct; + + do_number: string; + vehicle_number: string; + marketing_type: string; + + qty: number; + average_weight_kg: number; + total_weight_kg: number; + + sales_price_per_kg: number; + hpp_price_per_kg: number; + + sales_amount: number; + hpp_amount: number; +}; + +export type DailyMarketingRow = BaseMetadata & BaseDailyMarketingRow; + +export interface SalesSummary { + total_qty: number; + total_weight_kg: number; + total_sales_amount: number; + total_hpp_amount: number; +} + +export type DailyMarketingReport = { + rows: DailyMarketingRow[]; + summary: SalesSummary; +}; + +export type MarketingReportFilters = { + area_id?: number; + location_id?: number; + warehouse_id?: number; + customer_id?: number; + start_date?: string; + end_date?: string; + date_type?: 'realized' | 'transaction'; +}; diff --git a/src/types/api/report/report-expense.d.ts b/src/types/api/report/report-expense.d.ts new file mode 100644 index 00000000..3918820d --- /dev/null +++ b/src/types/api/report/report-expense.d.ts @@ -0,0 +1,55 @@ +import { BaseApproval, CreatedUser } from '@/types/api/api-general'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Location } from '@/types/api/master-data/location'; +import { Nonstock } from '@/types/api/master-data/nonstock'; +import { Kandang } from '@/types/api/master-data/kandang'; + +export type Pengajuan = { + id: number; + expense_id: number; + project_flock_kandang_id: number; + kandang_id: number; + nonstock_id: number; + qty: number; + price: number; + notes: string; + nonstock: Nonstock; + created_at: string; +}; + +export type Realisasi = { + id: number; + expense_nonstock_id: number; + qty: number; + price: number; + notes: string; + nonstock: Nonstock; + created_at: string; +}; + +export type ReportExpense = { + id: number; + reference_number: string; + po_number: string; + category: string; + supplier: Supplier; + realization_date: string; + transaction_date: string; + pengajuan: Pengajuan; + realisasi: Realisasi; + kandang: Kandang; + created_at: string; + updated_at: string; + created_user: CreatedUser; + latest_approval: BaseApproval; +}; + +export type ReportExpenseSearchParams = { + locationId: string | null; + supplierId: string | null; + kandangId: string | null; + nonstockId: string | null; + realizationDate: string | null; + category: string | null; + search: string; +};