Merge branch 'development' into feat/FE/US-164/TASK-200-204-205-206-207-expense-realization

This commit is contained in:
ValdiANS
2025-11-25 10:55:52 +07:00
86 changed files with 11655 additions and 5334 deletions
-3
View File
@@ -40,8 +40,5 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# prettier
.prettierrc
# idea # idea
.idea .idea
+519 -19
View File
@@ -8,12 +8,12 @@
"name": "lti-web-client", "name": "lti-web-client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.1",
"@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"inputmask": "^5.0.9",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
@@ -33,7 +33,6 @@
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/inputmask": "^5.0.7",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -1274,6 +1273,180 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@react-pdf/fns": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.3.tgz",
"integrity": "sha512-N1qQDZr6phXYQOp033Hvm2nkUkx2LkszjGPbmRavs9VOYzi4sp31MaccMKptL24ii6UhBh/z9yPUhnuNe/qHwA==",
"license": "MIT",
"dependencies": {
"@react-pdf/pdfkit": "^4.0.4",
"@react-pdf/types": "^2.9.1",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.3.tgz",
"integrity": "sha512-lvP5ryzYM3wpbO9bvqLZYwEr5XBDX9jcaRICvtnoRqdJOo7PRrMnmB4MMScyb+Xw10mGeIubZAAomNAG5ONQZQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.1.tgz",
"integrity": "sha512-GVzdlWoZWldRDzlWj3SttRXmVDxg7YfraAohwy+o9gb9hrbDJaaAV6jV3pc630Evd3K46OAzk8EFu8EgPDuVuA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/image": "^3.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.1",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.4.tgz",
"integrity": "sha512-/nITLggsPlB66bVLnm0X7MNdKQxXelLGZG6zB5acF5cCgkFwmXHnLNyxYOUD4GMOMg1HOPShXDKWrwk2ZeHsvw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"license": "MIT",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.4.tgz",
"integrity": "sha512-oTQDiR/t4Z/Guxac88IavpU2UgN7eR0RMI9DRKvKnvPz2DUasGjXfChAdMqDNmJJxxV26mMy9xQOUV2UU5/okg==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.1.tgz",
"integrity": "sha512-v1WAaAhQShQZGcBxfjkEThGCHVH9CSuitrZ1bIOLvB5iBKM14abYK5D6djKhWCwF6FTzYeT2WRjRMVgze/ND2A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^6.0.0",
"@react-pdf/types": "^2.9.1",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.1.tgz",
"integrity": "sha512-dPKHiwGTaOsKqNWCHPYYrx8CDfAGsUnV4tvRsEu0VPGxuot1AOq/M+YgfN/Pb+MeXCTe2/lv6NvA8haUtj3tsA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.2",
"@react-pdf/font": "^4.0.3",
"@react-pdf/layout": "^4.4.1",
"@react-pdf/pdfkit": "^4.0.4",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.4",
"@react-pdf/render": "^4.3.1",
"@react-pdf/types": "^2.9.1",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.1.tgz",
"integrity": "sha512-Iyw0A3wRIeQLN4EkaKf8yF9MvdMxiZ8JjoyzLzDHSxnKYoOA4UGu84veCb8dT9N8MxY5x7a0BUv/avTe586Plg==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"@react-pdf/types": "^2.9.1",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.0.0.tgz",
"integrity": "sha512-fDt19KWaJRK/n2AaFoVm31hgGmpygmTV7LsHGJNGZkgzXcFyLsx+XUl63DTDPH3iqxj3xUX128t104GtOz8tTw==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.2",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.1.tgz",
"integrity": "sha512-5GoCgG0G5NMgpPuHbKG2xcVRQt7+E5pg3IyzVIIozKG3nLcnsXW4zy25vG1ZBQA0jmo39q34au/sOnL/0d1A4w==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.0.3",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.1.1"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1647,13 +1820,6 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/inputmask": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz",
"integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2269,6 +2435,12 @@
"win32" "win32"
] ]
}, },
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2603,6 +2775,35 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2627,6 +2828,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -2728,6 +2947,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2754,9 +2982,18 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2813,6 +3050,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3003,6 +3246,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3047,6 +3296,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -3680,11 +3935,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
@@ -3843,6 +4106,23 @@
} }
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4196,6 +4476,21 @@
"react-is": "^16.7.0" "react-is": "^16.7.0"
} }
}, },
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/husky": { "node_modules/husky": {
"version": "9.1.7", "version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -4212,6 +4507,12 @@
"url": "https://github.com/sponsors/typicode" "url": "https://github.com/sponsors/typicode"
} }
}, },
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
"license": "ISC"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4248,11 +4549,11 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inputmask": { "node_modules/inherits": {
"version": "5.0.9", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "MIT" "license": "ISC"
}, },
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
@@ -4630,6 +4931,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -4708,6 +5015,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -4725,9 +5041,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5110,6 +5426,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -5182,6 +5517,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -5392,6 +5733,15 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5582,6 +5932,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5612,6 +5968,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5705,6 +6067,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5764,6 +6132,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5970,6 +6347,15 @@
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6009,6 +6395,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -6064,6 +6456,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6309,6 +6721,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -6348,6 +6775,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6538,6 +6974,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/swr": { "node_modules/swr": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
@@ -6588,6 +7030,12 @@
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tiny-warning": { "node_modules/tiny-warning": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@@ -6836,6 +7284,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -6916,6 +7390,26 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"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",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7053,6 +7547,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/yup": { "node_modules/yup": {
"version": "1.7.1", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
+1 -2
View File
@@ -11,12 +11,12 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.1",
"@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formik": "^2.4.6", "formik": "^2.4.6",
"inputmask": "^5.0.9",
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
@@ -36,7 +36,6 @@
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/inputmask": "^5.0.7",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -0,0 +1,54 @@
'use client';
import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const EditMarketingDelivery = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('marketingId');
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
);
if (!soId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<MarketingForm
formType='add_deliver'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)}
</div>
);
};
export default EditMarketingDelivery;
@@ -1,9 +1,9 @@
import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
const AddSalesOrder = () => { const AddSalesOrder = () => {
return ( return (
<div className='size-full p-4'> <div className='size-full p-4'>
<SalesForm /> <MarketingForm formType='add' />
</div> </div>
); );
}; };
@@ -0,0 +1,62 @@
'use client';
import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const EditMarketingDelivery = () => {
const router = useRouter();
const searchParams = useSearchParams();
const soId = searchParams.get('marketingId');
const {
data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
);
if (!soId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && (!marketing || isResponseError(marketing))) {
router.replace('/404');
return;
}
if (
isResponseSuccess(marketing) &&
marketing.data.latest_approval.step_number != 3
) {
toast.error('Data Marketing perlu dilakukan approval terlebih dahulu!');
router.back();
}
return (
<div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && (
<MarketingForm
formType='edit_deliver'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)}
</div>
);
};
export default EditMarketingDelivery;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -1,20 +1,22 @@
'use client'; 'use client';
import SalesOrderDetail from '@/components/pages/marketing/sales-orders/detail/SalesOrderDetail'; import MarketingDetail from '@/components/pages/marketing/detail/MarketingDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing'; import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
const DetailSalesOrder = () => { const DetailMarketing = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const soId = searchParams.get('salesOrderId'); const soId = searchParams.get('marketingId');
const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => const {
MarketingApi.getSingle(id) data: marketing,
); isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(soId, (id: number) => MarketingApi.getSingle(id));
if (!soId) { if (!soId) {
router.back(); router.back();
@@ -35,10 +37,13 @@ const DetailSalesOrder = () => {
<div className='w-full p-4'> <div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />} {isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && ( {!isLoading && isResponseSuccess(marketing) && (
<SalesOrderDetail initialValues={marketing.data} /> <MarketingDetail
initialValues={marketing.data}
refresh={refreshMarketing}
/>
)} )}
</div> </div>
); );
}; };
export default DetailSalesOrder; export default DetailMarketing;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -1,6 +1,6 @@
'use client'; 'use client';
import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; import MarketingForm from '@/components/pages/marketing/form/MarketingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing'; import { MarketingApi } from '@/services/api/marketing/marketing';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
@@ -10,10 +10,14 @@ const EditSalesOrder = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const soId = searchParams.get('salesOrderId'); const soId = searchParams.get('marketingId');
const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => const {
MarketingApi.getSingle(id) data: marketing,
isLoading: isLoading,
mutate: refreshMarketing,
} = useSWR(`get-so-${soId}`, () =>
MarketingApi.getSingle(soId ? parseInt(soId) : 0)
); );
if (!soId) { if (!soId) {
@@ -34,7 +38,13 @@ const EditSalesOrder = () => {
<div className='w-full p-4'> <div className='w-full p-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />} {isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(marketing) && ( {!isLoading && isResponseSuccess(marketing) && (
<SalesForm formType='edit' initialValues={marketing.data} /> <MarketingForm
formType='edit'
initialValues={marketing.data}
afterSubmit={() => {
refreshMarketing();
}}
/>
)} )}
</div> </div>
); );
+10
View File
@@ -0,0 +1,10 @@
import MarketingTable from '@/components/pages/marketing/MarketingTable';
const Marketing = () => {
return (
<div className='w-full p-4'>
<MarketingTable />
</div>
);
};
export default Marketing;
-10
View File
@@ -1,10 +0,0 @@
import SalesOrderTable from '@/components/pages/marketing/sales-orders/SalesOrderTable';
const SalesOrder = () => {
return (
<div className='w-full p-4'>
<SalesOrderTable />
</div>
);
};
export default SalesOrder;
@@ -11,10 +11,6 @@ const AddChickin = () => {
return ( return (
<> <>
<section className='w-full p-4'> <section className='w-full p-4'>
<FormHeader
title='Daftar Kandang Project Flock'
backUrl='/production/project-flock'
/>
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} /> <ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
</section> </section>
</> </>
@@ -1,343 +0,0 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ChickinApi } from '@/services/api/production/chickin';
import { BaseApiResponse } from '@/types/api/api-general';
import {
Chickin,
ChickinApprovalPayload,
} from '@/types/api/production/chickin';
import { Icon } from '@iconify/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
/**
* TODO: Refactor code - pindahin detail ke reuseable component
* setelah implement approval and reject
*/
const DetailChickin = () => {
const router = useRouter();
const searchParams = useSearchParams();
const chickinId = searchParams.get('chickinId');
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const confirmModal = useModal();
const deleteModal = useModal();
const chickinModal = useModal();
const {
data: chickin,
isLoading,
mutate: refreshChickin,
} = useSWR(chickinId, (id: number) => ChickinApi.getSingle(id));
const [isApprovedDisabled, setIsApprovedDisabled] = useState(
// chickin.data?.approval.step_number == 1 ? false : true
true
);
const [isRejectedDisabled, setIsRejectedDisabled] =
useState(!isApprovedDisabled);
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
!isApprovedDisabled ? 'APPROVED' : 'REJECTED'
);
if (!chickinId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoading && (!chickin || isResponseError(chickin))) {
router.replace('/404');
return;
}
if (!isResponseSuccess(chickin)) {
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
const confirmationModalClickHandler = async ({
action = 'APPROVED',
}: {
action: 'APPROVED' | 'REJECTED';
}) => {
if (chickin?.data.id === undefined) return;
setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.customRequest<
BaseApiResponse<Chickin>,
ChickinApprovalPayload
>(`/approvals`, {
method: 'POST',
payload: {
action: action,
approvable_ids: [chickin.data.id],
},
});
if (isResponseSuccess(approveChickinRes)) {
if (refreshChickin) {
await refreshChickin();
}
toast.success(approveChickinRes.message as string);
}
if (isResponseError(approveChickinRes)) {
toast.error(approveChickinRes?.message as string);
}
confirmModal.closeModal();
setIsApproveLoading(false);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteProjectFlockRes = await ChickinApi.delete(
chickin.data?.id as number
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push('/production/chickin');
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
deleteModal.closeModal();
setIsDeleteLoading(false);
};
return (
<>
<div className='w-full p-4 flex flex-col justify-center gap-4'>
{isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && isResponseSuccess(chickin) && (
<>
{/* <div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
variant='outline'
color='success'
onClick={(() => {
if (chickin?.data.id) {
setApprovalAction('APPROVED');
confirmModal.openModal();
}
})}
disabled={!chickin?.data.id || isApprovedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='material-symbols:check' width={24} height={24} />
Approve
</Button>
<Button
variant='outline'
color='error'
onClick={() => {
if (chickin?.data.id) {
setApprovalAction('REJECTED');
confirmModal.openModal();
}
}}
disabled={!chickin?.data.id || isRejectedDisabled}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
</div> */}
<Card
title='Informasi Umum'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock</div>
<div className='text-sm'>
{
chickin?.data?.project_flock_kandang?.project_flock?.flock
?.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Area</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.area
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kategori</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.category}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Lokasi</div>
<div className='text-sm'>
{
chickin.data.project_flock_kandang?.project_flock.location
.name
}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Periode</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.project_flock.period}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Kandang</div>
<div className='text-sm'>
{chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
</div>
</Card>
<Card
title='Detail Chickin'
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<div className='grid grid-cols-2 gap-4 mt-4'>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Flock Kandang</div>
<div className='text-sm'>
{
chickin?.data?.project_flock_kandang?.project_flock?.flock
?.name
}{' '}
- {chickin.data.project_flock_kandang?.kandang.name}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Tanggal Chickin</div>
<div className='text-sm'>
{chickin.data.chick_in_date
? new Date(chickin.data.chick_in_date).toLocaleDateString(
'id-ID'
)
: '-'}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Jumlah (Ekor)</div>
<div className='text-sm'>
{chickin.data.quantity?.toLocaleString('id-ID')}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='font-semibold text-sm'>Catatan</div>
<div className='text-sm'>{chickin.data.note}</div>
</div>
</div>
</Card>
<div className='w-full flex flex-col sm:flex-row gap-2'>
<Button
color='error'
onClick={() => {
deleteModal.openModal();
}}
>
<Icon icon='mdi:times' width={24} height={24} />
Delete
</Button>
<Button
color='warning'
onClick={() => {
chickinModal.openModal();
}}
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
Edit
</Button>
</div>
</>
)}
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${chickin?.data?.project_flock_kandang?.project_flock.flock?.name} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<Modal ref={chickinModal.ref}>
<div className='flex flex-row justify-between items-center'>
<h1 className='text-xl font-semibold text-center mb-6'>
Chickin Kandang -{' '}
{chickin?.data?.project_flock_kandang &&
chickin?.data?.project_flock_kandang.kandang?.name}
</h1>
<Button
color='error'
variant='link'
onClick={chickinModal.closeModal}
>
<Icon
className='text-black'
icon='uil:times'
width={24}
height={24}
/>
</Button>
</div>
</Modal>
<ConfirmationModal
ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject'
} chickin berikut? (${
chickin?.data?.project_flock_kandang?.project_flock?.flock?.name
} - ${chickin?.data.project_flock_kandang?.kandang.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: () => {
confirmationModalClickHandler({
action: approvalAction,
});
},
}}
/>
</>
);
};
export default DetailChickin;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,49 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const AddGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recording_id');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId && recordingId !== 'new' ? [recordingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (
recordingId &&
recordingId !== 'new' &&
!isLoadingRecording &&
(!recording || !isResponseSuccess(recording))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{recordingId && recordingId !== 'new' && isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{(!recordingId ||
recordingId === 'new' ||
(!isLoadingRecording && recording && isResponseSuccess(recording))) && (
<GradingForm
type='add'
initialValues={
isResponseSuccess(recording) ? recording.data?.eggs?.[0] : undefined
}
/>
)}
</div>
);
};
export default AddGrading;
@@ -0,0 +1,53 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const EditGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const gradingId = searchParams.get('gradingId');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId ? [recordingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (!recordingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && recording && isResponseSuccess(recording) && (
<GradingForm
type='edit'
initialValues={recording.data.eggs?.find(
(egg) => egg.id === parseInt(gradingId || '0')
)}
/>
)}
</div>
);
};
export default EditGrading;
@@ -0,0 +1,52 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const DetailGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const gradingId = searchParams.get('gradingId');
const { data: grading, isLoading: isLoadingGrading } = useSWR(
gradingId ? [gradingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (!gradingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingGrading && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingGrading && grading && isResponseSuccess(grading) && (
<GradingForm
type='detail'
initialValues={grading.data.eggs?.find(
(egg) => egg.id === parseInt(gradingId)
)}
/>
)}
</div>
);
};
export default DetailGrading;
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+3 -2
View File
@@ -3,6 +3,7 @@
import { HTMLAttributes, ReactNode } from 'react'; import { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import Image from 'next/image';
export interface CardProps export interface CardProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> { extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
@@ -106,7 +107,7 @@ const Card = ({
return ( return (
<div className={getCardClasses()} {...props}> <div className={getCardClasses()} {...props}>
<figure> <figure>
<img <Image
src={image} src={image}
alt={imageAlt || title || 'Card image'} alt={imageAlt || title || 'Card image'}
className={getImageClasses()} className={getImageClasses()}
@@ -127,7 +128,7 @@ const Card = ({
<div className={getCardClasses()} {...props}> <div className={getCardClasses()} {...props}>
{image && ( {image && (
<figure> <figure>
<img <Image
src={image} src={image}
alt={imageAlt || title || 'Card image'} alt={imageAlt || title || 'Card image'}
className={getImageClasses()} className={getImageClasses()}
+53 -1
View File
@@ -9,6 +9,11 @@ interface FormActionsProps<T> {
editUrl?: string; editUrl?: string;
onDelete?: () => void; onDelete?: () => void;
disableSubmit?: boolean; disableSubmit?: boolean;
onApprove?: () => void;
onReject?: () => void;
isApproveLoading?: boolean;
isRejectLoading?: boolean;
showApproveReject?: boolean;
} }
export const FormActions = <T,>({ export const FormActions = <T,>({
@@ -17,11 +22,17 @@ export const FormActions = <T,>({
editUrl, editUrl,
onDelete, onDelete,
disableSubmit = false, disableSubmit = false,
onApprove,
onReject,
isApproveLoading = false,
isRejectLoading = false,
showApproveReject = false,
}: FormActionsProps<T>) => { }: FormActionsProps<T>) => {
return ( return (
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && onDelete && ( {type !== 'add' && (
<div className='flex flex-row justify-start gap-2'> <div className='flex flex-row justify-start gap-2'>
{onDelete && (
<Button <Button
type='button' type='button'
color='error' color='error'
@@ -36,6 +47,7 @@ export const FormActions = <T,>({
/> />
Delete Delete
</Button> </Button>
)}
{type !== 'edit' && editUrl && ( {type !== 'edit' && editUrl && (
<Button <Button
type='button' type='button'
@@ -52,6 +64,46 @@ export const FormActions = <T,>({
Edit Edit
</Button> </Button>
)} )}
{type === 'detail' &&
showApproveReject &&
(onApprove || onReject) && (
<>
{onApprove && (
<Button
type='button'
color='success'
onClick={onApprove}
className='px-4'
isLoading={isApproveLoading}
>
<Icon
icon='material-symbols:check-circle-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Approve
</Button>
)}
{onReject && (
<Button
type='button'
color='error'
onClick={onReject}
className='px-4'
isLoading={isRejectLoading}
>
<Icon
icon='material-symbols:cancel-outline'
width={24}
height={24}
className='justify-start text-sm'
/>
Reject
</Button>
)}
</>
)}
</div> </div>
)} )}
{type !== 'detail' && ( {type !== 'detail' && (
+4 -1
View File
@@ -78,7 +78,10 @@ const DateInput = ({
// --- Sync value props --- // --- Sync value props ---
useEffect(() => { useEffect(() => {
if (!value) return; if (!value) {
setDisplayValue('');
return;
}
if (isRange && typeof value === 'object') { if (isRange && typeof value === 'object') {
const from = value.from ? new Date(value.from) : undefined; const from = value.from ? new Date(value.from) : undefined;
const to = value.to ? new Date(value.to) : undefined; const to = value.to ? new Date(value.to) : undefined;
+2 -2
View File
@@ -49,8 +49,8 @@ const NumberInput = ({
onValueChange={valueChangeHandler} onValueChange={valueChangeHandler}
decimalScale={decimalScale} decimalScale={decimalScale}
allowNegative={allowNegative} allowNegative={allowNegative}
startAdornment={inputPrefix} inputPrefix={inputPrefix}
endAdornment={inputSuffix} inputSuffix={inputSuffix}
{...restProps} {...restProps}
/> />
); );
+1 -1
View File
@@ -83,7 +83,7 @@ const TextArea = ({
<textarea <textarea
className={cn( className={cn(
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white', 'textarea h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all bg-white',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
+83 -1
View File
@@ -31,6 +31,8 @@ export interface TextInputProps {
errorMessage?: string; errorMessage?: string;
startAdornment?: ReactNode; startAdornment?: ReactNode;
endAdornment?: ReactNode; endAdornment?: ReactNode;
inputPrefix?: ReactNode;
inputSuffix?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
} }
@@ -48,6 +50,8 @@ const TextInput = ({
errorMessage, errorMessage,
startAdornment, startAdornment,
endAdornment, endAdornment,
inputPrefix,
inputSuffix,
disabled = false, disabled = false,
required = false, required = false,
onChange, onChange,
@@ -85,9 +89,86 @@ const TextInput = ({
</label> </label>
)} )}
{inputPrefix || inputSuffix ? (
<div className='relative flex'>
{inputPrefix && (
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200', 'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
{inputPrefix}
</div>
)}
<div
className={cn(
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white',
{
'border-error': isError,
'border-success!': isValid,
'rounded-l-none!': inputPrefix,
'rounded-r-none!': inputSuffix,
'input-disabled': disabled,
'cursor-not-allowed': disabled,
'bg-gray-50': disabled,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
<input
type={type}
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn(
'grow bg-transparent outline-none',
{
'cursor-not-allowed': disabled,
'text-gray-500': disabled,
},
className?.input
)}
readOnly={readOnly}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
</div>
{inputSuffix && (
<div
className={cn(
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
{inputSuffix}
</div>
)}
</div>
) : (
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200 bg-white',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
@@ -118,6 +199,7 @@ const TextInput = ({
</div> </div>
)} )}
</div> </div>
)}
{!isError && bottomLabel && ( {!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full text-sm opacity-60'>{bottomLabel}</p>
@@ -50,6 +50,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
...primaryButton, ...primaryButton,
onClick: () => { onClick: () => {
primaryButton?.onClick?.(notes); primaryButton?.onClick?.(notes);
setNotes('');
}, },
}} }}
secondaryButton={secondaryButton} secondaryButton={secondaryButton}
+1 -1
View File
@@ -298,7 +298,7 @@ const useApprovalSteps = ({
moduleName: string; moduleName: string;
moduleId: string; moduleId: string;
params?: { params?: {
page: number; page?: number;
limit: number; limit: number;
search?: string; search?: string;
group_step_number?: boolean; group_step_number?: boolean;
@@ -1,24 +1,46 @@
'use client'; 'use client';
import { useState } from 'react'; import { ChangeEventHandler, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { SortingState } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { useModal } from '@/components/Modal'; import { Icon } from '@iconify/react';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
import { MovementApi } from '@/services/api/inventory'; import { MovementApi } from '@/services/api/inventory';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { TableToolbar } from '@/components/table/TableToolbar'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; import Button from '@/components/Button';
import { OptionType } from '@/components/input/SelectInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput from '@/components/input/SelectInput';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import { TableRowOptions } from '@/components/table/TableRowOptions'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<Movement, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/inventory/movement/detail/?movementId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RowOptionsMenuWrapper>
);
const MovementTable = () => { const MovementTable = () => {
const { const {
@@ -28,30 +50,47 @@ const MovementTable = () => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { search: '' }, initial: {
paramMap: { page: 'page', pageSize: 'limit' }, search: '',
product: '',
warehouse: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
product: 'product_id',
warehouse: 'warehouse_id',
},
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const deleteModal = useModal();
const { const {
data: movements, setInputValue: setProductInputValue,
isLoading, options: productOptions,
mutate: refreshMovements, isLoadingOptions: isLoadingProductOptions,
} = useSWR( } = useSelect<Product>('/products', 'id', 'name');
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
} = useSelect<Warehouse>('/warehouses', 'id', 'name');
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
null
);
const [selectedWarehouse, setSelectedWarehouse] = useState<OptionType | null>(
null
);
const { data: movements, isLoading } = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`, `${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher MovementApi.getAllFetcher
); );
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
setPage(1);
}; };
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -60,41 +99,17 @@ const MovementTable = () => {
setPage(1); setPage(1);
}; };
const confirmationModalDeleteClickHandler = async () => { const productChangeHandler = (val: OptionType | OptionType[] | null) => {
setIsDeleteLoading(true); setSelectedProduct(val as OptionType);
try { updateFilter('product', val ? ((val as OptionType).value as string) : '');
await MovementApi.delete(selectedMovement?.id as number);
refreshMovements();
deleteModal.closeModal();
} finally {
setIsDeleteLoading(false);
}
}; };
return ( const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
<div className='flex flex-col gap-4'> setSelectedWarehouse(val as OptionType);
<div className='flex flex-col gap-2 mb-4'> updateFilter('warehouse', val ? ((val as OptionType).value as string) : '');
<TableToolbar };
addButton={{
href: '/inventory/movement/add',
label: 'Tambah',
}}
search={{
value: tableFilterState.search,
onChange: searchChangeHandler,
placeholder: 'Cari Movement',
}}
/>
<TableRowSizeSelector
value={tableFilterState.pageSize}
onChange={pageSizeChangeHandler}
options={ROWS_OPTIONS}
/>
</div>
<Table<Movement> const movementColumns: ColumnDef<Movement>[] = [
data={isResponseSuccess(movements) ? movements?.data : []}
columns={[
{ {
header: '#', header: '#',
cell: (props) => cell: (props) =>
@@ -118,9 +133,7 @@ const MovementTable = () => {
accessorKey: 'transfer_date', accessorKey: 'transfer_date',
header: 'Tanggal', header: 'Tanggal',
cell: (props) => cell: (props) =>
new Date(props.row.original.transfer_date).toLocaleDateString( new Date(props.row.original.transfer_date).toLocaleDateString('id-ID'),
'id-ID'
),
}, },
{ {
accessorFn: (row) => { accessorFn: (row) => {
@@ -135,52 +148,104 @@ const MovementTable = () => {
{ {
header: 'Aksi', header: 'Aksi',
cell: (props) => { cell: (props) => {
const currentPageSize = const currentPageSize = props.table.getPaginationRowModel().rows.length;
props.table.getPaginationRowModel().rows.length; const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentPageRows =
props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex = const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1; currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedMovement(props.row.original);
deleteModal.openModal();
};
return ( return (
<> <>
{currentPageSize > 2 && ( {currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}> <RowDropdownOptions isLast2Rows={isLast2Rows}>
<TableRowOptions <RowOptionsMenu type='dropdown' props={props} />
type='dropdown'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowDropdownOptions> </RowDropdownOptions>
)} )}
{currentPageSize <= 2 && ( {currentPageSize <= 2 && (
<RowCollapseOptions> <RowCollapseOptions>
<TableRowOptions <RowOptionsMenu type='collapse' props={props} />
type='collapse'
recordId={props.row.original.id}
basePath='/inventory/movement'
queryParam='movementId'
showEdit={false}
showDelete={false}
/>
</RowCollapseOptions> </RowCollapseOptions>
)} )}
</> </>
); );
}, },
}, },
]} ];
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'>
<Button
href='/inventory/movement/add'
variant='outline'
color='primary'
className='w-full sm:w-fit'
>
<Icon icon='ic:round-plus' width={24} height={24} />
Tambah
</Button>
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Movement'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-4'>
<SelectInput
label='Produk'
options={productOptions}
isLoading={isLoadingProductOptions}
value={selectedProduct}
onChange={productChangeHandler}
onInputChange={setProductInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<SelectInput
label='Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={selectedWarehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
</div>
<Table<Movement>
data={isResponseSuccess(movements) ? movements?.data : []}
columns={movementColumns}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={isResponseSuccess(movements) ? movements?.meta?.page : 0} page={isResponseSuccess(movements) ? movements?.meta?.page : 0}
totalItems={ totalItems={
@@ -205,22 +270,8 @@ const MovementTable = () => {
'px-6 py-3 last:flex last:flex-row last:justify-end', 'px-6 py-3 last:flex last:flex-row last:justify-end',
}} }}
/> />
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</div> </div>
</>
); );
}; };
@@ -1,34 +1,82 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Movement } from '@/types/api/inventory/movement'; import { Movement } from '@/types/api/inventory/movement';
export type ProductSchema = { type MovementFormSchemaType = {
product: { transfer_reason: string;
transfer_date: string;
source_warehouse?: {
value: number;
label: string;
area?: string;
location?: string;
} | null;
source_warehouse_id: number;
destination_warehouse?: {
value: number;
label: string;
area?: string;
location?: string;
} | null;
destination_warehouse_id: number;
products: {
product?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
product_id: number; product_id: number;
product_qty: number; product_qty: number | string;
}; }[];
deliveries: {
export type DeliverySchema = { delivery_cost?: number | string;
delivery_cost?: number | undefined; delivery_cost_per_item?: number | string;
delivery_cost_per_item?: number | undefined;
document?: File | string | null; document?: File | string | null;
document_path?: string | null; document_path?: string | null;
driver_name: string; driver_name: string;
vehicle_plate: string; vehicle_plate: string;
supplier: { supplier?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
supplier_id: number; supplier_id: number;
products: { products: {
product: { product?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
product_id: number; product_id: number;
product_qty: number; product_qty: number | string;
}[];
}[];
};
export type ProductSchema = {
product?: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number | string;
};
export type DeliverySchema = {
delivery_cost?: number | string;
delivery_cost_per_item?: number | string;
document?: File | string | null;
document_path?: string | null;
driver_name: string;
vehicle_plate: string;
supplier?: {
value: number;
label: string;
} | null;
supplier_id: number;
products: {
product?: {
value: number;
label: string;
} | null;
product_id: number;
product_qty: number | string;
}[]; }[];
}; };
@@ -102,7 +150,8 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
.required('Produk wajib diisi!'), .required('Produk wajib diisi!'),
}); });
export const MovementFormSchema = Yup.object({ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
Yup.object({
transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'),
transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'),
source_warehouse: Yup.object({ source_warehouse: Yup.object({
@@ -131,9 +180,7 @@ export const MovementFormSchema = Yup.object({
.of(DeliveryObjectSchema) .of(DeliveryObjectSchema)
.min(1, 'Minimal harus ada 1 pengiriman!') .min(1, 'Minimal harus ada 1 pengiriman!')
.required('Pengiriman wajib diisi!'), .required('Pengiriman wajib diisi!'),
}); });
export const UpdateMovementFormSchema = MovementFormSchema;
export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>; export type MovementFormValues = Yup.InferType<typeof MovementFormSchema>;
File diff suppressed because it is too large Load Diff
@@ -1,95 +0,0 @@
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { useModal } from '@/components/Modal';
import { MovementApi } from '@/services/api/inventory';
import {
CreateMovementPayload,
UpdateMovementPayload,
} from '@/types/api/inventory/movement';
import { isResponseError } from '@/lib/api-helper';
export const useMovementFormHandlers = (initialValuesId?: number) => {
const router = useRouter();
const deleteModal = useModal();
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createMovementHandler = useCallback(
async (payload: CreateMovementPayload, documents: File[] = []) => {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
const res = await MovementApi.create(
formData as unknown as CreateMovementPayload
);
if (isResponseError(res)) {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/inventory/movement');
},
[router]
);
const updateMovementHandler = useCallback(
async (
movementId: number,
payload: UpdateMovementPayload,
documents: File[] = []
) => {
let finalPayload: UpdateMovementPayload | FormData;
if (documents.length > 0) {
const formData = new FormData();
formData.append('data', JSON.stringify(payload));
documents.forEach((file, index) => {
formData.append(`documents[${index}]`, file);
});
finalPayload = formData as unknown as UpdateMovementPayload;
} else {
finalPayload = payload;
}
const res = await MovementApi.update(movementId, finalPayload);
if (res?.status === 'error') {
setMovementFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/inventory/movement');
},
[router]
);
const deleteMovementClickHandler = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const confirmationModalDeleteClickHandler = useCallback(async () => {
if (!initialValuesId) return;
setIsDeleteLoading(true);
await MovementApi.delete(initialValuesId);
deleteModal.closeModal();
toast.success('Successfully delete Movement!');
setIsDeleteLoading(false);
router.push('/inventory/movement');
}, [deleteModal, initialValuesId, router]);
return {
deleteModal,
movementFormErrorMessage,
isDeleteLoading,
createMovementHandler,
updateMovementHandler,
deleteMovementClickHandler,
confirmationModalDeleteClickHandler,
};
};
@@ -5,31 +5,38 @@ import CheckboxInput from '@/components/input/CheckboxInput';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar'; import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatVechicleNumber } from '@/lib/helper'; import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { MarketingApi } from '@/services/api/marketing/marketing'; import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing'; import { BaseSalesOrder, Marketing } from '@/types/api/marketing/marketing';
import { Customer } from '@/types/api/master-data/customer';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext } from '@tanstack/react-table'; import { CellContext, Row } from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
props, props,
deleteClickHandler, deleteClickHandler,
deliveryClickHandler,
}: { }: {
type: 'dropdown' | 'collapse'; type: 'dropdown' | 'collapse';
props: CellContext<Marketing, unknown>; props: CellContext<Marketing, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
deliveryClickHandler?: () => void;
}) => { }) => {
return ( return (
<div <div
@@ -44,7 +51,7 @@ const RowsOptionsMenu = ({
> >
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<Button <Button
href={`/marketing/sales-orders/detail/?salesOrderId=${props.row.original.id}`} href={`/marketing/detail?marketingId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='primary' color='primary'
className='justify-start text-sm' className='justify-start text-sm'
@@ -52,8 +59,31 @@ const RowsOptionsMenu = ({
<Icon icon='mdi:eye-outline' width={16} height={16} /> <Icon icon='mdi:eye-outline' width={16} height={16} />
Detail Detail
</Button> </Button>
{props.row.original.latest_approval.step_number != 1 && (
<Button <Button
href={`/marketing/sales-orders/detail/edit/?salesOrderId=${props.row.original.id}`} href={
props.row.original.latest_approval.step_number == 3
? `/marketing/detail/delivery-orders/edit?marketingId=${props.row.original.id}`
: props.row.original.latest_approval.step_number == 2
? `/marketing/add/delivery-orders?marketingId=${props.row.original.id}`
: undefined
}
onClick={() => {
if (props.row.original.latest_approval.step_number == 2) {
deliveryClickHandler?.();
}
}}
variant='ghost'
color='success'
className='justify-start text-sm'
>
<Icon icon='mdi:truck' width={16} height={16} />
Deliver
</Button>
)}
{props.row.original.latest_approval.step_number != 3 && (
<Button
href={`/marketing/detail/sales-orders/edit?marketingId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='warning'
className='justify-start text-sm' className='justify-start text-sm'
@@ -61,6 +91,7 @@ const RowsOptionsMenu = ({
<Icon icon='mdi:pencil-outline' width={16} height={16} /> <Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit Edit
</Button> </Button>
)}
<Button <Button
onClick={deleteClickHandler} onClick={deleteClickHandler}
variant='ghost' variant='ghost'
@@ -75,19 +106,18 @@ const RowsOptionsMenu = ({
); );
}; };
const SalesOrderTable = () => { const MarketingTable = () => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [approveAction, setApproveAction] = useState< const [approveAction, setApproveAction] = useState<'APPROVED' | 'REJECTED'>(
'approve' | 'reject' | null 'APPROVED'
>(null); );
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null); const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).filter(
(id) => rowSelection[id] const router = useRouter();
);
const { const {
data: marketing, data: marketing,
@@ -98,6 +128,7 @@ const SalesOrderTable = () => {
const deleteModal = useModal(); const deleteModal = useModal();
const confirmationModal = useModal(); const confirmationModal = useModal();
const productsModal = useModal(); const productsModal = useModal();
const deliveryModal = useModal();
const searchChangeHandler = useCallback( const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -116,12 +147,12 @@ const SalesOrderTable = () => {
); );
const approveClickHandler = () => { const approveClickHandler = () => {
setApproveAction('approve'); setApproveAction('APPROVED');
confirmationModal.openModal(); confirmationModal.openModal();
}; };
const rejectClickHandler = () => { const rejectClickHandler = () => {
setApproveAction('reject'); setApproveAction('REJECTED');
confirmationModal.openModal(); confirmationModal.openModal();
}; };
@@ -130,6 +161,93 @@ const SalesOrderTable = () => {
productsModal.openModal(); productsModal.openModal();
}; };
const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete(
selectedItem?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
confirmationModal.closeModal();
toast.success(deleteMarketingRes?.message as string);
}
if (isResponseError(deleteMarketingRes)) {
confirmationModal.closeModal();
toast.error(deleteMarketingRes?.message as string);
}
refreshMarketing();
deleteModal.closeModal();
};
const allData = isResponseSuccess(marketing) ? marketing.data : [];
const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()]
);
const hasApprovable = selectedRowsData.some(
(row) => row.latest_approval.step_number === 1
);
const hasRejectable = selectedRowsData.some(
(row) => row.latest_approval.step_number === 2
);
const disableApprove = !hasApprovable || hasRejectable;
// const disableReject = !hasRejectable || hasApprovable;
const idsToProcess =
approveAction === 'APPROVED'
? selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id)
: selectedRowsData
.filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
const approveMarketingHandler = async (notes: string) => {
let idsToProcess: number[] = [];
if (approveAction === 'APPROVED') {
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
} else if (approveAction === 'REJECTED') {
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
}
if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal();
return;
}
const approveMarketingRes = await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string);
setRowSelection({});
}
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
};
const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
deliveryModal.closeModal();
toast.success(res?.message as string);
refreshMarketing?.();
router.push(
`/marketing/detail/delivery-orders/edit?marketingId=${selectedItem?.id}`
);
};
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -144,13 +262,18 @@ const SalesOrderTable = () => {
}, },
}); });
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const step = row.original.latest_approval?.step_number;
return step === 1;
};
return ( return (
<> <>
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<TableToolbar <TableToolbar
addButton={{ addButton={{
href: '/marketing/sales-orders/add', href: '/marketing/add/sales-orders',
label: 'Tambah Sales Order', label: 'Tambah Sales Order',
}} }}
search={{ search={{
@@ -169,51 +292,75 @@ const SalesOrderTable = () => {
color='success' color='success'
onClick={approveClickHandler} onClick={approveClickHandler}
className='justify-start text-sm' className='justify-start text-sm'
disabled={!selectedRowIds.length} disabled={disableApprove}
> >
<Icon icon='material-symbols:check' width={24} height={24} /> <Icon icon='material-symbols:check' width={24} height={24} />
Approve Approve
</Button> </Button>
<Button {/* <Button
color='error' color='error'
onClick={rejectClickHandler} onClick={rejectClickHandler}
className='justify-start text-sm' className='justify-start text-sm'
disabled={!selectedRowIds.length} disabled={disableReject}
> >
<Icon icon='material-symbols:close' width={24} height={24} /> <Icon icon='material-symbols:close' width={24} height={24} />
Reject Reject
</Button> </Button> */}
</div> </div>
</div> </div>
<Table <Table
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
data={isResponseSuccess(marketing) ? marketing.data : []} data={allData}
columns={[ columns={[
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => {
const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter(getRowCanSelect);
const allSelected =
selectableRows.length > 0 &&
selectableRows.every((row) => row.getIsSelected());
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
);
};
return (
<div className='w-full flex flex-row justify-center'> <div className='w-full flex flex-row justify-center'>
<CheckboxInput <CheckboxInput
name='allRow' name='allRow'
checked={table.getIsAllRowsSelected()} checked={allSelected}
indeterminate={table.getIsSomeRowsSelected()} indeterminate={someSelected}
onChange={table.getToggleAllRowsSelectedHandler()} onChange={toggleSelectableRows}
disabled={selectableRows.length === 0}
/> />
</div> </div>
), );
cell: ({ row }) => ( },
cell: ({ row }) => {
const canSelect = getRowCanSelect(row);
return (
<div> <div>
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={row.getIsSelected()} checked={row.getIsSelected()}
disabled={!row.getCanSelect()} disabled={!canSelect}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
/> />
</div> </div>
), );
},
}, },
{ {
accessorKey: 'so_number', accessorKey: 'so_number',
@@ -222,9 +369,12 @@ const SalesOrderTable = () => {
{ {
accessorKey: 'so_date', accessorKey: 'so_date',
header: 'Tanggal', header: 'Tanggal',
cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
},
}, },
{ {
accessorKey: 'approval.step_name', accessorKey: 'latest_approval.step_name',
header: 'Status', header: 'Status',
}, },
{ {
@@ -232,15 +382,25 @@ const SalesOrderTable = () => {
header: 'Customer', header: 'Customer',
}, },
{ {
accessorKey: 'grand_total', accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total', header: 'Grand Total',
cell: (props) => {
return formatCurrency(
props.row.original?.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0
);
},
}, },
{ {
accessorKey: 'marketing_products.length', accessorKey: 'marketing_products.length',
header: 'Product Details', header: 'Product Details',
cell: (props) => { cell: (props) => {
if (props?.row?.original?.marketing_products?.length) { if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.marketing_products?.length > 1) { if (props?.row?.original?.sales_order?.length > 1) {
return ( return (
<Button <Button
variant='link' variant='link'
@@ -250,12 +410,11 @@ const SalesOrderTable = () => {
productsClickHandler(props?.row?.original); productsClickHandler(props?.row?.original);
}} }}
> >
Lihat {props?.row?.original?.marketing_products?.length}{' '} Lihat {props?.row?.original?.sales_order?.length} Produk
Produk
</Button> </Button>
); );
} else { } else {
const product = props?.row?.original?.marketing_products[0]; const product = props?.row?.original?.sales_order[0];
return <>{product?.product_warehouse?.product?.name}</>; return <>{product?.product_warehouse?.product?.name}</>;
} }
} }
@@ -274,7 +433,15 @@ const SalesOrderTable = () => {
const isLast2Rows = const isLast2Rows =
currentRowRelativeIndex > currentPageSize - 2; currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {}; const deleteClickHandler = () => {
setSelectedItem(props.row.original);
deleteModal.openModal();
};
const deliveryClickHandler = () => {
setSelectedItem(props.row.original);
deliveryModal.openModal();
};
return ( return (
<> <>
@@ -284,6 +451,7 @@ const SalesOrderTable = () => {
type='dropdown' type='dropdown'
props={props} props={props}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/> />
</RowDropdownOptions> </RowDropdownOptions>
)} )}
@@ -294,6 +462,7 @@ const SalesOrderTable = () => {
type='collapse' type='collapse'
props={props} props={props}
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
deliveryClickHandler={deliveryClickHandler}
/> />
</RowCollapseOptions> </RowCollapseOptions>
)} )}
@@ -330,16 +499,45 @@ const SalesOrderTable = () => {
}} }}
/> />
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmationModal.ref} ref={confirmationModal.ref}
type={approveAction === 'approve' ? 'success' : 'error'} type={approveAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction} data penjualan (${selectedRowIds.length} data)?`} text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan (${idsToProcess.length} data)?`}
secondaryButton={{
text: 'Tidak',
onClick: confirmationModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: approveAction === 'APPROVED' ? 'success' : 'error',
onClick: approveMarketingHandler,
}}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
onClick: deleteModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: deleteMarketingHandler,
}}
/>
<ConfirmationModalWithNotes
ref={deliveryModal.ref}
type={'success'}
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: approveAction === 'approve' ? 'success' : 'error', color: 'success',
onClick: confirmationModalDeliveryClickHandler,
}} }}
/> />
@@ -361,10 +559,10 @@ const SalesOrderTable = () => {
<Icon icon='mdi:close' width={16} height={16} /> <Icon icon='mdi:close' width={16} height={16} />
</Button> </Button>
</div> </div>
<Table<MarketingProduct> <Table<BaseSalesOrder>
data={ data={
isResponseSuccess(marketing) && selectedItem isResponseSuccess(marketing) && selectedItem
? (selectedItem?.marketing_products ?? []) ? (selectedItem?.sales_order ?? [])
: [] : []
} }
columns={[ columns={[
@@ -403,4 +601,4 @@ const SalesOrderTable = () => {
</> </>
); );
}; };
export default SalesOrderTable; export default MarketingTable;
@@ -0,0 +1,461 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import Table from '@/components/Table';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import {
BaseDelivery,
BaseSalesOrder,
Marketing,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import toast from 'react-hot-toast';
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
import DeliveryOrderExport from '../pdf/DeliveryOrderExport';
const MarketingDetail = ({
initialValues,
refresh,
}: {
initialValues?: Marketing;
refresh?: () => void;
}) => {
const router = useRouter();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
const [grandTotal, setGrandTotal] = useState(
initialValues?.sales_order
?.map((item) => item.total_price)
.reduce((a, b) => a + b, 0)
);
const [isLoading, setIsLoading] = useState(false);
const deleteModal = useModal();
const confirmationModal = useModal();
const deliveryModal = useModal();
const {
approvals,
isLoading: isLoadingApproval,
refresh: refreshApproval,
} = useApprovalSteps({
latestApproval: initialValues?.latest_approval,
approvalLines: MARKETING_APPROVAL_LINE,
moduleName: 'MARKETINGS',
moduleId: initialValues?.id as number as unknown as string,
});
const approveClickHandler = () => {
setApprovalAction('APPROVED');
confirmationModal.openModal();
};
// const rejectClickHandler = () => {
// setApprovalAction('REJECTED');
// confirmationModal.openModal();
// };
const deliveryClickHandler = () => {
deliveryModal.openModal();
};
const deleteClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsLoading(true);
const res = await MarketingApi.delete(initialValues?.id as number);
setIsLoading(false);
deleteModal.closeModal();
toast.success(res?.message as string);
refresh?.();
};
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsLoading(true);
const res = await SalesOrderApi.singleApproval(
initialValues?.id as number,
approvalAction,
notes
);
setIsLoading(false);
confirmationModal.closeModal();
toast.success(res?.message as string);
refresh?.();
refreshApproval?.();
};
const confirmationModalDeliveryClickHandler = async (notes: string) => {
setIsLoading(true);
const res = await SalesOrderApi.delivery(
initialValues?.id as number,
notes
);
setIsLoading(false);
deliveryModal.closeModal();
toast.success(res?.message as string);
refresh?.();
refreshApproval?.();
router.push(
`/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
);
};
return (
<>
<div className='flex flex-col w-full gap-4'>
<FormHeader title='Detail Sales Order' backUrl='/marketing' />
{!isLoadingApproval && approvals && (
<ApprovalSteps approvals={approvals} />
)}
<div className='flex-row flex gap-3'>
{initialValues?.latest_approval?.step_number != 3 && (
<>
<Button
color='success'
onClick={approveClickHandler}
disabled={initialValues?.latest_approval?.step_number != 1}
>
<Icon icon='mdi:check' width={24} height={24} />
Approve
</Button>
{/* <Button
color='error'
onClick={rejectClickHandler}
disabled={initialValues?.latest_approval?.step_number != 2}
>
<Icon icon='mdi:close' width={24} height={24} />
Reject
</Button> */}
</>
)}
{initialValues?.latest_approval?.step_number == 2 && (
<Button
color='success'
href={`/marketing/add/delivery-orders?marketingId=${initialValues?.id}`}
>
<Icon icon='mdi:truck' width={24} height={24} />
Delivery Order
</Button>
)}
</div>
<Card
title='Informasi Penjualan'
className={{
wrapper: 'w-full bg-white',
}}
>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td width='45%' className='font-semibold'>
No. Sales Order
</td>
<td>:</td>
<td width='50%'>{initialValues?.so_number}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Pelanggan</td>
<td>:</td>
<td>{initialValues?.customer?.name}</td>
</tr>
<tr>
<td className='font-semibold'>Status</td>
<td>:</td>
<td>{initialValues?.latest_approval?.step_name}</td>
</tr>
<tr>
<td className='font-semibold'>Tanggal Penjualan</td>
<td>:</td>
<td>{formatDate(initialValues?.so_date, 'DD MMM yyyy')}</td>
</tr>
<tr>
<td className='font-semibold'>Total Penjualan</td>
<td>:</td>
<td>{formatCurrency(grandTotal as number)}</td>
</tr>
<tr>
<td className='font-semibold'>Catatan</td>
<td>:</td>
<td>{initialValues?.notes ?? '-'}</td>
</tr>
<tr>
<td className='font-semibold'>Dokumen</td>
<td>:</td>
<td>
<SalesOrderExport data={initialValues} />
</td>
</tr>
</tbody>
</table>
</div>
</Card>
{initialValues?.sales_order && (
<Card
title='Informasi Produk'
className={{
wrapper: 'w-full bg-white',
}}
>
<Table<BaseSalesOrder>
data={initialValues?.sales_order}
columns={[
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
{
header: 'Total Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.total_weight);
},
},
{
header: 'Kuantitas',
accessorFn(row) {
return formatNumber(row.qty);
},
},
{
header: 'Avg. Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.avg_weight);
},
},
{
header: 'Total Penjualan (Rp)',
accessorFn(row) {
return formatCurrency(row.total_price);
},
},
]}
className={{
containerClassName: cn({
'mb-20':
initialValues?.sales_order &&
initialValues?.sales_order?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
</Card>
)}
{initialValues?.delivery_order && (
<Card
title='Informasi Pengiriman'
className={{
wrapper: 'w-full bg-white',
}}
>
{initialValues?.delivery_order.map((delivery, index) => {
return (
<div key={index}>
<Card
className={{
wrapper: 'w-full',
}}
>
<div className='flex flex-row gap-3'>
<div className='font-semibold'>
Nomor DO : {delivery.do_number}
</div>
</div>
<Table<BaseDelivery>
data={delivery.deliveries}
columns={[
{
header: 'Tanggal Pengiriman',
accessorFn() {
return formatDate(
delivery.delivery_date,
'DD MMM yyyy'
);
},
},
{
header: 'No. Polisi',
accessorFn(row) {
return formatVechicleNumber(row.vehicle_number);
},
},
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
{
header: 'Total Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.total_weight);
},
},
{
header: 'Kuantitas',
accessorFn(row) {
return formatNumber(row.qty);
},
},
{
header: 'Avg. Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.avg_weight);
},
},
{
header: 'Total Penjualan (Rp)',
accessorFn(row) {
return formatCurrency(row.total_price);
},
},
]}
className={{
containerClassName: cn({
'mb-20':
initialValues?.sales_order &&
initialValues?.sales_order?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName:
'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
</Card>
<div className='flex flex-row gap-3 my-3'>
<DeliveryOrderExport
data={initialValues}
deliveryOrder={delivery}
/>
</div>
</div>
);
})}
</Card>
)}
<div className='flex flex-row gap-3'>
<Button
color='warning'
type='button'
href={`/marketing/detail/${initialValues?.latest_approval.step_number == 3 ? 'delivery-orders' : 'sales-orders'}/edit?marketingId=${initialValues?.id}`}
>
<Icon icon='mdi:pencil' width={24} height={24} />
Edit
</Button>
<Button color='error' onClick={deleteClickHandler}>
<Icon icon='mdi:delete' width={24} height={24} />
Hapus
</Button>
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={confirmationModal.ref}
type={approvalAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approvalAction} data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction === 'APPROVED' ? 'success' : 'error',
isLoading: isLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModalWithNotes
ref={deliveryModal.ref}
type={'success'}
text={`Apakah anda yakin ingin deliver penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isLoading,
onClick: confirmationModalDeliveryClickHandler,
}}
/>
</>
);
};
export default MarketingDetail;
@@ -0,0 +1,76 @@
import * as Yup from 'yup';
import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './repeater/delivery-order/DeliverOrderProduct.schema';
type MarketingSchemaType = {
customer_id: number | undefined;
sales_person_id: number | undefined;
customer:
| {
value: number;
label: string;
}
| undefined
| null;
so_date: string | undefined;
notes: string | undefined;
};
type SalesOrderSchemaType = MarketingSchemaType & {
sales_order: SalesOrderProductFormValues[];
};
type DeliveryOrderSchemaType = {
delivery_order: DeliveryOrderProductFormValues[];
};
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'),
sales_person_id: Yup.number().required('Sales Person wajib diisi!'),
customer: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
so_date: Yup.string().required('Tanggal wajib diisi!'),
notes: Yup.string().required('Catatan wajib diisi!'),
sales_order: Yup.array()
.of(SalesOrderProductSchema)
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
});
export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
Yup.object({
delivery_order: Yup.array()
.of(DeliveryOrderProductSchema)
.min(1, 'Pengiriman wajib diisi!')
.required()
.test(
'at-least-one-delivery-date',
'Minimal ada satu tanggal pengiriman yang harus diisi!',
(value) => {
if (!value || value.length == 0) {
return false;
}
return value.some(
(item) =>
item.delivery_date !== null &&
item.delivery_date !== undefined &&
item.delivery_date !== ''
);
}
),
});
export const UpdateSalesOrderSchema = SalesOrderSchema;
export type SalesOrderFormValues = Yup.InferType<typeof SalesOrderSchema>;
export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
@@ -0,0 +1,757 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import { formatCurrency, formatDate } from '@/lib/helper';
import {
BaseDeliveryOrder,
BaseSalesOrder,
CreateDeliveryOrderPayload,
CreateSalesOrderPayload,
CreateSalesOrderProductPayload,
Marketing,
UpdateDeliveryOrderPayload,
UpdateSalesOrderPayload,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { useFormik } from 'formik';
import {
DeliveryOrderFormValues,
DeliveryOrderSchema,
SalesOrderFormValues,
SalesOrderSchema,
} from './MarketingForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import {
DeliveryOrderApi,
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { SalesOrderProductFormValues } from './repeater/sales-order/SalesOrderProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import SalesOrderProductTable from './table-view/SalesOrderProductTable';
import SalesOrderProductForm from './repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from './table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from './repeater/delivery-order/DeliverOrderProduct';
import { DeliveryOrderProductFormValues } from './repeater/delivery-order/DeliverOrderProduct.schema';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
const MemoizedDeliveryOrderProductTable = memo(DeliveryOrderProductTable);
const MemoizedDeliveryOrderProductForm = memo(DeliveryOrderProductForm);
const MarketingProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
return {
id: product.id,
vehicle_number: product.vehicle_number,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
avg_weight: product.avg_weight,
total_price: product.total_price,
};
};
const DeliveryProductToFieldValues = (
salesOrders: BaseSalesOrder[],
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => {
const soId = salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
)?.id;
return {
id: soId,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
avg_weight: item.avg_weight,
total_price: item.total_price,
vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number,
marketing_product_id: soId,
marketing_product: {
id: soId,
vehicle_number: item.vehicle_number,
kandang_id: item.product_warehouse.warehouse.id,
kandang: {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
},
product_warehouse: {
value: item.product_warehouse.id,
label: item.product_warehouse.product.name,
},
product_warehouse_id: item.product_warehouse.id,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
avg_weight: item.avg_weight,
total_price: item.total_price,
},
} as DeliveryOrderProductFormValues;
});
return data;
};
const mergeSOwithDO = (
salesOrders: SalesOrderProductFormValues[],
deliveryOrders: DeliveryOrderProductFormValues[]
): DeliveryOrderProductFormValues[] => {
return salesOrders.map((so) => {
const delivery = deliveryOrders.find(
(d) => d?.marketing_product_id === so.id
);
return {
...so, // nilai dasar dari sales order
marketing_product_id: so.id,
delivery_date: delivery?.delivery_date || undefined,
do_number: delivery?.do_number || undefined,
vehicle_number: delivery?.vehicle_number || so.vehicle_number,
unit_price: delivery?.unit_price ?? so.unit_price,
total_weight: delivery?.total_weight ?? so.total_weight,
qty: delivery?.qty ?? so.qty,
avg_weight: delivery?.avg_weight ?? so.avg_weight,
total_price: delivery?.total_price ?? so.total_price,
marketing_product: so, // jika ada, override
} as DeliveryOrderProductFormValues;
});
};
const MarketingForm = ({
formType = 'add',
initialValues,
afterSubmit,
}: {
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
initialValues?: Marketing;
afterSubmit?: () => void;
}) => {
const router = useRouter();
const deleteModal = useModal();
const [isLoading, setIsLoading] = useState(false);
const [selectedMarketingProduct, setSelectedMarketingProduct] =
useState<SalesOrderProductFormValues | null>(null);
const [selectedDeliveryProduct, setSelectedDeliveryProduct] =
useState<DeliveryOrderProductFormValues | null>(null);
const [deliveryOrderValues, setDeliveryOrderValues] = useState<
DeliveryOrderProductFormValues[]
>(
mergeSOwithDO(
initialValues?.sales_order?.map(MarketingProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
)
);
// Repeater Props
const addSOModal = useModal();
const addDOModal = useModal();
const [rowSOSelection, setRowSOSelection] = useState<Record<string, boolean>>(
{}
);
const selectedRowSOIds = Object.keys(rowSOSelection).map((item) =>
parseInt(item)
);
const [rowDOSelection, setRowDOSelection] = useState<Record<string, boolean>>(
{}
);
const selectedRowDOIds = Object.keys(rowDOSelection).map((item) =>
parseInt(item)
);
// End Repeater Props
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const formikInitialValues = useMemo<
SalesOrderFormValues & DeliveryOrderFormValues
>(() => {
return {
so_date: initialValues?.so_date || undefined,
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || 1,
customer: initialValues?.customer
? {
value: initialValues.customer.id,
label: initialValues.customer.name,
}
: null,
sales_order:
initialValues?.sales_order?.map((product) =>
MarketingProductToFieldValues(product)
) ?? [],
delivery_order: mergeSOwithDO(
initialValues?.sales_order?.map(MarketingProductToFieldValues) ?? [],
initialValues?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(initialValues.sales_order, delivery)
) ?? []
),
};
}, [initialValues]);
const formik = useFormik<SalesOrderFormValues & DeliveryOrderFormValues>({
enableReinitialize: true,
initialValues: formikInitialValues,
validationSchema:
formType == 'add_deliver' || formType == 'edit_deliver'
? DeliveryOrderSchema
: SalesOrderSchema,
validateOnMount: true,
onSubmit: async (values) => {
const payload =
formType != 'add_deliver' && formType != 'edit_deliver'
? ({
customer_id: values.customer_id as number,
sales_person_id: values.sales_person_id as number,
date: formatDate(values.so_date as string, 'yyyy-MM-DD'),
notes: values.notes as string,
marketing_products: values.sales_order.map((product) => {
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
} as CreateSalesOrderProductPayload;
}),
} as CreateSalesOrderPayload)
: ({
marketing_id: initialValues?.id as number,
delivery_products: values.delivery_order
.map((product) => {
if (Boolean(product.delivery_date)) {
return {
marketing_product_id:
product.marketing_product_id as number,
unit_price: parseFloat(product.unit_price as string),
total_weight: parseFloat(product.total_weight as string),
qty: parseFloat(product.qty as string),
avg_weight: parseFloat(product.avg_weight as string),
total_price: parseFloat(product.total_price as string),
delivery_date: formatDate(
product.delivery_date as string,
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
};
}
})
.filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload);
console.log('PAYLOAD');
console.log(payload);
switch (formType) {
case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload);
break;
case 'edit':
await updateMarketingHandler(payload as UpdateSalesOrderPayload);
break;
case 'add_deliver':
await createDeliveryHandler(payload as CreateDeliveryOrderPayload);
break;
case 'edit_deliver':
await updateDeliveryHandler(payload as UpdateDeliveryOrderPayload);
break;
default:
break;
}
afterSubmit?.();
},
});
const grandTotal = useMemo(() => {
return formik.values.sales_order.reduce(
(total, product) =>
total + parseFloat((product.total_price as string) || '0'),
0
);
}, [formik.values.sales_order]);
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const createMarketingRes = await SalesOrderApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
toast.success(createMarketingRes?.message as string);
router.push('/marketing');
}
if (isResponseError(createMarketingRes)) {
toast.error(createMarketingRes?.message as string);
}
setIsLoading(false);
};
const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => {
setIsLoading(true);
console.log(values);
const updateMarketingRes = await SalesOrderApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(updateMarketingRes)) {
toast.success(updateMarketingRes?.message as string);
router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
}
if (isResponseError(updateMarketingRes)) {
toast.error(updateMarketingRes?.message as string);
}
setIsLoading(false);
};
const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
const createDeliveryRes = await DeliveryOrderApi.create(values);
if (isResponseSuccess(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.success(createDeliveryRes?.message as string);
setDeliveryOrderValues(
createDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(
createDeliveryRes.data?.sales_order,
delivery
)
) ?? []
);
router.push(
`/marketing/detail/delivery-orders/edit?marketingId=${initialValues?.id}`
);
}
if (isResponseError(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.error(createDeliveryRes?.message as string);
}
setIsLoading(false);
};
const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => {
setIsLoading(true);
console.log(initialValues?.id);
const updateDeliveryRes = await DeliveryOrderApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.success(updateDeliveryRes?.message as string);
// router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
setDeliveryOrderValues(
updateDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
DeliveryProductToFieldValues(
updateDeliveryRes.data?.sales_order,
delivery
)
) ?? []
);
}
if (isResponseError(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.error(updateDeliveryRes?.message as string);
}
setIsLoading(false);
};
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.success(deleteMarketingRes?.message as string);
}
if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.error(deleteMarketingRes?.message as string);
}
setIsLoading(false);
deleteModal.closeModal();
router.push('/marketing/sales-orders');
};
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[formik]
);
// Repeater Handle
const handleDeleteSO = useCallback(
(id: number) => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter((p) => p.id != id)
);
},
[formik]
);
const handleBulkDeleteSO = useCallback(() => {
const currentProducts = formik.values.sales_order;
formik.setFieldValue(
'sales_order',
currentProducts.filter(
(product) => !selectedRowSOIds.includes(product.id ?? -1)
)
);
setRowSOSelection({});
}, [formik, selectedRowSOIds]);
const handleDelete = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const handleAddSOClick = useCallback(() => {
setSelectedMarketingProduct(null);
addSOModal.openModal();
}, [addSOModal]);
const handleAddSubmitSO = useCallback(
async (values: SalesOrderProductFormValues) => {
const currentProducts = formik.values.sales_order;
const newValues = {
...values,
id: values.id ?? Date.now(),
};
formik.setFieldValue('sales_order', [...currentProducts, newValues]);
addSOModal.closeModal();
},
[formik, addSOModal]
);
const handleDeleteDO = useCallback(
(id: number) => {
const currentProducts = formik.values.delivery_order;
setDeliveryOrderValues((prev) => prev.filter((p) => p.id !== id));
},
[formik]
);
const handleEditDO = useCallback(
(id: number) => {
const currentProducts = formik.values.delivery_order.find(
(product) => product.id == id
);
setSelectedDeliveryProduct(currentProducts ?? null);
addDOModal.openModal();
},
[formik]
);
const handleBulkDeleteDO = useCallback(() => {
setDeliveryOrderValues((prev) =>
prev.filter((product) => !selectedRowDOIds.includes(product.id ?? -1))
);
setRowDOSelection({});
}, [formik, selectedRowDOIds]);
const handleAddDOClick = useCallback(() => {
setSelectedDeliveryProduct(null);
addDOModal.openModal();
}, [addDOModal]);
const handleAddSubmitDO = useCallback(
async (values: DeliveryOrderProductFormValues) => {
const newValues = {
...values,
id: values.id ?? Date.now(),
};
setDeliveryOrderValues((prev) => [...prev, newValues]);
addDOModal.closeModal();
},
[formik, addDOModal]
);
const handleInputDate = useCallback(
(newData: DeliveryOrderProductFormValues) => {
setDeliveryOrderValues((prev) => {
return prev.map((item) => {
if (item.marketing_product_id == newData.marketing_product_id) {
return newData;
}
return item;
});
});
},
[]
);
const handleUpdateDO = useCallback(
async (id: number, values: DeliveryOrderProductFormValues) => {
setDeliveryOrderValues((prev) =>
prev.map((product) =>
product.id === id ? { ...product, ...values } : product
)
);
setSelectedDeliveryProduct(null);
addDOModal.closeModal();
},
[formik, addDOModal]
);
// End Repeater Handle
const memoSalesOrder = formik.values.sales_order;
useEffect(() => {
formik.setFieldValue('delivery_order', deliveryOrderValues);
}, [deliveryOrderValues, initialValues]);
return (
<>
<form
className='flex flex-col gap-4'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
<FormHeader
title={`${formType == 'add' || formType == 'add_deliver' ? 'Tambah' : 'Edit'} ${formType === 'add_deliver' || formType === 'edit_deliver' ? 'Delivery' : 'Sales'} Order`}
backUrl='/marketing'
/>
<Card
title='Informasi Order'
className={{
wrapper: 'bg-white w-full',
}}
>
<div className='grid grid-cols-2 gap-3 mt-3'>
<SelectInput
label='Pelanggan'
options={customerOptions}
isLoading={isLoadingCustomerOptions}
value={formik.values.customer}
onChange={handleChangeCustomer}
isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id)
}
errorMessage={formik.errors.customer_id}
isClearable
placeholder='Pilih Pelanggan'
isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
<DateInput
name='so_date'
label='Tanggal'
value={formik.values.so_date}
onChange={formik.handleChange}
isError={formik.touched.so_date && Boolean(formik.errors.so_date)}
errorMessage={formik.errors.so_date}
placeholder='Pilih Tanggal'
readOnly={formType == 'add_deliver' || formType == 'edit_deliver'}
/>
</div>
</Card>
{(formType == 'add' || formType == 'edit') && (
<Card
title='Informasi Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
{/* <div className='text-blue-500'>{JSON.stringify(initialValues)}</div>
<div className='text-green-500'>{JSON.stringify(formik.values)}</div>
<div className='text-red-500'>{JSON.stringify(formik.errors)}</div> */}
<MemoizedSalesOrderProductTable
formType={formType}
data={memoSalesOrder}
rowSelection={rowSOSelection}
setRowSelection={setRowSOSelection}
selectedRowIds={selectedRowSOIds}
onDelete={handleDeleteSO}
onBulkDelete={handleBulkDeleteSO}
onAddProductClick={handleAddSOClick}
/>
</Card>
)}
{(formType == 'add_deliver' || formType == 'edit_deliver') &&
initialValues?.sales_order &&
initialValues?.sales_order.length > 0 && (
<Card
title='Informasi Pengiriman'
className={{
wrapper: 'bg-white w-full',
}}
>
{/* {JSON.stringify(memoSalesOrder)} */}
{/* <small>{JSON.stringify(memoDeliveryOrder)}</small> */}
{/* <small className='block text-error'>
{JSON.stringify(formik.errors)}
</small> */}
<MemoizedDeliveryOrderProductTable
formType={formType}
data={deliveryOrderValues}
salesOrder={memoSalesOrder}
rowSelection={rowDOSelection}
setRowSelection={setRowDOSelection}
selectedRowIds={selectedRowDOIds}
onDelete={handleDeleteDO}
onEdit={handleEditDO}
onBulkDelete={handleBulkDeleteDO}
onAddProductClick={handleAddDOClick}
onInputDate={handleInputDate}
/>
</Card>
)}
<div className='grid grid-cols-2 gap-3'>
<TextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={formType === 'add_deliver' || formType === 'edit_deliver'}
/>
<div className='flex flex-col h-full justify-between items-end py-6'>
<span>Total Penjualan</span>
<span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}{' '}
</span>
</div>
</div>
<div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
</Button>
<Button
type='submit'
disabled={!formik.isValid || formik.isSubmitting}
isLoading={formik.isSubmitting}
>
Submit
</Button>
</div>
</form>
{formType == 'edit' && (
<div className='flex flex-row justify-start'>
<Button
type='button'
color='error'
onClick={handleDelete}
isLoading={isLoading}
>
<Icon icon='mdi:trash' width={24} height={24} />
Hapus
</Button>
</div>
)}
<Modal
ref={addSOModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
}}
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center justify-between'>
<h3 className='text-lg font-semibold mb-4'>Tambah Produk</h3>
<Button
variant='ghost'
color='error'
className='rounded-full'
onClick={addSOModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<MemoizedSalesOrderProductForm
onSubmitForm={handleAddSubmitSO}
initialValues={selectedMarketingProduct ?? undefined}
/>
</div>
</div>
</Modal>
<Modal
ref={addDOModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
}}
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center justify-between'>
<h3 className='text-lg font-semibold mb-4'>
{selectedDeliveryProduct ? 'Edit' : 'Tambah'} Pengiriman
</h3>
<Button
variant='ghost'
color='error'
className='rounded-full'
onClick={addDOModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<MemoizedDeliveryOrderProductForm
salesOrders={initialValues?.sales_order ?? []}
onSubmitForm={handleAddSubmitDO}
initialValues={selectedDeliveryProduct ?? undefined}
onUpdateForm={handleUpdateDO}
/>
</div>
</div>
</Modal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
onClick: deleteModal.closeModal,
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: deleteMarketingHandler,
}}
/>
</>
);
};
export default MarketingForm;
@@ -0,0 +1,63 @@
import * as Yup from 'yup';
import {
SalesOrderProductFormValues,
SalesOrderProductSchema,
} from '../sales-order/SalesOrderProduct.schema';
import { de } from 'react-day-picker/locale';
type DeliveryOrderProductSchemaType = {
id?: number | undefined;
marketing_product_id: number | undefined; // Sales Order ID
marketing_product?: SalesOrderProductFormValues | undefined | null;
unit_price: string | number | undefined;
total_weight: string | number | undefined;
qty: string | number | undefined;
avg_weight: string | number | undefined;
total_price: string | number | undefined;
vehicle_number: string | undefined;
delivery_date: string | undefined | null;
do_number?: string | undefined | null; // Uncertain
};
export const DeliveryOrderProductSchema: Yup.ObjectSchema<DeliveryOrderProductSchemaType> =
Yup.object({
id: Yup.number(),
marketing_product_id: Yup.number()
.min(1, 'Produk wajib diisi!')
.required('Produk wajib diisi!'),
marketing_product: Yup.object().nullable().optional(),
unit_price: Yup.number()
.min(1, 'Harga Satuan wajib diisi!')
.required('Harga Satuan wajib diisi!'),
total_weight: Yup.number()
.min(0, 'Total Bobot wajib diisi!')
.required('Total Bobot wajib diisi!'),
qty: Yup.number()
.min(1, 'Kuantitas wajib diisi!')
.required('Kuantitas wajib diisi!'),
avg_weight: Yup.number()
.min(0, 'Avg. Bobot wajib diisi!')
.required('Avg. Bobot wajib diisi!'),
total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
delivery_date: Yup.string()
.required('Tanggal Pengiriman wajib diisi!')
.nullable()
.optional(),
do_number: Yup.string().nullable().optional(),
});
export type DeliveryOrderProductFormValues = Yup.InferType<
typeof DeliveryOrderProductSchema
>;
// "marketing_product_id": 3,
// "qty": 20,
// "unit_price": 1000,
// "avg_weight": 1.1,
// "total_weight": 220,
// "total_price": 20000,
// "delivery_date": "2025-11-09",
// "vehicle_number": "D 4321 XXX"
@@ -0,0 +1,354 @@
import { useEffect, useState } from 'react';
import {
DeliveryOrderProductFormValues,
DeliveryOrderProductSchema,
} from './DeliverOrderProduct.schema';
import { useFormik } from 'formik';
import Alert from '@/components/Alert';
import Button from '@/components/Button';
import NumberInput from '@/components/input/NumberInput';
import PatternInput from '@/components/input/PatternInput';
import { formatVechicleNumber } from '@/lib/helper';
import DateInput from '@/components/input/DateInput';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { SalesOrderProductFormValues } from '../sales-order/SalesOrderProduct.schema';
import { BaseSalesOrder } from '@/types/api/marketing/marketing';
import Badge from '@/components/Badge';
const DeliveryOrderProductForm = ({
salesOrders,
initialValues,
onSubmitForm,
onUpdateForm,
}: {
salesOrders: BaseSalesOrder[];
initialValues?: DeliveryOrderProductFormValues;
onSubmitForm?: (value: DeliveryOrderProductFormValues) => Promise<void>;
onUpdateForm?: (
id: number,
value: DeliveryOrderProductFormValues
) => Promise<void>;
}) => {
const [formikErrorMessage, setFormErrorMessage] = useState('');
const [selectedProduct, setSelectedProduct] = useState<OptionType | null>(
null
);
const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
delivery_date: initialValues?.delivery_date || undefined,
vehicle_number: initialValues?.vehicle_number || undefined,
marketing_product_id: initialValues?.marketing_product_id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
marketing_product: initialValues?.marketing_product || undefined,
},
validationSchema: DeliveryOrderProductSchema,
validateOnBlur: true,
validateOnChange: false,
onSubmit: async (values) => {
setFormErrorMessage('');
if (initialValues?.id) {
await onUpdateForm?.(initialValues.id, values);
} else {
await onSubmitForm?.(values);
}
handleResetForm();
},
});
const handleResetForm = () => {
setFormErrorMessage('');
formik.resetForm({
values: {
delivery_date: '',
vehicle_number: '',
marketing_product_id: undefined,
unit_price: '',
total_weight: '',
qty: '',
avg_weight: '',
total_price: '',
marketing_product: undefined,
},
});
setSelectedProduct(null);
};
const handleBlurField = (field: string) => {
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue('total_price', Number(qty) * Number(unit_price));
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue('unit_price', Number(total_price) / Number(qty));
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight));
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
}
}
};
const MarketingProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
return {
id: product.id,
vehicle_number: product.vehicle_number,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
avg_weight: product.avg_weight,
total_price: product.total_price,
};
};
const options = salesOrders.map((item) => ({
value: item.id,
label: `${item.product_warehouse.product.name} - ${item.product_warehouse.warehouse.name}`,
}));
const { setValues: setFormikValues } = formik;
useEffect(() => {
if (initialValues) {
setFormikValues(initialValues);
const value = salesOrders.find(
(item) => item.id === initialValues.marketing_product_id
);
setSelectedProduct({
value: value?.id,
label: `${value?.product_warehouse.product.name} - ${value?.product_warehouse.warehouse.name}`,
} as OptionType);
}
}, [initialValues]);
return (
<>
<form
className='size-full'
onSubmit={formik.handleSubmit}
onReset={handleResetForm}
>
{/* <small className='block text-blue-500'>
{JSON.stringify(initialValues)}
</small>
<small className='block text-red-500'>
{JSON.stringify(formik.errors)}
</small>
<small className='block text-emerald-500'>
{JSON.stringify(formik.values)}
</small>
<div className='hidden'>
{JSON.stringify(formik.values.marketing_product)}
</div> */}
{formikErrorMessage && (
<div onClick={() => setFormErrorMessage('')} className='my-3 w-full'>
<Alert color='error'>{formikErrorMessage}</Alert>
</div>
)}
<div className='grid grid-cols-2 gap-4'>
<SelectInput
options={options}
label='Produk'
placeholder='Pilih Produk'
isDisabled
value={
selectedProduct
? ({
value: selectedProduct?.value,
label: salesOrders.find(
(item) => item.id === selectedProduct?.value
)?.product_warehouse.product.name,
} as OptionType)
: null
}
onChange={(value) => {
const selected = value as OptionType;
setSelectedProduct(selected);
const so = salesOrders.find(
(item) => item.id === selected?.value
);
if (!so) {
formik.setValues({
...formik.values,
marketing_product_id: undefined,
marketing_product: null,
qty: formik.values.qty || '',
unit_price: '',
total_price: '',
avg_weight: '',
total_weight: '',
vehicle_number: '',
});
return;
}
formik.setValues({
...formik.values,
marketing_product_id: selected.value as number,
marketing_product: MarketingProductToFieldValues(so),
qty: formik.values.qty || so.qty,
unit_price: so.unit_price,
total_price: so.total_price,
avg_weight: so.avg_weight,
total_weight: so.total_weight,
vehicle_number: so.vehicle_number,
});
}}
startAdornment={
selectedProduct && (
<Badge
variant='soft'
color='success'
size='sm'
className={{ badge: 'whitespace-nowrap font-semibold' }}
>
{
salesOrders.find(
(item) => item.id === selectedProduct?.value
)?.product_warehouse?.warehouse?.name
}
</Badge>
)
}
isClearable
isError={Boolean(formik.errors.marketing_product_id)}
errorMessage={formik.errors.marketing_product_id}
required
/>
<DateInput
name='delivery_date'
label='Tanggal'
value={formik.values.delivery_date ?? undefined}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.delivery_date &&
Boolean(formik.errors.delivery_date)
}
errorMessage={formik.errors.delivery_date}
placeholder='Pilih Tanggal'
required
/>
<PatternInput
name='vehicle_number'
label='No. Polisi'
format='AA #### AAA'
mask='_'
inputVehicleNumber
required
type='text'
placeholder='B 1234 CDE'
value={formatVechicleNumber(formik.values.vehicle_number ?? '')}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={Boolean(formik.errors.vehicle_number)}
errorMessage={formik.errors.vehicle_number}
/>
<NumberInput
required
label='Kuantitas'
name='qty'
value={formik.values.qty}
onChange={formik.handleChange}
onBlur={() => handleBlurField('qty')}
isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={formik.handleChange}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
name='unit_price'
value={formik.values.unit_price}
onChange={formik.handleChange}
onBlur={() => handleBlurField('unit_price')}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Total Bobot (Kg)'
name='total_weight'
value={formik.values.total_weight}
onChange={formik.handleChange}
onBlur={() => handleBlurField('total_weight')}
isError={Boolean(formik.errors.total_weight)}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
/>
<NumberInput
required
label='Total Penjualan (Rp)'
name='total_price'
value={formik.values.total_price}
onChange={formik.handleChange}
onBlur={() => handleBlurField('total_price')}
isError={Boolean(formik.errors.total_price)}
errorMessage={formik.errors.total_price}
placeholder='Masukan Total Penjualan'
/>
</div>
<div className='flex flex-row justify-end gap-3 mt-4'>
<Button type='reset' color='warning'>
Reset
</Button>
<Button
type='submit'
isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
</form>
</>
);
};
export default DeliveryOrderProductForm;
@@ -1,7 +1,7 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
type MarketingProductSchemaType = { type SalesOrderProductSchemaType = {
vehicle_number: string | undefined; id?: number | undefined;
kandang_id?: number; kandang_id?: number;
kandang?: { kandang?: {
value: number; value: number;
@@ -15,15 +15,15 @@ type MarketingProductSchemaType = {
unit_price: string | number | undefined; unit_price: string | number | undefined;
total_weight: string | number | undefined; total_weight: string | number | undefined;
qty: string | number | undefined; qty: string | number | undefined;
uom: string | undefined | null;
avg_weight: string | number | undefined; avg_weight: string | number | undefined;
total_price: string | number | undefined; total_price: string | number | undefined;
delivery_date?: string | undefined | null; vehicle_number?: string | undefined;
}; };
export const MarketingProductSchema: Yup.ObjectSchema<MarketingProductSchemaType> = export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
Yup.object({ Yup.object({
vehicle_number: Yup.string().required('No. Polisi wajib diisi!'), id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
kandang: Yup.object({ kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -42,21 +42,19 @@ export const MarketingProductSchema: Yup.ObjectSchema<MarketingProductSchemaType
.min(1, 'Harga Satuan wajib diisi!') .min(1, 'Harga Satuan wajib diisi!')
.required('Harga Satuan wajib diisi!'), .required('Harga Satuan wajib diisi!'),
total_weight: Yup.number() total_weight: Yup.number()
.min(1, 'Total Bobot wajib diisi!') .min(0, 'Total Bobot wajib diisi!')
.required('Total Bobot wajib diisi!'), .required('Total Bobot wajib diisi!'),
qty: Yup.number() qty: Yup.number()
.min(1, 'Kuantitas wajib diisi!') .min(1, 'Kuantitas wajib diisi!')
.required('Kuantitas wajib diisi!'), .required('Kuantitas wajib diisi!'),
uom: Yup.string().nullable(),
avg_weight: Yup.number() avg_weight: Yup.number()
.min(1, 'Avg. Bobot wajib diisi!') .min(0, 'Avg. Bobot wajib diisi!')
.required('Avg. Bobot wajib diisi!'), .required('Avg. Bobot wajib diisi!'),
total_price: Yup.number() total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!') .min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'), .required('Total Penjualan wajib diisi!'),
delivery_date: Yup.string().required().nullable(),
}); });
export type MarketingProductFormValues = Yup.InferType< export type SalesOrderProductFormValues = Yup.InferType<
typeof MarketingProductSchema typeof SalesOrderProductSchema
>; >;
@@ -1,17 +1,11 @@
'use client'; 'use client';
import TextInput from '@/components/input/TextInput';
import {
CreateMarketingPayload,
CreateMarketingProductPayload,
MarketingProduct,
} from '@/types/api/marketing/marketing';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { import {
MarketingProductFormValues, SalesOrderProductFormValues,
MarketingProductSchema, SalesOrderProductSchema,
} from './MarketingProduct.schema'; } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, use, useEffect, useRef, useState } from 'react'; import { RefObject, useState } from 'react';
import SelectInput, { import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
@@ -23,37 +17,47 @@ import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import PatternInput from '@/components/input/PatternInput';
import { formatVechicleNumber } from '@/lib/helper'; import { formatVechicleNumber } from '@/lib/helper';
import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert';
const MarketingProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
data,
modalRef,
onSubmitForm, onSubmitForm,
}: { }: {
initialValues?: MarketingProduct; initialValues?: SalesOrderProductFormValues;
data: MarketingProduct[];
modalRef?: RefObject<HTMLDialogElement | null>; modalRef?: RefObject<HTMLDialogElement | null>;
onSubmitForm?: ( onSubmitForm?: (value: SalesOrderProductFormValues) => Promise<void>;
tableValues: CreateMarketingProductPayload,
fieldValues: MarketingProductFormValues
) => Promise<void>;
}) => { }) => {
// State
const [selectedOptionsKandang, setSelectedOptionsKandang] =
useState<OptionType | null>(null);
const [selectedOptionsWarehouse, setSelectedOptionsWarehouse] = useState<
OptionType | null | undefined
>(undefined);
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
// Options Data const formik = useFormik<SalesOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
vehicle_number: initialValues?.vehicle_number || undefined,
kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || undefined,
product_warehouse: initialValues?.product_warehouse || undefined,
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
},
validationSchema: SalesOrderProductSchema,
onSubmit: async (values) => {
setFormErrorMessage('');
onSubmitForm?.(values);
handleResetForm();
},
});
const { const {
options: kandangSourceOptions, options: kandangSourceOptions,
rawData: kandangSourceRawData,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingKandangSourceOptions,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name'); } = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const { const {
options: warehouseSourceOptions, options: warehouseSourceOptions,
rawData: warehouseSourceRawData, rawData: warehouseSourceRawData,
@@ -64,109 +68,35 @@ const MarketingProductForm = ({
'product.name', 'product.name',
'search', 'search',
{ {
warehouse_id: selectedOptionsKandang?.value?.toString() ?? '', warehouse_id: formik.values.kandang_id?.toString() ?? '',
} }
); );
// Handler
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => { const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedOptionsKandang(val as OptionType);
formik.setFieldValue('kandang', val as OptionType); formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value); formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('qty', null); formik.setFieldValue('qty', null);
warehouseChangeHandler(null);
}; };
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
setSelectedOptionsWarehouse(val as OptionType);
formik.setFieldValue('product_warehouse', val as OptionType); formik.setFieldValue('product_warehouse', val as OptionType);
formik.setFieldValue('product_warehouse_id', (val as OptionType)?.value); const newId = (val as OptionType)?.value;
if (isResponseSuccess(warehouseSourceRawData)) { formik.setFieldValue('product_warehouse_id', newId);
if (isResponseSuccess(warehouseSourceRawData) && newId) {
const productWarehouse = warehouseSourceRawData?.data.find( const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === (val as OptionType)?.value (item: ProductWarehouse) => item.id === newId
); );
if (selectedOptionsWarehouse?.value !== null) {
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
formik.setFieldValue('qty', null); formik.setFieldValue('qty', null);
} }
}
}; };
// Formik
const formik = useFormik<MarketingProductFormValues>({
enableReinitialize: true,
initialValues: {
vehicle_number:
initialValues?.marketing_delivery_products?.vehicle_number || undefined,
kandang_id: initialValues?.product_warehouse.warehouse.id || undefined,
kandang: {
value: initialValues?.product_warehouse.warehouse.id as number,
label: initialValues?.product_warehouse.warehouse.name as string,
},
product_warehouse: {
value: initialValues?.product_warehouse.product.id as number,
label: initialValues?.product_warehouse.product.name as string,
},
product_warehouse_id:
initialValues?.product_warehouse.product.id || undefined,
unit_price: initialValues?.unit_price || undefined,
total_weight: initialValues?.total_weight || undefined,
qty: initialValues?.qty || undefined,
uom: initialValues?.product_warehouse?.product?.uom?.name || undefined,
avg_weight: initialValues?.avg_weight || undefined,
total_price: initialValues?.total_price || undefined,
delivery_date:
initialValues?.marketing_delivery_products?.delivery_date ||
new Date().toDateString() ||
undefined,
},
validationSchema: MarketingProductSchema,
onSubmit: async (values) => {
setFormErrorMessage('');
if (
isResponseSuccess(kandangSourceRawData) &&
isResponseSuccess(warehouseSourceRawData)
) {
const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === values.product_warehouse_id
);
const kandang = kandangSourceRawData?.data.find(
(item: Kandang) => item.id === values.kandang_id
);
const marketingProduct: CreateMarketingProductPayload = {
id: initialValues?.id || undefined,
vehicle_number: formatVechicleNumber(values.vehicle_number as string),
kandang_id: values.kandang_id as number,
kandang: kandang,
product_warehouse_id: values.product_warehouse_id as number,
product_warehouse: productWarehouse,
unit_price: values.unit_price as number,
total_weight: values.total_weight as number,
qty: values.qty as number,
uom: values.uom as string,
avg_weight: values.avg_weight as number,
total_price: values.total_price as number,
delivery_date: values.delivery_date as string,
};
onSubmitForm?.(marketingProduct, values);
handleResetForm();
}
},
});
const { setValues: formikSetValues } = formik;
useEffect(() => {
formikSetValues(formik.initialValues);
}, [formikSetValues, formik.initialValues]);
const handleResetForm = () => { const handleResetForm = () => {
setSelectedOptionsKandang(null);
setSelectedOptionsWarehouse(null);
setFormErrorMessage(''); setFormErrorMessage('');
formik.resetForm({ formik.resetForm({
values: { values: {
@@ -178,10 +108,8 @@ const MarketingProductForm = ({
unit_price: '', unit_price: '',
total_weight: '', total_weight: '',
qty: '', qty: '',
uom: '',
avg_weight: '', avg_weight: '',
total_price: '', total_price: '',
delivery_date: new Date().toDateString(),
}, },
}); });
}; };
@@ -191,7 +119,7 @@ const MarketingProductForm = ({
formik.values; formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && field === 'unit_price') { if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue( formik.setFieldValue(
'total_price', 'total_price',
(qty as number) * (unit_price as number) (qty as number) * (unit_price as number)
@@ -205,7 +133,7 @@ const MarketingProductForm = ({
} }
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') { if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && field === 'avg_weight') { if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue( formik.setFieldValue(
'total_weight', 'total_weight',
(qty as number) * (avg_weight as number) (qty as number) * (avg_weight as number)
@@ -226,6 +154,16 @@ const MarketingProductForm = ({
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={handleResetForm} onReset={handleResetForm}
> >
{formErrorMessage && (
<div onClick={() => setFormErrorMessage('')} className='my-3 w-full'>
<Alert color='error'>
{formErrorMessage ? formErrorMessage : ''}
</Alert>
</div>
)}
{/* <small className='block text-rose-500'>
{JSON.stringify(formik.errors)}
</small> */}
<div className='grid grid-cols-2 gap-4 z-200'> <div className='grid grid-cols-2 gap-4 z-200'>
<PatternInput <PatternInput
name='vehicle_number' name='vehicle_number'
@@ -250,10 +188,9 @@ const MarketingProductForm = ({
label='Kandang' label='Kandang'
options={kandangSourceOptions} options={kandangSourceOptions}
isLoading={isLoadingKandangSourceOptions} isLoading={isLoadingKandangSourceOptions}
value={selectedOptionsKandang} value={formik.values.kandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
isClearable isClearable
menuPortalTarget={modalRef?.current}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -265,12 +202,11 @@ const MarketingProductForm = ({
label='Produk' label='Produk'
options={warehouseSourceOptions} options={warehouseSourceOptions}
isLoading={isLoadingWarehouseSourceOptions} isLoading={isLoadingWarehouseSourceOptions}
value={selectedOptionsWarehouse} value={formik.values.product_warehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
isClearable isClearable
menuPortalTarget={modalRef?.current}
placeholder='Pilih Kandang Terlebih Dahulu' placeholder='Pilih Kandang Terlebih Dahulu'
isDisabled={!selectedOptionsKandang?.value} isDisabled={!formik.values.kandang_id}
isError={ isError={
formik.touched.product_warehouse_id && formik.touched.product_warehouse_id &&
Boolean(formik.errors.product_warehouse_id) Boolean(formik.errors.product_warehouse_id)
@@ -358,4 +294,4 @@ const MarketingProductForm = ({
); );
}; };
export default MarketingProductForm; export default SalesOrderProductForm;
@@ -0,0 +1,280 @@
import Table from '@/components/Table';
import { DeliveryOrderProductFormValues } from '../repeater/delivery-order/DeliverOrderProduct.schema';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import * as TanStack from '@tanstack/react-table';
import { useMemo, useRef } from 'react';
import CheckboxInput from '@/components/input/CheckboxInput';
import {
cn,
formatCurrency,
formatDate,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { SalesOrderProductFormValues } from '../repeater/sales-order/SalesOrderProduct.schema';
import DateInput from '@/components/input/DateInput';
type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[];
salesOrder: SalesOrderProductFormValues[];
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
rowSelection: Record<string, boolean>;
setRowSelection: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
selectedRowIds: number[];
onDelete: (id: number) => void;
onEdit: (id: number) => void;
onBulkDelete: () => void;
onAddProductClick: () => void;
onInputDate: (data: DeliveryOrderProductFormValues) => void;
};
const DeliveryOrderProductTable = ({
data,
salesOrder,
formType,
rowSelection,
setRowSelection,
selectedRowIds,
onDelete,
onEdit,
onBulkDelete,
onAddProductClick,
onInputDate,
}: DeliveryOrderProductTableProps) => {
const onDeleteRef = useRef(onDelete);
const onEditRef = useRef(onDelete);
onDeleteRef.current = onDelete;
onEditRef.current = onEdit;
const canAddData = salesOrder.reduce((acc, curr) => {
const deliveredQty = data.filter(
(deliveryItem) => deliveryItem.marketing_product_id == curr.id
);
return acc && deliveredQty.length != salesOrder.length;
}, true);
const columns = useMemo(() => {
const cols = [
// {
// id: 'select',
// header: ({
// table,
// }: {
// table: TanStack.Table<DeliveryOrderProductFormValues>;
// }) => (
// <div className='w-full flex flex-row justify-center'>
// <CheckboxInput
// name='allRow'
// checked={table.getIsAllRowsSelected()}
// indeterminate={table.getIsSomeRowsSelected()}
// onChange={table.getToggleAllRowsSelectedHandler()}
// />
// </div>
// ),
// cell: ({
// row,
// }: {
// row: TanStack.Row<DeliveryOrderProductFormValues>;
// }) => (
// <div>
// <CheckboxInput
// name='row'
// checked={row.getIsSelected()}
// disabled={!row.getCanSelect()}
// indeterminate={row.getIsSomeSelected()}
// onChange={row.getToggleSelectedHandler()}
// />
// </div>
// ),
// },
{
accessorFn: (row: DeliveryOrderProductFormValues) => row.do_number,
header: 'No. Pengiriman',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => <div>{props.row.original.do_number}</div>,
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.delivery_date
? formatDate(row.delivery_date as string, 'DD MMM YYYY')
: '-',
header: 'Tanggal Delivery',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => (
<>
{formType == 'add_deliver' && (
<DateInput
name={`delivery_date_${props.row.original.marketing_product_id}`}
className={{
input: 'p-0',
inputWrapper: 'py-1 px-3 h-fit w-fit bg-white',
wrapper: 'p-0',
}}
value={
props.row.original.delivery_date
? formatDate(props.row.original.delivery_date, 'yyyy-MM-DD')
: undefined
}
onChange={(val) => {
onInputDate({
...props.row.original,
delivery_date: val.target.value,
});
}}
/>
)}
{formType == 'edit_deliver' &&
formatDate(
props.row.original.delivery_date as string,
'DD MMM YYYY'
)}
</>
),
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatVechicleNumber(row.vehicle_number as string),
header: 'No. Polisi',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.marketing_product?.kandang?.label,
header: 'Kandang',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
row.marketing_product?.product_warehouse?.label,
header: 'Produk',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatCurrency(parseFloat(row.unit_price as string)),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.total_weight as string)),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.qty as string)),
header: 'Kuantitas',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatNumber(parseFloat(row.avg_weight as string)),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: DeliveryOrderProductFormValues) =>
formatCurrency(parseFloat(row.total_price as string)),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (
props: TanStack.CellContext<DeliveryOrderProductFormValues, unknown>
) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<>
<Button
color='warning'
className='px-2 py-1 text-sm'
onClick={() =>
onEditRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:edit' width={16} height={16} /> Edit
</Button>
{/* <Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button> */}
</>
</div>
),
},
];
if (formType == 'add_deliver') {
return cols.filter(
(col) => col.header != 'Aksi' && col.header != 'No. Pengiriman'
);
}
return cols;
}, [formType, onInputDate, onEditRef]);
return (
<>
<Table<DeliveryOrderProductFormValues>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={data}
columns={columns}
className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-2 py-2 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
emptyContent={
<div
className={cn(
'w-full h-16 flex flex-col justify-center items-center gap-2'
)}
>
<span className='text-gray-500'>Belum ada data pengiriman</span>
</div>
}
/>
<div className='flex flex-row gap-3 mt-3'>
{/* <Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={onAddProductClick}
// disabled={!canAddData}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Pengiriman
</Button> */}
{selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Pengiriman
</Button>
)}
</div>
</>
);
};
export default DeliveryOrderProductTable;
@@ -0,0 +1,203 @@
'use client';
import Button from '@/components/Button';
import Table from '@/components/Table';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import {
cn,
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { Icon } from '@iconify/react';
import { useMemo, useRef, useState } from 'react';
import * as TanStack from '@tanstack/react-table';
import CheckboxInput from '@/components/input/CheckboxInput';
type SalesOrderProductTableProps = {
data: SalesOrderProductFormValues[];
formType?: 'add' | 'edit' | 'deliver';
rowSelection: Record<string, boolean>;
setRowSelection: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
selectedRowIds: number[];
onDelete: (id: number) => void;
onBulkDelete: () => void;
onAddProductClick: () => void;
};
const SalesOrderProductTable = ({
data,
formType,
rowSelection,
setRowSelection,
selectedRowIds,
onDelete,
onBulkDelete,
onAddProductClick,
}: SalesOrderProductTableProps) => {
const onDeleteRef = useRef(onDelete);
onDeleteRef.current = onDelete;
const columns = useMemo(
() => [
{
id: 'select',
header: ({
table,
}: {
table: TanStack.Table<SalesOrderProductFormValues>;
}) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }: { row: TanStack.Row<SalesOrderProductFormValues> }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
value={`${row.original.product_warehouse_id}${row.original.kandang_id}`}
/>
</div>
),
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatVechicleNumber(row.vehicle_number as string),
header: 'No. Polisi',
},
{
accessorFn: (row: SalesOrderProductFormValues) => row.kandang?.label,
header: 'Kandang',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
row.product_warehouse?.label,
header: 'Produk',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatCurrency(parseFloat(row.unit_price as string)),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.total_weight as string)),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.qty as string)),
header: 'Kuantitas',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.avg_weight as string)),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: SalesOrderProductFormValues) =>
formatCurrency(parseFloat(row.total_price as string)),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (
props: TanStack.CellContext<SalesOrderProductFormValues, unknown>
) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<Button
color='error'
className='p-1'
onClick={() =>
onDeleteRef.current(props.row.original.id as number)
}
type='button'
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</div>
),
},
],
[]
);
return (
<>
<Table<SalesOrderProductFormValues>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={data}
columns={
formType == 'deliver'
? columns.filter(
(col) => col.header != 'Aksi' && col.id != 'select'
)
: columns
}
className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-2 py-2 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
paginationClassName: 'hidden',
}}
emptyContent={
<div
className={cn(
'w-full h-16 flex flex-col justify-center items-center gap-2'
)}
>
<span className='text-gray-500'>Belum ada data penjualan</span>
</div>
}
/>
{formType != 'deliver' && (
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={onAddProductClick}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Produk
</Button>
{selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={onBulkDelete}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Produk
</Button>
)}
</div>
)}
</>
);
};
export default SalesOrderProductTable;
@@ -0,0 +1,235 @@
import Button from '@/components/Button';
import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path';
import { date } from 'yup';
interface DeliveryOrderExportProps {
data?: Marketing;
deliveryOrder: BaseDeliveryOrder;
className?: string;
}
const DeliveryOrderExport = ({
data,
deliveryOrder,
}: DeliveryOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data;
const handleDownloadPDF = async () => {
if (!salesData) {
alert('No sales order data available');
return;
}
setIsGeneratingPDF(true);
try {
const blob = await pdf(
<PDFDocument data={salesData} deliveryOrder={deliveryOrder} />
).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${deliveryOrder?.do_number || 'delivery-order'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
};
if (!salesData) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'>No sales order data available</div>
</div>
);
}
return salesData?.so_number && salesData.so_number !== 'Belum dibuat' ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
onClick={handleDownloadPDF}
isLoading={isGeneratingPDF}
>
<Icon icon='material-symbols:file-open-outline' width={16} height={16} />
{deliveryOrder.do_number}
</Button>
) : null;
};
export default DeliveryOrderExport;
const PDFDocument = ({
data,
deliveryOrder,
}: {
data: Marketing;
deliveryOrder: BaseDeliveryOrder;
}) => {
const grandTotal = useMemo(() => {
return (
deliveryOrder.deliveries?.reduce((a, b) => a + b.total_price, 0) ?? 0
);
}, []);
return (
<Document>
<Page size='A4' style={pdfStyles.page}>
{/* Header Section */}
<View style={pdfStyles.header}>
<Image
src={'https://placehold.co/120x30/png'}
style={pdfStyles.logo}
id={'mbu-logo'}
/>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
{/* Delivery Order Title */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.title}>DELIVERY ORDER</Text>
<View style={pdfStyles.poInfo}>
<Text>{deliveryOrder.do_number || '-'}</Text>
</View>
</View>
{/* Depature Table */}
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Ship To</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Depature From</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text style={{ fontWeight: 'bold' }}>
{data?.customer?.name || '-'} ({data?.customer?.type || '-'})
</Text>
<Text style={{ marginTop: '2px' }}>
{data?.customer.email || ''} - {data?.customer.phone || ''}
</Text>
<Text></Text>
<Text>{data?.customer.address || ''}</Text>
<Text style={{ fontSize: '7px', marginTop: '3px' }}></Text>
</View>
<View style={pdfStyles.tableCellLast}>
<Text style={{ fontWeight: 'bold' }}>
{deliveryOrder.warehouse?.name || '-'}
</Text>
<Text style={{ marginTop: '2px' }}>
{formatDate(deliveryOrder.delivery_date, 'DD MMM YYYY')}
</Text>
<Text>{deliveryOrder.warehouse?.area?.name}</Text>
</View>
</View>
</View>
{/* Delivery Table */}
<Text style={pdfStyles.sectionTitle}>Product Shipped</Text>
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Item Description</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Vehicle Number</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Unit Price</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Quantity</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Total Amount</Text>
</View>
</View>
{deliveryOrder.deliveries?.map((item, index) => {
return (
<View key={index} style={[pdfStyles.tableRow]}>
<View style={pdfStyles.tableCell}>
<Text>{item.product_warehouse?.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>
{formatVechicleNumber(item.vehicle_number) || '-'}
</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>Rp{formatNumber(item.unit_price || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>{formatNumber(item.qty || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRightLast}>
<Text>Rp{formatNumber(item.total_price || 0)}</Text>
</View>
</View>
);
}) || []}
{/* Grand Total Row inside table */}
<View style={pdfStyles.grandTotalRow}>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text style={{ fontWeight: 'bold' }}>Grand Total</Text>
</View>
<View
style={[pdfStyles.tableCellRightLast, { fontWeight: 'bold' }]}
>
<Text>Rp{formatNumber(grandTotal)}</Text>
</View>
</View>
</View>
{/* Footer with Special Instructions */}
<View style={pdfStyles.footer}>
<View style={pdfStyles.specialInstructionTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>From Sales Order</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCellLast}>
<Text>
{data?.so_number || '-'} -{' '}
{formatDate(data.so_date, 'DD MMM YYYY')}
</Text>
</View>
</View>
</View>
<View style={pdfStyles.footerCompany}>
<Text>PT LUMBUNG TELUR INDONESIA</Text>
</View>
</View>
</Page>
</Document>
);
};
@@ -0,0 +1,227 @@
import Button from '@/components/Button';
import { Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber } from '@/lib/helper';
interface SalesOrderExportProps {
data?: Marketing;
className?: string;
}
const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data;
const handleDownloadPDF = async () => {
if (!salesData) {
alert('No sales order data available');
return;
}
setIsGeneratingPDF(true);
try {
const blob = await pdf(<PDFDocument data={salesData} />).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${salesData?.so_number || 'sales-order'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
} finally {
setIsGeneratingPDF(false);
}
};
if (!salesData) {
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='text-gray-500'>No sales order data available</div>
</div>
);
}
return salesData?.so_number && salesData.so_number !== 'Belum dibuat' ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm font-mono'
onClick={handleDownloadPDF}
isLoading={isGeneratingPDF}
>
<Icon icon='material-symbols:file-open-outline' width={16} height={16} />
{salesData.so_number}
</Button>
) : null;
};
export default SalesOrderExport;
const PDFDocument = ({ data }: { data: Marketing }) => {
const grandTotal = useMemo(() => {
return data?.sales_order?.reduce((a, b) => a + b.total_price, 0) ?? 0;
}, [data?.sales_order]);
return (
<Document>
<Page size='A4' style={pdfStyles.page}>
{/* Header Section */}
<View style={pdfStyles.header}>
<Image
src={'https://placehold.co/120x30/png'}
style={pdfStyles.logo}
id={'mbu-logo'}
/>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
{/* Sales Order Title */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.title}>SALES ORDER</Text>
<View style={pdfStyles.poInfo}>
<Text>SO Number: {data?.so_number || '-'}</Text>
<Text>
Date:{' '}
{data?.so_date
? formatDate(data.so_date, 'DD MMM YYYY')
: formatDate(new Date(), 'DD MMM YYYY')}
</Text>
</View>
</View>
{/* Customer Table */}
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Customer</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Sales</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCell}>
<Text style={{ fontWeight: 'bold' }}>
{data?.customer?.name || '-'} ({data?.customer?.type || '-'})
</Text>
<Text style={{ marginTop: '2px' }}>
{data?.customer.email || ''} - {data?.customer.phone || ''}
</Text>
<Text></Text>
<Text>{data?.customer.address || ''}</Text>
<Text style={{ fontSize: '7px', marginTop: '3px' }}></Text>
</View>
<View style={pdfStyles.tableCellLast}>
<Text style={{ fontWeight: 'bold' }}>
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={{ fontWeight: 'bold', marginTop: '2px' }}>
{data?.sales_person?.name || '-'}
</Text>
<Text>{data?.sales_person.email}</Text>
</View>
</View>
</View>
{/* Product Sales Order Table */}
<Text style={pdfStyles.sectionTitle}>Product Sold</Text>
<View style={pdfStyles.table}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeader}>
<Text>Item Description</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>From</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Unit Price</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Quantity</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Total Amount</Text>
</View>
</View>
{data?.sales_order?.map((item, index) => {
const isLastItem = index === (data?.sales_order?.length || 0) - 1;
return (
<View
key={index}
style={[
pdfStyles.tableRow,
// isLastItem ? {} : pdfStyles.tableBorderBottom,
]}
>
<View style={pdfStyles.tableCell}>
<Text>{item.product_warehouse?.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.product_warehouse?.warehouse?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>Rp{formatNumber(item.unit_price || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text>{formatNumber(item.qty || 0)}</Text>
</View>
<View style={pdfStyles.tableCellRightLast}>
<Text>Rp{formatNumber(item.total_price || 0)}</Text>
</View>
</View>
);
}) || []}
{/* Grand Total Row inside table */}
<View style={pdfStyles.grandTotalRow}>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { borderRightWidth: 0 }]}>
<Text></Text>
</View>
<View style={pdfStyles.tableCellRight}>
<Text style={{ fontWeight: 'bold' }}>Grand Total</Text>
</View>
<View
style={[pdfStyles.tableCellRightLast, { fontWeight: 'bold' }]}
>
<Text>Rp{formatNumber(grandTotal)}</Text>
</View>
</View>
</View>
{/* Footer with Special Instructions */}
<View style={pdfStyles.footer}>
<View style={pdfStyles.specialInstructionTable}>
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Notes</Text>
</View>
</View>
<View style={pdfStyles.tableRow}>
<View style={pdfStyles.tableCellLast}>
<Text>{data?.notes || '-'}</Text>
</View>
</View>
</View>
<View style={pdfStyles.footerCompany}>
<Text>PT LUMBUNG TELUR INDONESIA</Text>
</View>
</View>
</Page>
</Document>
);
};
@@ -0,0 +1,212 @@
import { StyleSheet } from '@react-pdf/renderer';
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
header: {
marginBottom: 20,
},
logo: {
width: 120,
height: 30,
marginBottom: 8,
},
companyInfo: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
color: '#1f74bf',
},
address: {
fontSize: 8,
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: 9,
textAlign: 'right',
},
sectionTitle: {
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: 8,
fontSize: 9,
},
tableCellLast: {
flex: 1,
padding: 8,
fontSize: 9,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellHeaderLast: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableCellRightLast: {
flex: 1,
padding: 8,
fontSize: 9,
textAlign: 'right',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
grandTotalRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: '#000000',
borderTopStyle: 'solid',
},
grandTotalLabel: {
flex: 3,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
},
grandTotalValue: {
flex: 1,
padding: 8,
fontSize: 9,
fontWeight: 'bold',
textAlign: 'right',
borderRightWidth: 0,
},
allocationSection: {
marginBottom: 15,
},
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: 8,
fontSize: 9,
},
innerCellLast: {
flex: 1,
padding: 8,
fontSize: 9,
},
innerCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 8,
fontSize: 9,
textAlign: 'right',
},
innerCellRightLast: {
flex: 1,
padding: 8,
fontSize: 9,
textAlign: 'right',
},
footer: {
marginTop: 30,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
footerCompany: {
fontSize: 12,
fontWeight: 'bold',
textAlign: 'right',
flex: 1,
color: '#1f74bf',
},
specialInstructionTable: {
width: '60%',
maxWidth: 300,
borderWidth: 1,
borderColor: '#000000',
flex: 1,
},
});
export default pdfStyles;
@@ -1,308 +0,0 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import Table from '@/components/Table';
import {
cn,
formatCurrency,
formatNumber,
formatVechicleNumber,
} from '@/lib/helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useState } from 'react';
import toast from 'react-hot-toast';
const SalesOrderDetail = ({
initialValues,
refreshValues,
}: {
initialValues?: Marketing;
refreshValues?: () => void;
}) => {
const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>(
'approve'
);
const [isLoading, setIsLoading] = useState(false);
const deleteModal = useModal();
const confirmationModal = useModal();
const deliveryModal = useModal();
const approveClickHandler = () => {
setApprovalAction('approve');
confirmationModal.openModal();
};
const rejectClickHandler = () => {
setApprovalAction('reject');
confirmationModal.openModal();
};
const deliveryClickHandler = () => {
deliveryModal.openModal();
};
const deleteClickHandler = () => {
deleteModal.openModal();
};
const confirmationModalDeleteClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.delete(initialValues?.id as number);
setIsLoading(false);
deleteModal.closeModal();
toast.success('Successfully deleted Sales Order!');
refreshValues?.();
};
const confirmationModalApproveClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.singleApproval(
// initialValues?.id as number,
// approvalAction
// );
setIsLoading(false);
confirmationModal.closeModal();
toast.success('Successfully approved Sales Order!');
refreshValues?.();
};
const confirmationModalDeliveryClickHandler = async () => {
setIsLoading(true);
// await MarketingApi.delivery(initialValues?.id as number);
setIsLoading(false);
deliveryModal.closeModal();
toast.success('Successfully delivered Sales Order!');
refreshValues?.();
};
return (
<>
<div className='flex flex-col w-full gap-4'>
<FormHeader
title='Detail Sales Order'
backUrl='/marketing/sales-orders'
/>
<div className='flex-row flex gap-3'>
{initialValues?.approval?.step_number != 3 && (
<>
<Button
color='success'
onClick={approveClickHandler}
disabled={initialValues?.approval?.step_number != 1}
>
<Icon icon='mdi:check' width={24} height={24} />
Approve
</Button>
<Button
color='error'
onClick={rejectClickHandler}
disabled={initialValues?.approval?.step_number != 2}
>
<Icon icon='mdi:close' width={24} height={24} />
Reject
</Button>
</>
)}
{initialValues?.approval?.step_number == 2 && (
<Button color='success' onClick={deliveryClickHandler}>
<Icon icon='mdi:check' width={24} height={24} />
Delivery Order
</Button>
)}
</div>
<Card
title='Informasi Sales Order'
className={{
wrapper: 'w-full bg-white',
}}
>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td width='45%' className='font-semibold'>
No. Sales Order
</td>
<td>:</td>
<td width='50%'>{initialValues?.so_number}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Pelanggan</td>
<td>:</td>
<td>{initialValues?.customer?.name}</td>
</tr>
<tr>
<td className='font-semibold'>Status</td>
<td>:</td>
<td>{initialValues?.approval?.step_name}</td>
</tr>
<tr>
<td className='font-semibold'>Tanggal Penjualan</td>
<td>:</td>
<td>{initialValues?.so_date}</td>
</tr>
<tr>
<td className='font-semibold'>Total Penjualan</td>
<td>:</td>
<td>
{formatCurrency(initialValues?.grand_total as number)}
</td>
</tr>
<tr>
<td className='font-semibold'>Catatan</td>
<td>:</td>
<td>{initialValues?.notes ?? '-'}</td>
</tr>
</tbody>
</table>
</div>
</Card>
{initialValues?.marketing_products && (
<Card
title='Daftar Produk'
className={{
wrapper: 'w-full bg-white',
}}
>
<Table<MarketingProduct>
data={initialValues?.marketing_products}
columns={[
{
header: 'No. Polisi',
accessorFn(row) {
return formatVechicleNumber(
row.marketing_delivery_products?.vehicle_number as string
);
},
},
{
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
},
{
header: 'Produk',
accessorFn(row) {
return row.product_warehouse.product.name;
},
},
{
header: 'Harga Satuan (Rp)',
accessorFn(row) {
return formatCurrency(row.unit_price);
},
},
{
header: 'Total Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.total_weight);
},
},
{
header: 'Kuantitas',
accessorFn(row) {
return formatNumber(row.qty);
},
},
{
header: 'Avg. Bobot (Kg)',
accessorFn(row) {
return formatNumber(row.avg_weight);
},
},
{
header: 'Total Penjualan (Rp)',
accessorFn(row) {
return formatCurrency(row.total_price);
},
},
]}
className={{
containerClassName: cn({
'mb-20':
initialValues?.marketing_products &&
initialValues?.marketing_products?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
</Card>
)}
<div className='flex flex-row gap-3'>
<Button
color='warning'
type='button'
href={`/marketing/sales-orders/detail/edit?salesOrderId=${initialValues?.id}`}
>
<Icon icon='mdi:pencil' width={24} height={24} />
Edit
</Button>
<Button color='error' onClick={deleteClickHandler}>
<Icon icon='mdi:delete' width={24} height={24} />
Hapus
</Button>
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
<ConfirmationModal
ref={confirmationModal.ref}
type={approvalAction === 'approve' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approvalAction} data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: approvalAction === 'approve' ? 'success' : 'error',
isLoading: isLoading,
onClick: confirmationModalApproveClickHandler,
}}
/>
<ConfirmationModal
ref={deliveryModal.ref}
type={'success'}
text={`Apakah anda yakin ingin deliver penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isLoading,
onClick: confirmationModalDeliveryClickHandler,
}}
/>
</>
);
};
export default SalesOrderDetail;
@@ -1,38 +0,0 @@
import * as Yup from 'yup';
import { MarketingProduct } from '@/types/api/marketing/marketing';
import {
MarketingProductFormValues,
MarketingProductSchema,
} from './repeater/MarketingProduct.schema';
type MarketingSchema = {
customer_id: number | undefined;
customer:
| {
value: number;
label: string;
}
| undefined
| null;
so_date: string | undefined;
notes: string | undefined;
marketing_products: MarketingProductFormValues[];
};
export const MarketingSchema: Yup.ObjectSchema<MarketingSchema> = Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'),
customer: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
so_date: Yup.string().required('Tanggal wajib diisi!'),
notes: Yup.string().required('Catatan wajib diisi!'),
marketing_products: Yup.array()
.of(MarketingProductSchema)
.min(1, 'Minimal harus ada 1 produk!')
.required('Produk wajib diisi!'),
});
export const UpdateMarketingSchema = MarketingSchema;
export type MarketingFormValues = Yup.InferType<typeof MarketingSchema>;
@@ -1,514 +0,0 @@
'use client';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import * as TanStack from '@tanstack/react-table';
import Table from '@/components/Table'; // Keep this import
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import {
CreateMarketingPayload,
CreateMarketingProductPayload,
Marketing,
MarketingProduct,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import MarketingProductForm from './repeater/MarketingProductForm';
import CheckboxInput from '@/components/input/CheckboxInput';
import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data';
import { useFormik } from 'formik';
import { MarketingFormValues, MarketingSchema } from './SalesForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { MarketingProductFormValues } from './repeater/MarketingProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
const SalesForm = ({
formType = 'add',
initialValues,
}: {
formType?: 'add' | 'edit';
initialValues?: Marketing;
}) => {
const router = useRouter();
const addProductModal = useModal();
const deleteModal = useModal();
const [isLoading, setIsLoading] = useState(false);
const [selectedMarketingProduct, setSelectedMarketingProduct] =
useState<MarketingProduct | null>(null);
const [rawMarketingProducts, setRawMarketingProducts] = useState<
MarketingProduct[]
>(initialValues?.marketing_products || []);
const [selectedCustomer, setSelectedCustomer] = useState<OptionType | null>(
initialValues?.customer
? { value: initialValues.customer.id, label: initialValues.customer.name }
: null
);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
);
const [grandTotal, setGrandTotal] = useState<number>(
initialValues?.grand_total ?? 0
);
const marketingProducts = useMemo(
() => rawMarketingProducts,
[rawMarketingProducts]
);
const {
options: customerOptions,
rawData: customerRawData,
isLoadingOptions: isLoadingCustomerOptions,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const handleAddProduct = useCallback(() => {
addProductModal.openModal();
}, [addProductModal]);
const handleDeleteProduct = useCallback((id: number) => {
setRawMarketingProducts((prev) => prev.filter((p) => p.id !== id));
}, []);
const handleBulkDeleteProduct = () => {
setRawMarketingProducts((prev) =>
prev.filter((product) => !selectedRowIds.includes(product.id))
);
};
const handleDelete = () => {
deleteModal.openModal();
};
const handleAddSubmitProduct = useCallback(
async (
tableValue: CreateMarketingProductPayload,
fieldValues: MarketingProductFormValues
) => {
const newMarketingProduct: MarketingProduct = {
id: rawMarketingProducts.length + 1,
product_warehouse: tableValue.product_warehouse!,
unit_price: tableValue.unit_price as number,
total_weight: tableValue.total_weight as number,
qty: tableValue.qty as number,
avg_weight: tableValue.avg_weight as number,
total_price: tableValue.total_price as number,
marketing_delivery_products: {
id: rawMarketingProducts.length + 1,
vehicle_number: tableValue.vehicle_number as string,
delivery_date: tableValue.delivery_date as string,
unit_price: tableValue.unit_price as number,
total_weight: tableValue.total_weight as number,
qty: tableValue.qty as number,
avg_weight: tableValue.avg_weight as number,
total_price: tableValue.total_price as number,
},
};
setRawMarketingProducts((prev) => [...prev, newMarketingProduct]);
formik.setValues({
...formik.values,
marketing_products: [...formik.values.marketing_products, fieldValues],
});
setGrandTotal((prev) => prev + (tableValue.total_price as number));
addProductModal.closeModal();
},
[rawMarketingProducts.length, addProductModal]
);
const handleChangeCustomer = useCallback(
(val: OptionType | OptionType[] | null) => {
setSelectedCustomer(val as OptionType);
formik.setFieldValue('customer_id', (val as OptionType)?.value);
formik.setFieldValue('customer', val as OptionType);
},
[selectedCustomer, setSelectedCustomer]
);
const createMarketingHandler = async (values: CreateMarketingPayload) => {
console.log(values);
const createMarketingRes = await MarketingApi.create(values);
if (isResponseSuccess(createMarketingRes)) {
console.log(createMarketingRes);
}
if (isResponseError(createMarketingRes)) {
console.log(createMarketingRes);
}
toast.success('Successfully created Sales Order!');
router.push('/marketing/sales-orders');
};
const updateMarketingHandler = async (values: CreateMarketingPayload) => {
console.log(values);
const createMarketingRes = await MarketingApi.update(
initialValues?.id as number,
values
);
if (isResponseSuccess(createMarketingRes)) {
console.log(createMarketingRes);
}
if (isResponseError(createMarketingRes)) {
console.log(createMarketingRes);
}
toast.success('Successfully updated Sales Order!');
router.push('/marketing/sales-orders');
};
const deleteMarketingHandler = async () => {
setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number
);
if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
}
if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
}
toast.success('Successfully deleted Sales Order!');
setIsLoading(false);
deleteModal.closeModal();
router.push('/marketing/sales-orders');
};
const MarketingProductToFieldValues = (
product: MarketingProduct
): MarketingProductFormValues => {
return {
vehicle_number: product.marketing_delivery_products?.vehicle_number,
kandang_id: product.product_warehouse.warehouse.id,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.product.id,
label: product.product_warehouse.product.name,
},
product_warehouse_id: product.product_warehouse.product.id,
unit_price: product.unit_price,
total_weight: product.total_weight,
qty: product.qty,
uom: product.product_warehouse?.product?.uom?.name,
avg_weight: product.avg_weight,
total_price: product.total_price,
delivery_date: product.marketing_delivery_products?.delivery_date,
};
};
const formik = useFormik<MarketingFormValues>({
enableReinitialize: true,
initialValues: {
so_date: initialValues?.so_date || undefined,
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
customer: {
value: initialValues?.customer?.id as number,
label: initialValues?.customer?.name as string,
},
marketing_products:
initialValues?.marketing_products?.map((product) =>
MarketingProductToFieldValues(product)
) ?? [],
},
validationSchema: MarketingSchema,
onSubmit: async (values) => {
const payload = {
customer_id: values.customer_id as number,
date: values.so_date as string,
notes: values.notes as string,
marketing_products: values.marketing_products,
} as CreateMarketingPayload;
switch (formType) {
case 'add':
createMarketingHandler(payload);
break;
case 'edit':
updateMarketingHandler(payload);
break;
default:
break;
}
},
});
const { setValues: formikSetValues } = formik;
useEffect(() => {
formikSetValues(formik.initialValues);
}, [formikSetValues, formik.initialValues]);
const columns = useMemo(
() => [
{
id: 'select',
header: ({ table }: { table: TanStack.Table<MarketingProduct> }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }: { row: TanStack.Row<MarketingProduct> }) => (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
),
},
{
accessorFn: (row: MarketingProduct) =>
row.marketing_delivery_products?.vehicle_number,
header: 'No. Polisi',
},
{
accessorFn: (row: MarketingProduct) =>
row.product_warehouse.warehouse.name,
header: 'Kandang',
},
{
accessorFn: (row: MarketingProduct) =>
row.product_warehouse.product.name,
header: 'Produk',
},
{
accessorFn: (row: MarketingProduct) => formatCurrency(row.unit_price),
header: 'Harga Satuan (Rp)',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.total_weight),
header: 'Total Bobot (Kg)',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.qty),
header: 'Kuantitas',
},
{
accessorFn: (row: MarketingProduct) => formatNumber(row.avg_weight),
header: 'Avg. Bobot (Kg)',
},
{
accessorFn: (row: MarketingProduct) => formatCurrency(row.total_price),
header: 'Total Penjualan (Rp)',
},
{
header: 'Aksi',
cell: (props: TanStack.CellContext<MarketingProduct, unknown>) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<Button
color='error'
className='p-1'
onClick={() => handleDeleteProduct(props.row.original.id)}
>
<Icon icon='mdi:trash' width={16} height={16} />
</Button>
</div>
),
},
],
[handleDeleteProduct] // dependensi tunggal
);
return (
<>
<form
className='flex flex-col gap-4'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
<FormHeader
title={`${formType === 'add' ? 'Tambah' : 'Edit'} Sales Order`}
backUrl='/marketing/sales-orders'
/>
<Card
title='Informasi Order'
className={{
wrapper: 'bg-white w-full',
}}
>
<div className='grid grid-cols-2 gap-3 mt-3'>
<SelectInput
label='Pelanggan'
options={customerOptions}
isLoading={isLoadingCustomerOptions}
value={selectedCustomer}
onChange={handleChangeCustomer}
isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id)
}
errorMessage={formik.errors.customer_id}
isClearable
placeholder='Pilih Pelanggan'
/>
<DateInput
name='so_date'
label='Tanggal'
value={formik.values.so_date}
onChange={formik.handleChange}
isError={formik.touched.so_date && Boolean(formik.errors.so_date)}
errorMessage={formik.errors.so_date}
placeholder='Pilih Tanggal'
/>
</div>
</Card>
<Card
title='Daftar Produk'
className={{
wrapper: 'bg-white w-full',
}}
>
<Table<MarketingProduct>
rowSelection={rowSelection}
setRowSelection={setRowSelection}
data={marketingProducts}
columns={columns}
className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-2 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-2 py-2 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start',
paginationClassName: 'hidden',
}}
emptyContent={
<div
className={cn(
'w-full h-16 flex flex-col justify-center items-center gap-2'
)}
>
<span className='text-gray-500'>Belum ada data penjualan</span>
</div>
}
/>
<div className='flex flex-row gap-3 mt-3'>
<Button
type='button'
variant='outline'
className='justify-start w-fit py-1 text-sm'
onClick={handleAddProduct}
>
<Icon icon='mdi:plus' width={16} height={16} />
Tambah Produk
</Button>
{selectedRowIds.length > 0 && (
<Button
type='button'
variant='outline'
color='error'
className='justify-start w-fit py-1 text-sm'
onClick={handleBulkDeleteProduct}
>
<Icon icon='mdi:trash' width={16} height={16} />
Hapus
{selectedRowIds.length > 0
? ` (${selectedRowIds.length})`
: ''}{' '}
Produk
</Button>
)}
</div>
</Card>
<div className='grid grid-cols-2 gap-3'>
<TextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
/>
<div className='flex flex-col h-full justify-between items-end py-6'>
<span>Total Penjualan</span>
<span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}
</span>
</div>
</div>
<div className='flex flex-row items-start justify-center gap-2 mt-4'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
</Button>
<Button
type='submit'
disabled={!formik.isValid || formik.isSubmitting}
>
Submit
</Button>
</div>
</form>
{formType == 'edit' && (
<div className='flex flex-row justify-start'>
<Button type='button' color='error' onClick={handleDelete}>
<Icon icon='mdi:trash' width={24} height={24} />
Hapus
</Button>
</div>
)}
<Modal
ref={addProductModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-4/5 z-100',
}}
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center justify-between'>
<h3 className='text-lg font-semibold mb-4'>Tambah Produk</h3>
<Button
variant='ghost'
color='error'
className='rounded-full'
onClick={addProductModal.closeModal}
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div>
<MarketingProductForm
onSubmitForm={handleAddSubmitProduct}
modalRef={addProductModal.ref}
data={rawMarketingProducts}
initialValues={selectedMarketingProduct ?? undefined}
/>
</div>
</div>
</Modal>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data penjualan ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
onClick: deleteMarketingHandler,
}}
/>
</>
);
};
export default SalesForm;
@@ -1,6 +1,28 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const KandangFormSchema = Yup.object({ type KandangFormSchemaType = {
name: string;
locationId: number | undefined;
location:
| {
value: number;
label: string;
}
| undefined
| null;
capacity: number | undefined;
picId: number | undefined;
pic:
| {
value: number;
label: string;
}
| undefined
| null;
};
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
locationId: Yup.number() locationId: Yup.number()
@@ -20,7 +42,7 @@ export const KandangFormSchema = Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
}); });
export const UpdateKandangFormSchema = KandangFormSchema; export const UpdateKandangFormSchema = KandangFormSchema;
@@ -82,7 +82,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
capacity: initialValues?.capacity ?? 0, capacity: initialValues?.capacity,
picId: initialValues?.pic?.id ?? 0, picId: initialValues?.pic?.id ?? 0,
pic: initialValues?.pic pic: initialValues?.pic
? { ? {
@@ -102,9 +102,9 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
const kandangPayload: CreateKandangPayload = { const kandangPayload: CreateKandangPayload = {
name: values.name, name: values.name,
location_id: values.locationId, location_id: values.locationId!,
capacity: values.capacity, capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
pic_id: values.picId, pic_id: values.picId!,
}; };
switch (type) { switch (type) {
@@ -256,7 +256,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
required required
name='capacity' name='capacity'
label='Kapasitas' label='Kapasitas'
value={formik.values.capacity ?? undefined} placeholder='Masukan kapasitas kandang'
value={formik.values.capacity}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={ isError={
@@ -1,11 +1,17 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProductCategoryFormSchema = Yup.object({ type ProductCategoryFormSchemaType = {
code: string;
name: string;
};
export const ProductCategoryFormSchema: Yup.ObjectSchema<ProductCategoryFormSchemaType> =
Yup.object({
code: Yup.string() code: Yup.string()
.required('Kode wajib diisi!') .required('Kode wajib diisi!')
.max(3, 'Kode kategori produk melebihi 3 karakter!'), .max(3, 'Kode kategori produk melebihi 3 karakter!'),
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
}); });
export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema; export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
@@ -71,12 +71,13 @@ const ProductCategoryForm = ({
[router] [router]
); );
const formikInitialValues = useMemo<ProductCategoryFormValues>(() => { const formikInitialValues = useMemo<ProductCategoryFormValues>(
return { () => ({
code: initialValues?.code ?? '', code: initialValues?.code ?? '',
name: initialValues?.name ?? '', name: initialValues?.name ?? '',
}; }),
}, [initialValues]); [initialValues]
);
const formik = useFormik<ProductCategoryFormValues>({ const formik = useFormik<ProductCategoryFormValues>({
initialValues: formikInitialValues, initialValues: formikInitialValues,
@@ -118,7 +119,7 @@ const ProductCategoryForm = ({
await ProductCategoryApi.delete(initialValues?.id as number); await ProductCategoryApi.delete(initialValues?.id as number);
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Product Category!'); toast.success('Berhasil menghapus data Kategori Produk!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
router.push('/master-data/product-category'); router.push('/master-data/product-category');
}; };
@@ -129,7 +130,7 @@ const ProductCategoryForm = ({
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-2xl'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/master-data/product-category' href='/master-data/product-category'
@@ -141,9 +142,9 @@ const ProductCategoryForm = ({
</Button> </Button>
<h1 className='text-2xl font-bold text-center'> <h1 className='text-2xl font-bold text-center'>
{type === 'add' && 'Tambah Product Category'} {type === 'add' && 'Tambah Kategori Produk'}
{type === 'edit' && 'Edit Product Category'} {type === 'edit' && 'Edit Kategori Produk'}
{type === 'detail' && 'Detail Product Category'} {type === 'detail' && 'Detail Kategori Produk'}
</h1> </h1>
</header> </header>
@@ -157,7 +158,7 @@ const ProductCategoryForm = ({
required required
label='Kode' label='Kode'
name='code' name='code'
placeholder='Masukkan kode kategori produk' placeholder='Masukkan kode...'
value={formik.values.code} value={formik.values.code}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -169,7 +170,7 @@ const ProductCategoryForm = ({
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama kategori produk' placeholder='Masukkan nama...'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -256,7 +257,7 @@ const ProductCategoryForm = ({
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Product Category ini (${initialValues?.name})?`} text={`Apakah anda yakin ingin menghapus data Kategori Produk ini (${initialValues?.name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -1,53 +1,86 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProductFormSchema = Yup.object({ type ProductFormSchemaType = {
name: string;
brand: string;
sku: string;
uom?: {
value: number;
label: string;
} | null;
uom_id: number;
product_category?: {
value: number;
label: string;
} | null;
product_category_id: number;
product_price: number | string;
selling_price: number | string;
tax: number | string;
expiry_period: number | string;
supplier_ids: number[];
flags: string[];
};
export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'), brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string().required('SKU wajib diisi!'),
uom: Yup.object({ uom: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), })
.nullable()
.required('Satuan wajib diisi!'),
uom_id: Yup.number() uom_id: Yup.number()
.required('Satuan wajib diisi!') .required('Satuan wajib diisi!')
.typeError('Satuan wajib diisi!'), .typeError('Satuan wajib diisi!'),
product_category: Yup.object({ product_category: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), })
.nullable()
.required('Kategori produk wajib diisi!'),
product_category_id: Yup.number() product_category_id: Yup.number()
.required('Kategori produk wajib diisi!') .required('Kategori produk wajib diisi!')
.typeError('Kategori produk wajib diisi!'), .typeError('Kategori produk wajib diisi!'),
product_price: Yup.number() product_price: Yup.number()
.required('Harga produk wajib diisi!') .required('Harga produk wajib diisi!')
.typeError('Harga produk wajib diisi!') .typeError('Harga produk wajib diisi!')
.min(0, 'Harga produk tidak boleh kurang dari 0!'), .min(0, 'Harga produk tidak boleh kurang dari 0!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!') .typeError('Harga jual wajib diisi!')
.min(0, 'Harga jual tidak boleh kurang dari 0!'), .min(0, 'Harga jual tidak boleh kurang dari 0!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .required('Pajak wajib diisi!')
.typeError('Pajak wajib diisi!') .typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!') .min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'), .max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa wajib diisi!')
.min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'), .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
supplier_ids: Yup.array() supplier_ids: Yup.array()
.of(Yup.number().typeError('Supplier tidak valid!')) .of(Yup.number().required().typeError('Supplier tidak valid!'))
.min(1, 'Minimal harus ada 1 supplier!') .min(1, 'Minimal harus ada 1 supplier!')
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array() flags: Yup.array()
.of(Yup.string()) .of(Yup.string().required())
.min(1, 'Minimal harus ada 1 flag!') .min(1, 'Minimal harus ada 1 flag!')
.required('Flag wajib diisi!'), .required('Flag wajib diisi!'),
}); });
export const UpdateProductFormSchema = ProductFormSchema; export const UpdateProductFormSchema = ProductFormSchema;
@@ -9,7 +9,11 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import NumberInput from '@/components/input/NumberInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
@@ -79,20 +83,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: initialValues?.sku ?? '', sku: initialValues?.sku ?? '',
uom: initialValues?.uom uom: initialValues?.uom
? { value: initialValues.uom.id, label: initialValues.uom.name } ? { value: initialValues.uom.id, label: initialValues.uom.name }
: null, : undefined,
uom_id: initialValues?.uom?.id ?? 0, uom_id: initialValues?.uom?.id ?? 0,
product_category: initialValues?.product_category product_category: initialValues?.product_category
? { ? {
value: initialValues.product_category.id, value: initialValues.product_category.id,
label: initialValues.product_category.name, label: initialValues.product_category.name,
} }
: null, : undefined,
product_category_id: initialValues?.product_category?.id ?? 0, product_category_id: initialValues?.product_category?.id ?? 0,
product_price: initialValues?.product_price ?? 0, product_price: initialValues?.product_price ?? '',
selling_price: initialValues?.selling_price ?? 0, selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? 0, tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? 0, expiry_period: initialValues?.expiry_period ?? '',
supplier: null, // not used for payload, just for UI
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [],
flags: initialValues?.flags ?? [], flags: initialValues?.flags ?? [],
}), }),
@@ -111,16 +114,14 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku, sku: values.sku,
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: values.product_price, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: values.selling_price, selling_price: parseInt(values.selling_price.toString()) || 0,
tax: values.tax, tax: parseInt(values.tax.toString()) || 0,
expiry_period: values.expiry_period, expiry_period: parseInt(values.expiry_period.toString()) || 0,
supplier_ids: (values.supplier_ids ?? []).filter( supplier_ids: values.supplier_ids.filter(
(id): id is number => typeof id === 'number' (id): id is number => typeof id === 'number'
), ),
flags: (values.flags ?? []).filter( flags: values.flags.filter((f): f is string => typeof f === 'string'),
(f): f is string => typeof f === 'string'
),
}; };
switch (type) { switch (type) {
case 'add': case 'add':
@@ -136,15 +137,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// UOM // UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState(''); const {
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`; setInputValue: setUomSelectInputValue,
const { data: uoms, isLoading: isLoadingUoms } = useSWR( options: uomOptions,
uomsUrl, isLoadingOptions: isLoadingUoms,
UomApi.getAllFetcher } = useSelect(UomApi.basePath, 'id', 'name');
);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name }))
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
formik.setFieldValue('uom', val); formik.setFieldValue('uom', val);
@@ -153,15 +150,11 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
}; };
// Product Category // Product Category
const [categorySelectInputValue, setCategorySelectInputValue] = useState(''); const {
const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`; setInputValue: setCategorySelectInputValue,
const { data: categories, isLoading: isLoadingCategories } = useSWR( options: categoryOptions,
categoriesUrl, isLoadingOptions: isLoadingCategories,
ProductCategoryApi.getAllFetcher } = useSelect(ProductCategoryApi.basePath, 'id', 'name');
);
const categoryOptions = isResponseSuccess(categories)
? categories?.data.map((cat) => ({ value: cat.id, label: cat.name }))
: [];
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true); formik.setFieldTouched('product_category', true);
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
@@ -169,7 +162,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
formik.setFieldValue('product_category_id', (val as OptionType)?.value); formik.setFieldValue('product_category_id', (val as OptionType)?.value);
}; };
// Supplier (multi select) // Supplier (multi select) - using SWR to filter by category
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
@@ -209,7 +202,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
return ( return (
<> <>
<section className='w-full max-w-xl'> <section className='w-full max-w-2xl'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/master-data/product' href='/master-data/product'
@@ -235,7 +228,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama produk' placeholder='Masukkan nama...'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -247,7 +240,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='Merek' label='Merek'
name='brand' name='brand'
placeholder='Masukkan merek produk' placeholder='Masukkan merek...'
value={formik.values.brand} value={formik.values.brand}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -259,7 +252,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
required required
label='SKU' label='SKU'
name='sku' name='sku'
placeholder='Masukkan SKU produk' placeholder='Masukkan SKU...'
value={formik.values.sku} value={formik.values.sku}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -270,6 +263,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Satuan' label='Satuan'
placeholder='Pilih satuan...'
value={formik.values.uom ?? undefined} value={formik.values.uom ?? undefined}
onChange={uomChangeHandler} onChange={uomChangeHandler}
options={uomOptions} options={uomOptions}
@@ -283,6 +277,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Kategori Produk' label='Kategori Produk'
placeholder='Pilih kategori produk...'
value={formik.values.product_category ?? undefined} value={formik.values.product_category ?? undefined}
onChange={categoryChangeHandler} onChange={categoryChangeHandler}
options={categoryOptions} options={categoryOptions}
@@ -296,15 +291,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
<TextInput <NumberInput
required required
label='Harga Produk' label='Harga Produk'
name='product_price' name='product_price'
type='number' placeholder='Masukkan harga produk...'
placeholder='Masukkan harga produk'
value={formik.values.product_price} value={formik.values.product_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={ isError={
formik.touched.product_price && formik.touched.product_price &&
Boolean(formik.errors.product_price) Boolean(formik.errors.product_price)
@@ -312,15 +311,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.product_price as string} errorMessage={formik.errors.product_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Harga Jual' label='Harga Jual'
name='selling_price' name='selling_price'
type='number' placeholder='Masukkan harga jual...'
placeholder='Masukkan harga jual'
value={formik.values.selling_price} value={formik.values.selling_price}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={ isError={
formik.touched.selling_price && formik.touched.selling_price &&
Boolean(formik.errors.selling_price) Boolean(formik.errors.selling_price)
@@ -328,28 +331,36 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
errorMessage={formik.errors.selling_price as string} errorMessage={formik.errors.selling_price as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Pajak (%)' label='Pajak (%)'
name='tax' name='tax'
type='number' placeholder='Masukkan pajak...'
placeholder='Masukkan pajak'
value={formik.values.tax} value={formik.values.tax}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='%'
isError={formik.touched.tax && Boolean(formik.errors.tax)} isError={formik.touched.tax && Boolean(formik.errors.tax)}
errorMessage={formik.errors.tax as string} errorMessage={formik.errors.tax as string}
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <NumberInput
required required
label='Periode Kadaluarsa (hari)' label='Periode Kadaluarsa (hari)'
name='expiry_period' name='expiry_period'
type='number' placeholder='Masukkan periode kadaluarsa...'
placeholder='Masukkan periode kadaluarsa'
value={formik.values.expiry_period} value={formik.values.expiry_period}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputSuffix='hari'
isError={ isError={
formik.touched.expiry_period && formik.touched.expiry_period &&
Boolean(formik.errors.expiry_period) Boolean(formik.errors.expiry_period)
@@ -360,9 +371,10 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Supplier' label='Supplier'
placeholder='Pilih supplier...'
isMulti isMulti
value={supplierOptions.filter((opt) => value={supplierOptions.filter((opt) =>
formik.values.supplier_ids.includes(opt.value) (formik.values.supplier_ids || []).includes(opt.value)
)} )}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions} options={supplierOptions}
@@ -379,9 +391,10 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
<SelectInput <SelectInput
required required
label='Flags' label='Flags'
placeholder='Pilih flags...'
isMulti isMulti
value={PRODUCT_FLAG_OPTIONS.filter((opt) => value={PRODUCT_FLAG_OPTIONS.filter((opt) =>
formik.values.flags.includes(opt.value) (formik.values.flags || []).includes(opt.value)
)} )}
onChange={(val) => { onChange={(val) => {
const arr = Array.isArray(val) ? val : val ? [val] : []; const arr = Array.isArray(val) ? val : val ? [val] : [];
@@ -20,7 +20,6 @@ import { Icon } from '@iconify/react';
import { CellContext, SortingState } from '@tanstack/react-table'; import { CellContext, SortingState } from '@tanstack/react-table';
import { useState } from 'react'; import { useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import ChickinForm from './form/ChickinForm';
const ChickinTable = () => { const ChickinTable = () => {
const { const {
@@ -45,7 +45,7 @@ const ChickinFormKandang = ({
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<FormHeader <FormHeader
title='Chick In DOC' title={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`} backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`}
/> />
@@ -3,6 +3,7 @@ import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -31,12 +32,13 @@ const ChickinLogsView = ({
confirmModal.openModal(); confirmModal.openModal();
}; };
const confirmationModalApproveClickHandler = async () => { const confirmationModalApproveClickHandler = async (notes?: string) => {
setChickinErrorMessage(''); setChickinErrorMessage('');
setIsApproveLoading(true); setIsApproveLoading(true);
const approveChickinRes = await ChickinApi.singleApproval( const approveChickinRes = await ChickinApi.singleApproval(
initialValues?.id as number, initialValues?.id as number,
'APPROVED' 'APPROVED',
notes
); );
if (isResponseSuccess(approveChickinRes)) { if (isResponseSuccess(approveChickinRes)) {
toast.success(approveChickinRes?.message as string); toast.success(approveChickinRes?.message as string);
@@ -151,7 +153,7 @@ const ChickinLogsView = ({
</div> </div>
)} )}
</Card> </Card>
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type='success' type='success'
text={`Apakah anda yakin ingin approve data Chickin yang Pending?`} text={`Apakah anda yakin ingin approve data Chickin yang Pending?`}
@@ -161,7 +163,9 @@ const ChickinLogsView = ({
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'success', color: 'success',
onClick: confirmationModalApproveClickHandler, onClick: (notes) => {
confirmationModalApproveClickHandler(notes);
},
isLoading: isApproveLoading, isLoading: isApproveLoading,
}} }}
/> />
@@ -8,8 +8,6 @@ import {
ChickinSchema, ChickinSchema,
} from '../ChickinForm.schema'; } from '../ChickinForm.schema';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import NumberInput from '@/components/input/NumberInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -24,7 +22,6 @@ import Alert from '@/components/Alert';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
const ChickinFormView = ({ const ChickinFormView = ({
formType = 'add',
initialValues, initialValues,
afterSubmit, afterSubmit,
}: { }: {
@@ -122,7 +119,7 @@ const ChickinFormView = ({
return ( return (
<form <form
className='flex flex-col gap-4' className='flex flex-col gap-4'
onReset={(e) => { onReset={() => {
handleReset(); handleReset();
}} }}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
@@ -6,6 +6,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions'; import RowDropdownOptions from '@/components/table/RowDropdownOptions';
@@ -144,6 +145,9 @@ const ProjectFlockTable = () => {
useState<ProjectFlock>(); useState<ProjectFlock>();
const deleteModal = useModal(); const deleteModal = useModal();
const confirmModal = useModal(); const confirmModal = useModal();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED'
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
@@ -226,18 +230,21 @@ const ProjectFlockTable = () => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
const confirmationModalApproveClickHandler = async () => { const confirmApprovalHandler = async (
notes: string,
approvalAction: 'APPROVED' | 'REJECTED'
) => {
setIsApproveLoading(true); setIsApproveLoading(true);
const approveProjectFlockRes = await ProjectFlockApi.customRequest< const approveProjectFlockRes =
BaseApiResponse<ProjectFlock>, approvalAction === 'APPROVED'
ProjectFlockApprovalPayload ? await ProjectFlockApi.bulkApprove(
>(`/approvals`, { selectedRowIds.map((id) => id),
method: 'POST', notes
payload: { )
action: 'APPROVED', : await ProjectFlockApi.bulkReject(
approvable_ids: selectedRowIds.map((id) => id), selectedRowIds.map((id) => id),
}, notes
}); );
if (isResponseSuccess(approveProjectFlockRes)) { if (isResponseSuccess(approveProjectFlockRes)) {
toast.success('Project Flock berhasil di-approve!'); toast.success('Project Flock berhasil di-approve!');
@@ -271,6 +278,7 @@ const ProjectFlockTable = () => {
variant='outline' variant='outline'
color='success' color='success'
onClick={() => { onClick={() => {
setApprovalAction('APPROVED');
confirmModal.openModal(); confirmModal.openModal();
}} }}
disabled={selectedRowIds.length === 0} disabled={selectedRowIds.length === 0}
@@ -279,6 +287,19 @@ const ProjectFlockTable = () => {
<Icon icon='material-symbols:check' width={24} height={24} /> <Icon icon='material-symbols:check' width={24} height={24} />
Approve Approve
</Button> </Button>
<Button
variant='outline'
color='error'
onClick={() => {
setApprovalAction('REJECTED');
confirmModal.openModal();
}}
disabled={selectedRowIds.length === 0}
className='w-full sm:w-fit'
>
<Icon icon='mdi:times' width={24} height={24} />
Reject
</Button>
<div className='ms-auto w-full sm:w-auto'> <div className='ms-auto w-full sm:w-auto'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
@@ -558,7 +579,7 @@ const ProjectFlockTable = () => {
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${selectedProjectFlock?.flock?.name})?`} text={`Apakah anda yakin ingin menghapus data Project Flock ini (${selectedProjectFlock?.flock_name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -570,17 +591,19 @@ const ProjectFlockTable = () => {
}} }}
/> />
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type='success' type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin approve data Project Flock ini (${selectedRowIds.length} data)?`} text={`Apakah anda yakin ingin ${approvalAction == 'APPROVED' ? 'approve' : 'reject'} data Project Flock ini (${selectedRowIds.length} data)?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'success', color: approvalAction == 'APPROVED' ? 'success' : 'error',
onClick: confirmationModalApproveClickHandler, onClick: (notes) => {
confirmApprovalHandler(notes, approvalAction);
},
isLoading: isApproveLoading, isLoading: isApproveLoading,
}} }}
/> />
@@ -14,13 +14,13 @@ import { cn } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production'; import { ProjectFlockKandangApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { FormHeader } from '@/components/helper/form/FormHeader';
const ProjectFlockChickinDetail = ({ const ProjectFlockChickinDetail = ({
projectFlockId, projectFlockId,
@@ -42,10 +42,7 @@ const ProjectFlockChickinDetail = ({
const [projectFlock, setProjectFlock] = useState<ProjectFlock>(); const [projectFlock, setProjectFlock] = useState<ProjectFlock>();
// Fetch Data // Fetch Data
const { const { data: listProjectFlockKandang } = useSWR(
data: listProjectFlockKandang,
isLoading: isLoadingListProjectFlockKandang,
} = useSWR(
`${ProjectFlockKandangApi.basePath}?${new URLSearchParams({ `${ProjectFlockKandangApi.basePath}?${new URLSearchParams({
search: searchProjectFlock, search: searchProjectFlock,
project_flock_id: project_flock_id:
@@ -104,6 +101,10 @@ const ProjectFlockChickinDetail = ({
}, [projectFlockId, listProjectFlock]); }, [projectFlockId, listProjectFlock]);
return ( return (
<> <>
<FormHeader
title={`Chick In ${projectFlock?.flock_name ?? 'Project Flock'}`}
backUrl='/production/project-flock'
/>
<div className='flex flex-col gap-4 w-full my-4'> <div className='flex flex-col gap-4 w-full my-4'>
<div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'> <div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'>
<SelectInput <SelectInput
@@ -118,7 +119,7 @@ const ProjectFlockChickinDetail = ({
value={ value={
projectFlock projectFlock
? { ? {
label: `${projectFlock?.flock?.name}`, label: `${projectFlock?.flock_name}`,
value: projectFlock?.id, value: projectFlock?.id,
} }
: null : null
@@ -175,7 +176,7 @@ const ProjectFlockChickinDetail = ({
}, },
{ {
header: 'Nama Flock', header: 'Nama Flock',
accessorKey: 'flock.name', accessorKey: 'flock_name',
}, },
{ {
header: 'Kategori', header: 'Kategori',
@@ -209,10 +210,6 @@ const ProjectFlockChickinDetail = ({
); );
}, },
}, },
{
header: 'Periode',
accessorKey: 'period',
},
{ {
header: 'FCR Layer', header: 'FCR Layer',
accessorKey: 'fcr.name', accessorKey: 'fcr.name',
@@ -278,6 +275,10 @@ const ProjectFlockChickinDetail = ({
accessorKey: 'kandang.capacity', accessorKey: 'kandang.capacity',
header: 'Kapasitas', header: 'Kapasitas',
}, },
{
accessorFn: () => projectFlock?.period,
header: 'Periode',
},
{ {
accessorKey: 'approval.step_name', accessorKey: 'approval.step_name',
header: 'Status', header: 'Status',
@@ -42,11 +42,6 @@ export const ProjectFlockFormSchema = Yup.object({
.min(1, 'Lokasi wajib diisi!') .min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'), .required('Lokasi wajib diisi!'),
period: Yup.number()
.required('Periode wajib diisi!')
.typeError('Periode harus berupa angka')
.min(1, 'Minimal periode adalah 1'),
kandang_ids: Yup.array() kandang_ids: Yup.array()
.of(Yup.number().typeError('Kandang tidak valid!')) .of(Yup.number().typeError('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!') .min(1, 'Minimal harus ada 1 kandang!')
@@ -29,7 +29,6 @@ import {
ProjectFlock, ProjectFlock,
} from '@/types/api/production/project-flock'; } from '@/types/api/production/project-flock';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import TextInput from '@/components/input/TextInput';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import Collapse from '@/components/Collapse'; import Collapse from '@/components/Collapse';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
@@ -42,6 +41,7 @@ import ApprovalSteps, {
useApprovalSteps, useApprovalSteps,
} from '@/components/pages/ApprovalSteps'; } from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line'; import { PROJECT_FLOCK_APPROVAL_LINE } from '@/config/approval-line';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
interface ProjectFlockFormProps { interface ProjectFlockFormProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -72,8 +72,11 @@ const ProjectFlockForm = ({
const [optionsKandang, setOptionsKandang] = useState<Kandang[]>( const [optionsKandang, setOptionsKandang] = useState<Kandang[]>(
initialValues?.kandangs ?? [] initialValues?.kandangs ?? []
); );
const [selectedFlock, setSelectedFlock] = useState<number | undefined>( const [selectedFlock, setSelectedFlock] = useState<string | undefined>(
initialValues?.flock?.id ?? 0 initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
) ?? ''
); );
const deleteModal = useModal(); const deleteModal = useModal();
@@ -102,9 +105,13 @@ const ProjectFlockForm = ({
useEffect(() => { useEffect(() => {
if (initialValues?.approval?.step_name) { if (initialValues?.approval?.step_name) {
const approvedDisabled = initialValues.approval.step_name !== 'Pengajuan'; const pengajuanRejected =
initialValues.approval.step_number == 1 &&
initialValues.approval.action == 'REJECTED';
const approvedDisabled =
initialValues.approval.step_number !== 1 || pengajuanRejected;
setIsApprovedDisabled(approvedDisabled); setIsApprovedDisabled(approvedDisabled);
setIsRejectedDisabled(!approvedDisabled); setIsRejectedDisabled(!approvedDisabled || pengajuanRejected);
setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED'); setApprovalAction(!approvedDisabled ? 'APPROVED' : 'REJECTED');
} }
}, [initialValues]); }, [initialValues]);
@@ -143,15 +150,14 @@ const ProjectFlockForm = ({
mutate: refreshKandang, mutate: refreshKandang,
} = useSWR(kandangUrl, KandangApi.getAllFetcher); } = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, isLoading: isLoadingPeriodFlocks } = useSWR( const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
`${selectedFlock?.toString()}/periods`, `${selectedFlock?.toString()}/periods`,
(id: string) => ProjectFlockApi.getNextPeriod(id) () => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
); );
const { const {
approvals, approvals,
isLoading: approvalsLoading, isLoading: approvalsLoading,
rawDataApprovals: rawDataApprovals,
refresh: refreshApprovals, refresh: refreshApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.approval, latestApproval: initialValues?.approval,
@@ -182,6 +188,7 @@ const ProjectFlockForm = ({
formik.setFieldValue('kandang_ids', selectedRowIds); formik.setFieldValue('kandang_ids', selectedRowIds);
} }
} }
refreshPeriodFlocks();
} }
}, [kandang, selectedLocation]); }, [kandang, selectedLocation]);
useEffect(() => { useEffect(() => {
@@ -278,13 +285,24 @@ const ProjectFlockForm = ({
// Formik InitialValue // Formik InitialValue
const formikInitialValues = useMemo<ProjectFlockFormValues>(() => { const formikInitialValues = useMemo<ProjectFlockFormValues>(() => {
const trimFlock =
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
) ?? '';
return { return {
name: initialValues?.flock_name, flock: initialValues?.flock_name
flock: initialValues?.flock
? { ? {
value: initialValues?.flock?.id ?? 0, value:
optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.value ?? 0,
label: label:
initialValues?.flock?.name ?? initialValues?.flock_name ?? '', formType != 'detail'
? (optionsFlock.find((flock) => {
return flock.label == trimFlock;
})?.label ?? '')
: initialValues?.flock_name,
} }
: null, : null,
area: initialValues?.area area: initialValues?.area
@@ -311,31 +329,56 @@ const ProjectFlockForm = ({
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
flock_id: initialValues?.flock?.id ?? 0, flock_name:
flock_name: initialValues?.flock_name ?? '', optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label ?? '',
area_id: initialValues?.area?.id ?? 0, area_id: initialValues?.area?.id ?? 0,
category: initialValues?.category as NonNullable< category: initialValues?.category as NonNullable<
'GROWING' | 'LAYING' | undefined 'GROWING' | 'LAYING' | undefined
>, >,
fcr_id: initialValues?.fcr?.id ?? 0, fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0, location_id: initialValues?.location?.id ?? 0,
period: initialValues?.period ?? 1,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as ( kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number | number
| undefined | undefined
)[], )[],
}; };
}, [initialValues]); }, [initialValues, optionsFlock]);
// Formik // Formik
const formik = useFormik<ProjectFlockFormValues>({ const formik = useFormik<ProjectFlockFormValues>({
initialValues: { initialValues: {
name: initialValues?.flock_name, flock: initialValues?.flock_name
flock: initialValues?.flock
? { ? {
value: initialValues?.flock?.id ?? 0, value:
optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.value ?? 0,
label: label:
initialValues?.flock?.name ?? initialValues?.flock_name ?? '', formType != 'detail'
? (optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label ?? '')
: initialValues?.flock_name,
} }
: null, : null,
area: initialValues?.area area: initialValues?.area
@@ -362,15 +405,24 @@ const ProjectFlockForm = ({
label: initialValues.location.name, label: initialValues.location.name,
} }
: null, : null,
flock_id: initialValues?.flock?.id ?? 0, flock_name:
flock_name: initialValues?.flock_name ?? '', formType != 'detail'
? optionsFlock.find((flock) => {
return (
flock.label ==
initialValues?.flock_name?.slice(
0,
initialValues?.flock_name?.lastIndexOf(' ')
)
);
})?.label
: (initialValues?.flock_name ?? ''),
area_id: initialValues?.area?.id ?? 0, area_id: initialValues?.area?.id ?? 0,
category: initialValues?.category as NonNullable< category: initialValues?.category as NonNullable<
'GROWING' | 'LAYING' | undefined 'GROWING' | 'LAYING' | undefined
>, >,
fcr_id: initialValues?.fcr?.id ?? 0, fcr_id: initialValues?.fcr?.id ?? 0,
location_id: initialValues?.location?.id ?? 0, location_id: initialValues?.location?.id ?? 0,
period: initialValues?.period ?? 1,
kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as ( kandang_ids: initialValues?.kandangs?.map((k: Kandang) => k.id) as (
| number | number
| undefined | undefined
@@ -385,12 +437,11 @@ const ProjectFlockForm = ({
onSubmit: async (values) => { onSubmit: async (values) => {
setProjectFlockFormErrorMessage(''); setProjectFlockFormErrorMessage('');
const payload: CreateProjectFlockPayload = { const payload: CreateProjectFlockPayload = {
flock_name: values.flock?.label as string, flock_name: values.flock_name as string,
area_id: values.area_id as number, area_id: values.area_id as number,
category: values.category as string, category: values.category as string,
fcr_id: values.fcr_id as number, fcr_id: values.fcr_id as number,
location_id: values.location_id as number, location_id: values.location_id as number,
period: values.period as number,
kandang_ids: values.kandang_ids as number[], kandang_ids: values.kandang_ids as number[],
}; };
@@ -419,8 +470,6 @@ const ProjectFlockForm = ({
if (initialValues?.area_id) { if (initialValues?.area_id) {
setSelectedArea(initialValues?.area_id.toString() as string); setSelectedArea(initialValues?.area_id.toString() as string);
} }
formik.setFieldValue('period', initialValues?.period);
} }
}, [initialValues, setSelectedArea, formType]); }, [initialValues, setSelectedArea, formType]);
@@ -449,15 +498,6 @@ const ProjectFlockForm = ({
formik.validateForm(); formik.validateForm();
}, [formik.values]); }, [formik.values]);
useEffect(() => {
if (isResponseSuccess(periodFlocks)) {
formik.setFieldValue('period', periodFlocks.data.next_period);
}
if (isResponseError(periodFlocks)) {
console.log(periodFlocks?.message as string);
}
}, [periodFlocks]);
useEffect(() => { useEffect(() => {
const selectedRowIds = Object.keys(rowSelection) const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id]) .filter((id) => rowSelection[id])
@@ -485,32 +525,23 @@ const ProjectFlockForm = ({
setIsDeleteLoading(false); setIsDeleteLoading(false);
}; };
const confirmationModalClickHandler = async ({ const confirmApprovalHandler = async (
action = 'APPROVED', notes: string,
}: { approvalAction: 'REJECTED' | 'APPROVED'
action: 'APPROVED' | 'REJECTED'; ) => {
}) => {
if (initialValues?.id === undefined) return; if (initialValues?.id === undefined) return;
setIsApproveLoading(true); setIsApproveLoading(true);
const approveProjectFlockRes = await ProjectFlockApi.customRequest<
BaseApiResponse<ProjectFlock>,
ProjectFlockApprovalPayload
>(`/approvals`, {
method: 'POST',
payload: {
action: action,
approvable_ids: [initialValues?.id],
},
});
if (isResponseSuccess(approveProjectFlockRes)) { const approvalRes =
if (refreshProjectFlocks) { approvalAction == 'APPROVED'
await refreshProjectFlocks(); ? await ProjectFlockApi.approve(initialValues?.id, notes)
: await ProjectFlockApi.reject(initialValues?.id, notes);
if (isResponseSuccess(approvalRes)) {
refreshProjectFlocks?.();
toast.success(approvalRes.message as string);
} }
toast.success(approveProjectFlockRes.message as string); if (isResponseError(approvalRes)) {
} toast.error(approvalRes?.message as string);
if (isResponseError(approveProjectFlockRes)) {
toast.error(approveProjectFlockRes?.message as string);
} }
refreshApprovals(); refreshApprovals();
confirmModal.closeModal(); confirmModal.closeModal();
@@ -555,7 +586,7 @@ const ProjectFlockForm = ({
</div> </div>
</div> </div>
)} )}
{approvals && !approvalsLoading && ( {approvals && !approvalsLoading && formType == 'detail' && (
<ApprovalSteps approvals={approvals} /> <ApprovalSteps approvals={approvals} />
)} )}
{formType == 'detail' && ( {formType == 'detail' && (
@@ -615,7 +646,6 @@ const ProjectFlockForm = ({
<div className='card bg-base-100 shadow w-full mb-6'> <div className='card bg-base-100 shadow w-full mb-6'>
<div className='card-body'> <div className='card-body'>
<div className='card-title mb-4'>Informasi Umum</div> <div className='card-title mb-4'>Informasi Umum</div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-2 gap-4'>
<SelectInput <SelectInput
required required
@@ -634,10 +664,19 @@ const ProjectFlockForm = ({
<SelectInput <SelectInput
required required
label='Flock' label='Flock'
value={formik.values.flock as OptionType} value={
formik.values.flock_name
? ({
label: formik.values.flock_name,
value: optionsFlock.find((flock) => {
return flock.label === formik.values.flock_name;
})?.value,
} as OptionType)
: undefined
}
onChange={(val) => { onChange={(val) => {
optionChangeHandler(val, 'flock'); optionChangeHandler(val, 'flock');
setSelectedFlock((val as OptionType)?.value as number); setSelectedFlock((val as OptionType)?.label);
formik.setFieldValue( formik.setFieldValue(
'flock_name', 'flock_name',
(val as OptionType)?.label (val as OptionType)?.label
@@ -701,22 +740,6 @@ const ProjectFlockForm = ({
isClearable isClearable
isDisabled={formType === 'detail'} isDisabled={formType === 'detail'}
/> />
<TextInput
required
type='number'
name='period'
label='Periode'
placeholder='Masukkan periode yang project'
value={formik.values.period ?? (1 as number)}
onChange={formik.handleChange}
isError={
formik.touched.period && Boolean(formik.errors.period)
}
errorMessage={formik.errors.period as string}
readOnly={formType === 'detail'}
disabled={true}
isLoading={isLoadingPeriodFlocks}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -750,6 +773,9 @@ const ProjectFlockForm = ({
<span className='loading loading-dots loading-xl'></span> <span className='loading loading-dots loading-xl'></span>
)} )}
<ProjectFlockKandangTable <ProjectFlockKandangTable
listPeriods={
isResponseSuccess(periodFlocks) ? periodFlocks.data : []
}
listKandang={optionsKandang} listKandang={optionsKandang}
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
@@ -832,7 +858,7 @@ const ProjectFlockForm = ({
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${initialValues?.flock?.name} - ${initialValues?.area?.name})?`} text={`Apakah anda yakin ingin menghapus data Project Flock ini (${initialValues?.flock_name} - ${initialValues?.area?.name})?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
}} }}
@@ -844,12 +870,12 @@ const ProjectFlockForm = ({
}} }}
/> />
<ConfirmationModal <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type={approvalAction == 'APPROVED' ? 'success' : 'error'} type={approvalAction == 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${ text={`Apakah anda yakin ingin ${
approvalAction == 'APPROVED' ? 'approve' : 'reject' approvalAction == 'APPROVED' ? 'approve' : 'reject'
} Project Flock berikut? (${initialValues?.flock?.name} - ${ } Project Flock berikut? (${initialValues?.flock_name} - ${
initialValues?.area?.name initialValues?.area?.name
})?`} })?`}
secondaryButton={{ secondaryButton={{
@@ -859,10 +885,8 @@ const ProjectFlockForm = ({
text: 'Ya', text: 'Ya',
color: approvalAction == 'APPROVED' ? 'success' : 'error', color: approvalAction == 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading, isLoading: isApproveLoading,
onClick: () => { onClick: (notes) => {
confirmationModalClickHandler({ confirmApprovalHandler(notes, approvalAction);
action: approvalAction,
});
}, },
}} }}
/> />
@@ -5,10 +5,12 @@ import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockPeriods } from '@/types/api/production/project-flock';
import { OnChangeFn, Row } from '@tanstack/react-table'; import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react'; import { useMemo } from 'react';
const ProjectFlockKandangTable = ({ const ProjectFlockKandangTable = ({
listPeriods,
listKandang, listKandang,
rowSelection, rowSelection,
setRowSelection, setRowSelection,
@@ -16,6 +18,7 @@ const ProjectFlockKandangTable = ({
initialValues, initialValues,
formType = 'add', formType = 'add',
}: { }: {
listPeriods: ProjectFlockPeriods;
listKandang: Kandang[]; listKandang: Kandang[];
rowSelection: Record<string, boolean>; rowSelection: Record<string, boolean>;
setRowSelection: OnChangeFn<Record<string, boolean>>; setRowSelection: OnChangeFn<Record<string, boolean>>;
@@ -134,6 +137,19 @@ const ProjectFlockKandangTable = ({
accessorFn: (row) => row.capacity, accessorFn: (row) => row.capacity,
header: 'Kapasitas', header: 'Kapasitas',
}, },
{
accessorFn: (row) => row.location?.name,
header: 'Periode',
cell: (props) => {
console.log('listPeriods');
console.log(listPeriods);
const period =
listPeriods.length > 0
? listPeriods.find((p) => p.id == props.row.original.id)
: undefined;
return period?.period ?? '-';
},
},
{ {
accessorFn: (row) => row.pic?.name, accessorFn: (row) => row.pic?.name,
header: 'Penanggung Jawab', header: 'Penanggung Jawab',
File diff suppressed because it is too large Load Diff
@@ -1,212 +1,320 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { RECORDING_FLAG_OPTIONS } from '@/config/constant'; import {
import { Recording } from '@/types/api/production/recording'; Recording,
CreateGrowingRecordingPayload,
CreateLayingRecordingPayload,
CreateEggPayload,
CreateGradingPayload,
} from '@/types/api/production/recording';
export const RecordingFormSchema = Yup.object({ type RecordingGrowingFormSchemaType = {
flock: Yup.object({ project_flock_kandang: {
value: Yup.number().min(1).required(), value: number;
label: Yup.string().required(), label: string;
}).nullable(), } | null;
flock_id: Yup.number() project_flock_kandang_id: number;
.default(0) body_weights: {
.typeError('Flock wajib diisi!') weight: number | string;
.test( avg_weight: number | string;
'is-valid-flock', qty: number | string;
'Flock wajib diisi!', }[];
(value) => value !== undefined && value !== null && value > 0 stocks: {
) product_warehouse_id: number;
.required('Flock wajib diisi!'), qty: number | string;
location: Yup.object({ }[];
value: Yup.number().min(1).required(), depletions: {
label: Yup.string().required(), product_warehouse_id: number;
}).nullable(), qty: number | string;
location_id: Yup.number() }[];
.default(0) };
.typeError('Lokasi wajib diisi!')
.test( type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
'is-valid-location', eggs: {
'Lokasi wajib diisi!', product_warehouse_id: number;
(value) => value !== undefined && value !== null && value > 0 qty: number | string;
) }[];
.required('Lokasi wajib diisi!'), };
coop: Yup.object({
value: Yup.number().min(1).required(), type RecordingGradingFormSchemaType = {
label: Yup.string().required(), eggs_grading: {
}).nullable(), recording_egg_id: number;
coop_id: Yup.number() grade: string;
.default(0) qty: number | string;
.typeError('Kandang wajib diisi!') }[];
.test( };
'is-valid-coop',
'Kandang wajib diisi!', export type BodyWeightSchema = {
(value) => value !== undefined && value !== null && value > 0 weight: number | string;
) avg_weight: number | string;
.required('Kandang wajib diisi!'), qty: number | string;
recording_date: Yup.date() };
.required('Tanggal recording wajib diisi')
.typeError('Format tanggal tidak valid'), export type StockSchema = {
feed_data: Yup.array() product_warehouse_id: number;
.of( qty: number | string;
Yup.object({ };
feed_id: Yup.string().required('Nama pakan wajib diisi!'),
feed_qty: Yup.mixed<number | ''>().notRequired(), export type DepletionSchema = {
feed_stock: Yup.number() product_warehouse_id: number;
.required('Jumlah pakan yang digunakan wajib diisi!') qty: number | string;
.min(1, 'Jumlah pakan minimal 1!') };
.typeError('Jumlah pakan yang digunakan harus berupa angka!')
.test( export type EggSchema = {
'is-not-exceed-qty', product_warehouse_id: number;
'Jumlah pakan yang digunakan tidak boleh melebihi stok tersedia!', qty: number | string;
function (value) { };
const { feed_qty } = this.parent;
if (value === undefined) return true; const BodyWeightObjectSchema: Yup.ObjectSchema<BodyWeightSchema> = Yup.object({
if ( weight: Yup.number()
feed_qty === undefined || .required('Berat ayam total wajib diisi!')
feed_qty === '' || .min(1, 'Berat ayam total minimal 1 gram!')
typeof feed_qty !== 'number' .typeError('Berat ayam total harus berupa angka!'),
) avg_weight: Yup.number()
return true; .required('Berat ayam rata-rata wajib diisi!')
return value <= feed_qty; .typeError('Berat ayam rata-rata harus berupa angka!'),
} qty: Yup.number()
),
})
)
.min(1, 'Minimal harus ada 1 data pakan!')
.required('Data pakan wajib diisi!'),
body_weight: Yup.array()
.of(
Yup.object({
chicken_weight: Yup.number()
.required('Berat ayam wajib diisi!')
.min(1, 'Berat ayam minimal 1 gram!')
.typeError('Berat ayam harus berupa angka!'),
chicken_count: Yup.number()
.required('Jumlah ayam wajib diisi!') .required('Jumlah ayam wajib diisi!')
.min(1, 'Jumlah ayam minimal 1 ekor!') .min(1, 'Jumlah ayam minimal 1 ekor!')
.typeError('Jumlah ayam harus berupa angka!'), .typeError('Jumlah ayam harus berupa angka!'),
average_chicken_weight: Yup.number() });
.required('Rata-rata berat ayam wajib diisi!')
.min(1, 'Rata-rata berat ayam minimal 1 gram!') const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
.typeError('Rata-rata berat ayam harus berupa angka!'), product_warehouse_id: Yup.number()
}) .required('Produk wajib diisi!')
) .min(1, 'Produk wajib diisi!')
.min(1, 'Minimal harus ada 1 data bobot badan!') .typeError('Produk harus berupa angka!'),
.required('Data bobot badan wajib diisi!'), qty: Yup.number()
vaccination: Yup.array() .required('Jumlah penggunaan wajib diisi!')
.of( .min(1, 'Jumlah penggunaan tidak boleh 0!')
.typeError('Jumlah penggunaan harus berupa angka!'),
});
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number()
.required('Produk depletions wajib diisi!')
.min(1, 'Produk depletions wajib diisi!')
.typeError('Produk depletions harus berupa angka!'),
qty: Yup.number()
.required('Jumlah depletions wajib diisi!')
.min(1, 'Jumlah depletions minimal 1!')
.typeError('Jumlah depletions harus berupa angka!'),
});
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.number()
.required('Kondisi telur wajib diisi!')
.min(1, 'Kondisi telur wajib diisi!')
.typeError('Kondisi telur harus berupa angka!'),
qty: Yup.number()
.required('Jumlah telur wajib diisi!')
.min(1, 'Jumlah telur tidak boleh 0!')
.typeError('Jumlah telur harus berupa angka!'),
});
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
Yup.object({ Yup.object({
vaccine_id: Yup.string().required('Nama vaksin wajib diisi!'), project_flock_kandang: Yup.object({
total_stock: Yup.mixed<number | ''>().notRequired(), value: Yup.number().min(1).required(),
used_stock: Yup.number() label: Yup.string().required(),
.required('Jumlah vaksin yang digunakan wajib diisi!') }).nullable(),
.min(1, 'Jumlah vaksin minimal 1!') project_flock_kandang_id: Yup.number()
.typeError('Jumlah vaksin yang digunakan harus berupa angka!') .default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test( .test(
'is-not-exceed-total', 'is-valid-project-flock-kandang',
'Jumlah vaksin yang digunakan tidak boleh melebihi stok tersedia!', 'Project Flock Kandang wajib diisi!',
function (value) { (value) => value !== undefined && value !== null && value > 0
const { total_stock } = this.parent;
if (value === undefined) return true;
if (
total_stock === undefined ||
total_stock === '' ||
typeof total_stock !== 'number'
) )
.required('Project Flock Kandang wajib diisi!')
.test(
'not-already-recorded',
'Project Flock ini sudah direcord hari ini!',
function (value) {
const recordedProjectFlockIds = this.options.context
?.recordedProjectFlockIds as Set<number>;
const formType = this.options.context?.type as
| 'add'
| 'edit'
| 'detail';
if (formType !== 'add') return true;
if (value && recordedProjectFlockIds?.has(value)) {
return false;
}
return true; return true;
return value <= total_stock;
} }
), ),
}) body_weights: Yup.array()
.of(BodyWeightObjectSchema)
.min(1, 'Minimal harus ada 1 data bobot badan!')
.required('Data bobot badan wajib diisi!'),
stocks: Yup.array()
.of(StockObjectSchema)
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array()
.of(DepletionObjectSchema)
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
});
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
RecordingGrowingFormSchema.shape({
eggs: Yup.array()
.of(EggObjectSchema)
.min(1, 'Minimal harus ada 1 data telur!')
.required('Data telur wajib diisi!'),
});
export const UpdateRecordingGrowingFormSchema =
RecordingGrowingFormSchema.shape({
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
) )
.min(1, 'Minimal harus ada 1 data vaksinasi!') .required('Project Flock Kandang wajib diisi!'),
.required('Data vaksinasi wajib diisi!'), });
mortality: Yup.array()
export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({
project_flock_kandang_id: Yup.number()
.default(0)
.typeError('Project Flock Kandang wajib diisi!')
.test(
'is-valid-project-flock-kandang',
'Project Flock Kandang wajib diisi!',
(value) => value !== undefined && value !== null && value > 0
)
.required('Project Flock Kandang wajib diisi!'),
});
export const RecordingGradingFormSchema: Yup.ObjectSchema<RecordingGradingFormSchemaType> =
Yup.object({
eggs_grading: Yup.array()
.of( .of(
Yup.object({ Yup.object({
condition: Yup.mixed<string>() recording_egg_id: Yup.number()
.oneOf( .required('Recording Egg ID wajib diisi!')
RECORDING_FLAG_OPTIONS.map((opt) => opt.value), .min(1, 'Recording Egg ID minimal 1!')
'Kondisi tidak valid!' .typeError('Recording Egg ID harus berupa angka!'),
) grade: Yup.string()
.required('Kondisi wajib diisi!'), .required('Grade telur wajib diisi!')
count: Yup.number() .typeError('Grade telur harus berupa string!'),
.required('Jumlah mortalitas wajib diisi!') qty: Yup.number()
.min(1, 'Jumlah mortalitas minimal 1 ekor!') .required('Jumlah telur wajib diisi!')
.typeError('Jumlah mortalitas harus berupa angka!'), .min(1, 'Jumlah telur minimal 1!')
.typeError('Jumlah telur harus berupa angka!'),
}) })
) )
.min(1, 'Minimal harus ada 1 data mortalitas!') .min(1, 'Minimal harus ada 1 data grading telur!')
.required('Data mortalitas wajib diisi!'), .required('Data grading telur wajib diisi!'),
}); });
export const UpdateRecordingFormSchema = RecordingFormSchema; export const UpdateRecordingGradingFormSchema = RecordingGradingFormSchema;
export type RecordingFormValues = Yup.InferType<typeof RecordingFormSchema>; export type RecordingGrowingFormValues = Yup.InferType<
typeof RecordingGrowingFormSchema
>;
export const getRecordingFormInitialValues = ( export type RecordingLayingFormValues = Yup.InferType<
initialValues?: Recording typeof RecordingLayingFormSchema
): RecordingFormValues => ({ >;
flock: initialValues?.flock
export type RecordingGradingFormValues = Yup.InferType<
typeof RecordingGradingFormSchema
>;
type RecordingFormData = Partial<Recording> & {
body_weights?: CreateGrowingRecordingPayload['body_weights'];
stocks?: CreateGrowingRecordingPayload['stocks'] | Recording['stocks'];
depletions?:
| CreateGrowingRecordingPayload['depletions']
| Recording['depletions'];
eggs?: CreateLayingRecordingPayload['eggs'] | Recording['eggs'];
project_flock_kandang_id?: number;
project_flock_category?: string;
};
export const getRecordingGrowingFormInitialValues = (
initialValues?: RecordingFormData
): RecordingGrowingFormValues => ({
project_flock_kandang: initialValues?.project_flock_kandang_id
? { ? {
value: initialValues.flock.id, value: initialValues.project_flock_kandang_id,
label: initialValues.flock.name, label: `Project Flock #${initialValues.project_flock_kandang_id}`,
} }
: null, : null,
flock_id: initialValues?.flock?.id ?? 0, project_flock_kandang_id: initialValues?.project_flock_kandang_id ?? 0,
location: initialValues?.location body_weights: initialValues?.body_weights?.map(
? { (bw: NonNullable<CreateGrowingRecordingPayload['body_weights']>[0]) => ({
value: initialValues.location.id, weight: bw.avg_weight * bw.qty,
label: initialValues.location.name, avg_weight: bw.avg_weight,
} qty: bw.qty,
: null, })
location_id: initialValues?.location?.id ?? 0, ) ?? [
coop: initialValues?.coop
? {
value: initialValues.coop.id,
label: initialValues.coop.name,
}
: null,
coop_id: initialValues?.coop?.id ?? 0,
recording_date: initialValues?.recording_date
? new Date(initialValues.recording_date)
: new Date(),
feed_data: initialValues?.feed_data
? initialValues.feed_data.map((feed) => ({
feed_id: feed.feed_name,
feed_qty: feed.feed_qty,
feed_stock: feed.feed_stock,
}))
: [
{ {
feed_id: '', weight: '',
feed_qty: '', avg_weight: '',
feed_stock: 0, qty: '',
}, },
], ],
body_weight: initialValues?.body_weight ?? [ stocks: initialValues?.stocks?.map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty:
(stock as { qty?: number; usage_amount?: number }).qty ||
(stock as { qty?: number; usage_amount?: number }).usage_amount ||
'',
})) ?? [
{ {
chicken_weight: 0, product_warehouse_id: 0,
chicken_count: 0, qty: '',
average_chicken_weight: 0,
}, },
], ],
vaccination: initialValues?.vaccination depletions: initialValues?.depletions?.map(
? initialValues.vaccination.map((vaccine) => ({ (
vaccine_id: vaccine.vaccine_name, depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
total_stock: vaccine.total_stock, ) => ({
used_stock: vaccine.used_stock, product_warehouse_id: depletion.product_warehouse_id,
})) qty: depletion.qty,
: [ })
) ?? [
{ {
vaccine_id: '', product_warehouse_id: 0,
total_stock: '', qty: '',
used_stock: 0, },
}, ],
], });
mortality: initialValues?.mortality ?? [
{ export const getRecordingLayingFormInitialValues = (
condition: '', initialValues?: RecordingFormData
count: 0, ): RecordingLayingFormValues => ({
...getRecordingGrowingFormInitialValues(initialValues),
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: egg.product_warehouse_id,
qty: egg.qty,
})) ?? [
{
product_warehouse_id: 0,
qty: '',
},
],
});
export const getRecordingGradingFormInitialValues = (
initialValues?: Partial<CreateGradingPayload> & { recording_egg_id?: number }
): RecordingGradingFormValues => ({
eggs_grading: initialValues?.eggs_grading?.map((grading) => ({
recording_egg_id: grading.recording_egg_id,
grade: grading.grade,
qty: grading.qty,
})) ?? [
{
recording_egg_id: initialValues?.recording_egg_id ?? 0,
grade: '',
qty: '',
}, },
], ],
}); });
File diff suppressed because it is too large Load Diff
@@ -1,70 +0,0 @@
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { useModal } from '@/components/Modal';
import { RecordingApi } from '@/services/api/production';
import {
CreateRecordingPayload,
UpdateRecordingPayload,
} from '@/types/api/production/recording';
import { isResponseError } from '@/lib/api-helper';
export const useRecordingFormHandlers = (initialValuesId?: number) => {
const router = useRouter();
const deleteModal = useModal();
const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const createRecordingHandler = useCallback(
async (payload: CreateRecordingPayload) => {
const res = await RecordingApi.create(payload);
if (isResponseError(res)) {
setRecordingFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.push('/flock/recording');
},
[router]
);
const updateRecordingHandler = useCallback(
async (recordingId: number, payload: UpdateRecordingPayload) => {
const res = await RecordingApi.update(recordingId, payload);
if (res?.status === 'error') {
setRecordingFormErrorMessage(res.message);
return;
}
toast.success(res?.message as string);
router.refresh();
router.push('/flock/recording');
},
[router]
);
const deleteRecordingClickHandler = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
const confirmationModalDeleteClickHandler = useCallback(async () => {
if (!initialValuesId) return;
setIsDeleteLoading(true);
await RecordingApi.delete(initialValuesId);
deleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false);
router.push('/flock/recording');
}, [deleteModal, initialValuesId, router]);
return {
deleteModal,
recordingFormErrorMessage,
isDeleteLoading,
createRecordingHandler,
updateRecordingHandler,
deleteRecordingClickHandler,
confirmationModalDeleteClickHandler,
};
};
File diff suppressed because it is too large Load Diff
+60
View File
@@ -33,6 +33,66 @@ export const TRANSFER_TO_LAYING_APPROVAL_LINE: ApprovalLine = [
}, },
] as const; ] as const;
export const MARKETING_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Pengajuan',
},
{
step_number: 2,
step_name: 'Sales Order',
},
{
step_number: 3,
step_name: 'Delivery Order',
},
] as const;
export const RECORDING_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan',
},
{
step_number: 3,
step_name: 'Disetujui',
},
] as const;
export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan',
},
{
step_number: 3,
step_name: 'Disetujui',
},
] as const;
export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan',
},
{
step_number: 3,
step_name: 'Disetujui',
},
] as const;
export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [ export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [
{ {
step_number: 1, step_number: 1,
+37 -4
View File
@@ -48,7 +48,7 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
{ {
title: 'Penjualan', title: 'Penjualan',
link: '/marketing/sales-orders', link: '/marketing',
icon: 'mdi:attach-money', icon: 'mdi:attach-money',
}, },
@@ -233,9 +233,42 @@ export const SUPPLIER_FLAG_OPTIONS = [
]; ];
export const RECORDING_FLAG_OPTIONS = [ export const RECORDING_FLAG_OPTIONS = [
{ label: 'Ayam Afkir', value: 'Ayam Afkir' }, { label: 'Ayam Afkir', value: 'Afkir' },
{ label: 'Ayam Culling', value: 'Ayam Culling' }, { label: 'Ayam Culling', value: 'Culling' },
{ label: 'Ayam Mati', value: 'Ayam Mati' }, { label: 'Ayam Mati', value: 'Mati' },
];
export const APPROVAL_WORKFLOWS = [
{
key: 'PROJECT_FLOCKS',
steps: [
{
step_number: 1,
step_name: 'Pengajuan',
},
{
step_number: 2,
step_name: 'Aktif',
},
],
},
{
key: 'RECORDINGS',
steps: [
{
step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan',
},
{
step_number: 3,
step_name: 'Disetujui',
},
],
},
]; ];
export const ACCEPTED_FILE_TYPE = { export const ACCEPTED_FILE_TYPE = {
+189 -165
View File
@@ -4,12 +4,34 @@ import { Location } from '@/types/api/master-data/location';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Marketing } from '@/types/api/marketing/marketing'; import {
import { CreatedUser } from '@/types/api/api-general'; 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 { 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 // 👤 Created User & Helper Data
// ====================== // ======================
export const createdUser: CreatedUser = { export const createdUser: CreatedUser = {
id: 1, id: 1,
@@ -18,6 +40,24 @@ export const createdUser: CreatedUser = {
name: 'Admin Utama', 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 // 📍 Area Dummy
// ====================== // ======================
@@ -26,15 +66,15 @@ export const dummyAreas: Area[] = [
id: 1, id: 1,
name: 'Bandung Barat', name: 'Bandung Barat',
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
{ {
id: 2, id: 2,
name: 'Cimahi Utara', name: 'Cimahi Utara',
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
]; ];
@@ -48,8 +88,8 @@ export const dummyLocations: Location[] = [
address: 'Jl. Sukajadi No. 12', address: 'Jl. Sukajadi No. 12',
area: dummyAreas[0], area: dummyAreas[0],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
{ {
id: 2, id: 2,
@@ -57,8 +97,8 @@ export const dummyLocations: Location[] = [
address: 'Jl. Setiabudi No. 45', address: 'Jl. Setiabudi No. 45',
area: dummyAreas[1], area: dummyAreas[1],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
]; ];
@@ -74,8 +114,8 @@ export const dummyKandangs: Kandang[] = [
location: dummyLocations[0], location: dummyLocations[0],
pic: createdUser, pic: createdUser,
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
{ {
id: 2, id: 2,
@@ -85,8 +125,8 @@ export const dummyKandangs: Kandang[] = [
location: dummyLocations[1], location: dummyLocations[1],
pic: createdUser, pic: createdUser,
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
]; ];
@@ -100,9 +140,9 @@ export const dummyWarehouses: Warehouse[] = [
name: 'Gudang Wilayah Bandung Barat', name: 'Gudang Wilayah Bandung Barat',
area: dummyAreas[0], area: dummyAreas[0],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Warehouse,
{ {
id: 2, id: 2,
type: 'LOKASI', type: 'LOKASI',
@@ -110,9 +150,9 @@ export const dummyWarehouses: Warehouse[] = [
area: dummyAreas[0], area: dummyAreas[0],
location: { ...dummyLocations[0], area: dummyAreas[0] }, location: { ...dummyLocations[0], area: dummyAreas[0] },
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Warehouse,
{ {
id: 3, id: 3,
type: 'KANDANG', type: 'KANDANG',
@@ -125,9 +165,9 @@ export const dummyWarehouses: Warehouse[] = [
pic: createdUser, pic: createdUser,
}, },
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Warehouse,
]; ];
// ====================== // ======================
@@ -139,16 +179,11 @@ export const dummyProductWarehouses: ProductWarehouse[] = [
product_id: 101, product_id: 101,
warehouse_id: 1, warehouse_id: 1,
quantity: 1000, quantity: 1000,
product: { product: dummyProductBase,
id: 101,
name: 'Pakan Ayam Premium',
sku: 'PAK-001',
category: 'PAKAN',
} as unknown as Product,
warehouse: dummyWarehouses[0], warehouse: dummyWarehouses[0],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
{ {
id: 2, id: 2,
@@ -156,29 +191,95 @@ export const dummyProductWarehouses: ProductWarehouse[] = [
warehouse_id: 2, warehouse_id: 2,
quantity: 500, quantity: 500,
product: { product: {
...dummyProductBase,
id: 102, id: 102,
name: 'Vitamin Ayam Super', name: 'Vitamin Ayam Super',
sku: 'VIT-002', sku: 'VIT-002',
category: 'VITAMIN', flags: ['VITAMIN'],
} as unknown as Product, selling_price: 25000,
},
warehouse: dummyWarehouses[1], warehouse: dummyWarehouses[1],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, },
]; ];
// ====================== // ======================
// 💼 Marketing Dummy // 💼 Marketing Dummy
// ====================== // ======================
export const dummyMarketings: Marketing[] = [
// Step 1: Pengajuan Order // 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, id: 1,
status: 'APPROVED', 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_number: 'SO-001-2025',
so_docs: 'https://example.com/docs/so001.pdf', so_date: today,
so_date: format(new Date(), 'yyyy-MM-dd'),
customer: { customer: {
id: 1, id: 1,
name: 'PT Maju Jaya', name: 'PT Maju Jaya',
@@ -190,52 +291,32 @@ export const dummyMarketings: Marketing[] = [
email: 'contact@majujaya.com', email: 'contact@majujaya.com',
account_number: '1234567890', account_number: '1234567890',
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Customer,
sales_person: createdUser, sales_person: createdUser,
notes: 'Pengiriman awal bulan.', notes: 'Pengajuan Order Awal, menunggu persetujuan harga.',
grand_total: 7500000, latest_approval: {
approval: {
step_number: 1, step_number: 1,
step_name: 'Pengajuan Order', step_name: 'Pengajuan Order',
action: 'APPROVED', action: 'CREATED',
action_by: createdUser, action_by: createdUser,
action_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), action_at: now,
}, } as BaseApproval,
marketing_products: [ sales_order: [soItem1],
{ delivery_order: [],
id: 1,
qty: 100,
unit_price: 75000,
avg_weight: 2.5,
total_weight: 250,
total_price: 7500000,
product_warehouse: dummyProductWarehouses[0],
marketing_delivery_products: {
id: 1,
qty: 100,
unit_price: 75000,
avg_weight: 2.5,
total_weight: 250,
total_price: 7500000,
delivery_date: format(new Date(), 'yyyy-MM-dd'),
vehicle_number: 'B 1234 XY',
},
},
],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Marketing,
// Step 2: Sales Order // 2. Sales Order (Disetujui dan Siap DO)
{ {
id: 2, id: 2,
status: 'APPROVED', status: 'APPROVED',
// name: 'SO-002-2025', // `name` is not part of BaseMarketing
so_number: 'SO-002-2025', so_number: 'SO-002-2025',
so_docs: 'https://example.com/docs/so002.pdf', so_date: today,
so_date: format(new Date(), 'yyyy-MM-dd'),
customer: { customer: {
id: 2, id: 2,
name: 'CV Sumber Sehat', name: 'CV Sumber Sehat',
@@ -247,52 +328,33 @@ export const dummyMarketings: Marketing[] = [
email: 'info@sumbersehat.com', email: 'info@sumbersehat.com',
account_number: '9876543210', account_number: '9876543210',
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Customer,
sales_person: createdUser, sales_person: createdUser,
notes: 'Pesanan kedua untuk stok akhir tahun.', notes: 'Sales Order telah disetujui oleh Supervisor.',
grand_total: 3750000, latest_approval: {
approval: { id: 2,
step_number: 2, step_number: 2,
step_name: 'Sales Order', step_name: 'Sales Order',
action: 'APPROVED', action: 'APPROVED',
action_by: createdUser, action_by: createdUser,
action_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), action_at: now,
}, } as BaseApproval,
marketing_products: [ sales_order: [soItem2],
{ delivery_order: [], // Belum ada pengiriman (DO) yang dibuat
id: 2,
qty: 50,
unit_price: 75000,
avg_weight: 2.5,
total_weight: 125,
total_price: 3750000,
product_warehouse: dummyProductWarehouses[1],
marketing_delivery_products: {
id: 2,
qty: 50,
unit_price: 75000,
avg_weight: 2.5,
total_weight: 125,
total_price: 3750000,
delivery_date: format(new Date(), 'yyyy-MM-dd'),
vehicle_number: 'B 5678 YZ',
},
},
],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Marketing,
// Step 3: Delivery Order // 3. Delivery Order (Proses Pengiriman telah dibuat)
{ {
id: 3, id: 3,
status: 'APPROVED', 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_number: 'SO-003-2025',
so_docs: 'https://example.com/docs/so003.pdf', so_date: today,
so_date: format(new Date(), 'yyyy-MM-dd'),
customer: { customer: {
id: 3, id: 3,
name: 'UD Ternak Sejahtera', name: 'UD Ternak Sejahtera',
@@ -304,61 +366,23 @@ export const dummyMarketings: Marketing[] = [
email: 'halo@ternaksejahtera.com', email: 'halo@ternaksejahtera.com',
account_number: '1122334455', account_number: '1122334455',
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Customer,
sales_person: createdUser, sales_person: createdUser,
notes: 'Order untuk pengiriman ke luar kota.', notes: 'Pengiriman barang telah berhasil dilakukan.',
grand_total: 5600000, latest_approval: {
approval: { id: 3,
step_number: 3, step_number: 3,
step_name: 'Delivery Order', step_name: 'Delivery Order',
action: 'APPROVED', action: 'COMPLETED',
action_by: createdUser, action_by: createdUser,
action_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), action_at: now,
}, } as BaseApproval,
marketing_products: [ sales_order: [soItem1, soItem2],
{ delivery_order: deliveryOrder1, // DO sudah terbuat
id: 3,
qty: 80,
unit_price: 70000,
avg_weight: 2.4,
total_weight: 192,
total_price: 5600000,
product_warehouse: dummyProductWarehouses[0],
marketing_delivery_products: {
id: 3,
qty: 80,
unit_price: 70000,
avg_weight: 2.4,
total_weight: 192,
total_price: 5600000,
delivery_date: format(new Date(), 'yyyy-MM-dd'),
vehicle_number: 'D 9090 ZZ',
},
},
{
id: 4,
qty: 80,
unit_price: 70000,
avg_weight: 2.4,
total_weight: 192,
total_price: 5600000,
product_warehouse: dummyProductWarehouses[0],
marketing_delivery_products: {
id: 3,
qty: 80,
unit_price: 70000,
avg_weight: 2.4,
total_weight: 192,
total_price: 5600000,
delivery_date: format(new Date(), 'yyyy-MM-dd'),
vehicle_number: 'D 9090 ZZ',
},
},
],
created_user: createdUser, created_user: createdUser,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), created_at: now,
updated_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss'), updated_at: now,
}, } as Marketing,
]; ];
+1 -2
View File
@@ -2,7 +2,6 @@ import moment from 'moment';
import 'moment/locale/id'; import 'moment/locale/id';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import clsx, { ClassValue } from 'clsx'; import clsx, { ClassValue } from 'clsx';
import { ChangeEvent } from 'react';
// set locale globally // set locale globally
moment.locale('id'); moment.locale('id');
@@ -32,7 +31,7 @@ export const formatNumber = (
export function formatVechicleNumber(value: string): string { export function formatVechicleNumber(value: string): string {
let result = ''; let result = '';
for (let i = 0; i < value.length; i++) { for (let i = 0; i < (value?.length ?? 0); i++) {
const curr = value[i]; const curr = value[i];
const prev = value[i - 1]; const prev = value[i - 1];
+6
View File
@@ -0,0 +1,6 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApproval } from '@/types/api/api-general';
export const ApprovalApi = new BaseApiService<BaseApproval, unknown, unknown>(
'/approvals'
);
+1 -2
View File
@@ -7,7 +7,6 @@ import {
import { import {
CreateMovementPayload, CreateMovementPayload,
Movement, Movement,
UpdateMovementPayload,
} from '@/types/api/inventory/movement'; } from '@/types/api/inventory/movement';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
@@ -23,7 +22,7 @@ export const ProductWarehouseApi = new BaseApiService<
export const MovementApi = new BaseApiService< export const MovementApi = new BaseApiService<
Movement, Movement,
CreateMovementPayload, CreateMovementPayload,
UpdateMovementPayload unknown
>('/inventory/transfers'); >('/inventory/transfers');
export const inventoryAdjustmentApi = new BaseApiService< export const inventoryAdjustmentApi = new BaseApiService<
+87 -63
View File
@@ -5,82 +5,74 @@ import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
Marketing, Marketing,
CreateMarketingPayload, CreateSalesOrderPayload,
UpdateMarketingPayload, UpdateSalesOrderPayload,
CreateDeliveryOrderPayload,
UpdateDeliveryOrderPayload,
} from '@/types/api/marketing/marketing'; } from '@/types/api/marketing/marketing';
export class MarketingService extends BaseApiService< /**
* 💡 Helper untuk membuat respons dummy
* @param data Data yang akan dimasukkan ke dalam body respons
*/
const createDummyResponse = <T>(data: T): BaseApiResponse<T> => ({
code: 200,
status: 'success',
message: 'Data retrieved successfully (MOCK)',
data: data,
});
export class SalesOrderService extends BaseApiService<
Marketing, Marketing,
CreateMarketingPayload, CreateSalesOrderPayload,
UpdateMarketingPayload UpdateSalesOrderPayload
> { > {
constructor(basePath: string = '/marketing') { constructor(basePath: string = '/marketing') {
super(basePath); super(basePath);
} }
/** // /**
* Override: Get all marketing data (dummy mode) // * Override: Mengambil semua data Marketing dari dummyMarketings
*/ // */
override async getAllFetcher( // async getAllFetcher(endpoint: string): Promise<BaseApiResponse<Marketing[]>> {
endpoint: string // // Simulasi delay jaringan
): Promise<BaseApiResponse<Marketing[]>> { // await sleep(500);
// simulasi loading
await sleep(750);
// data dummy sementara // // Filter data marketing yang valid (jika menggunakan BaseMarketing[])
const DUMMY_MARKETING_DATA: BaseApiResponse<Marketing[]> = { // const data = dummyMarketings as Marketing[];
code: 200,
status: 'success',
message: 'Berhasil mengambil data marketing (dummy)',
data: dummyMarketings,
};
return DUMMY_MARKETING_DATA; // return createDummyResponse<Marketing[]>(data);
} // }
/** // /**
* Override: Get single marketing data (dummy mode) // * Override: Mengambil satu data Marketing berdasarkan ID dari dummyMarketings
*/ // */
override async getSingle( // async getSingle(id: number): Promise<BaseApiResponse<Marketing> | undefined> {
id: number // // Simulasi delay jaringan
): Promise<BaseApiResponse<Marketing> | undefined> { // await sleep(300);
// simulasi delay
await new Promise((res) => setTimeout(res, 500));
const marketing = dummyMarketings.find((marketing) => { // const foundData = dummyMarketings.find((m) => m.id == id);
console.log('marketing', marketing);
console.log('id-m', marketing.id);
console.log('id-p', id);
console.log('id', marketing.id == id);
return marketing.id == id;
});
console.log('marketings', dummyMarketings);
console.log('marketing', marketing);
if (marketing) { // if (foundData) {
// misalnya fetch dari dummy // // Data ditemukan, kembalikan respons sukses
return { // return createDummyResponse<Marketing>(foundData as Marketing);
code: 200, // } else {
status: 'success', // // Data tidak ditemukan, simulasi respons error
message: 'Data marketing berhasil diambil.', // return {
data: marketing, // code: 404,
}; // status: 'error',
} else { // message: 'Marketing data not found (MOCK)',
// jika tidak ditemukan // };
throw { // }
code: 404, // }
status: 'error',
message: 'Data marketing tidak ditemukan.',
};
}
}
/** /**
* Approve single marketing data * Approve single marketing data
*/ */
async singleApproval( async singleApproval(
id: number, id: number,
action: 'approve' | 'reject' action: 'APPROVED' | 'REJECTED',
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
try { try {
const path = `${this.basePath}/approvals`; const path = `${this.basePath}/approvals`;
@@ -88,8 +80,8 @@ export class MarketingService extends BaseApiService<
method: 'POST', method: 'POST',
body: { body: {
action: action, action: action,
approval_ids: [id], approvable_ids: [id],
notes: `${action} marketing ${id}`, notes: notes || `${action} marketing ${id}`,
}, },
}); });
} catch (error) { } catch (error) {
@@ -103,7 +95,8 @@ export class MarketingService extends BaseApiService<
*/ */
async bulkApprovals( async bulkApprovals(
ids: number[], ids: number[],
action: 'approve' | 'reject' action: 'APPROVED' | 'REJECTED',
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
try { try {
const path = `${this.basePath}/approvals`; const path = `${this.basePath}/approvals`;
@@ -111,8 +104,8 @@ export class MarketingService extends BaseApiService<
method: 'POST', method: 'POST',
body: { body: {
action: action, action: action,
approval_ids: ids, approvable_ids: ids,
notes: `${action} marketing ${ids.join(', ')}`, notes: notes || `${action} marketing ${ids.join(', ')}`,
}, },
}); });
} catch (error) { } catch (error) {
@@ -120,6 +113,37 @@ export class MarketingService extends BaseApiService<
return undefined; return undefined;
} }
} }
/**
* Delivery
*/
async delivery(
id: number,
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> {
try {
const path = `${this.basePath}/approvals`;
return await httpClient<BaseApiResponse<{ message: string }>>(path, {
method: 'POST',
body: {
action: 'APPROVED',
approvable_ids: [id],
notes: notes || `Delivery marketing ${id}`,
},
});
} catch (error) {
console.error('Error delivery marketing:', error);
return undefined;
}
}
} }
export const MarketingApi = new MarketingService('/marketing'); export const SalesOrderApi = new SalesOrderService('/marketing/sales-orders');
export const DeliveryOrderApi = new BaseApiService<
Marketing,
CreateDeliveryOrderPayload,
UpdateDeliveryOrderPayload
>('/marketing/delivery-orders');
export const MarketingApi = new BaseApiService<Marketing, unknown, unknown>(
'/marketing'
);
+100 -3
View File
@@ -1,8 +1,17 @@
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from './base';
import { BaseApiResponse } from '@/types/api/api-general';
import {
CreateProjectFlockPayload,
ProjectFlock,
UpdateProjectFlockPayload,
} from '@/types/api/production/project-flock';
import { import {
CreateRecordingPayload, CreateRecordingPayload,
Recording, Recording,
UpdateRecordingPayload, UpdateRecordingPayload,
CreateGradingPayload,
UpdateGradingPayload,
NextDayRecording,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
@@ -11,8 +20,96 @@ export const ProjectFlockKandangApi = new BaseApiService<
unknown, unknown,
unknown unknown
>('/production/project-flock-kandangs'); >('/production/project-flock-kandangs');
export const RecordingApi = new BaseApiService< export const ProjectFlockApi = new BaseApiService<
ProjectFlock,
CreateProjectFlockPayload,
UpdateProjectFlockPayload
>('/production/project-flocks');
export class RecordingService extends BaseApiService<
Recording, Recording,
CreateRecordingPayload, CreateRecordingPayload,
UpdateRecordingPayload UpdateRecordingPayload
>('/flock/recordings'); > {
constructor(basePath: string = '') {
super(basePath);
}
async approve(
idOrIds: number | number[],
notes?: string
): Promise<BaseApiResponse<Recording[]> | undefined> {
const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
return await this.customRequest<BaseApiResponse<Recording[]>>('approvals', {
method: 'POST',
payload: {
action: 'APPROVED',
approvable_ids,
notes,
},
});
}
async reject(
idOrIds: number | number[],
notes: string = ''
): Promise<BaseApiResponse<Recording[]> | undefined> {
const approvable_ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
return await this.customRequest<BaseApiResponse<Recording[]>>('approvals', {
method: 'POST',
payload: {
action: 'REJECTED',
approvable_ids,
notes,
},
});
}
async createGrading(
payload: CreateGradingPayload
): Promise<BaseApiResponse<unknown> | undefined> {
return await this.customRequest<BaseApiResponse<unknown>>('gradings', {
method: 'POST',
payload,
});
}
async updateGrading(
gradingId: number,
payload: UpdateGradingPayload
): Promise<BaseApiResponse<unknown> | undefined> {
return await this.customRequest<BaseApiResponse<unknown>>(
`gradings/${gradingId}`,
{
method: 'PUT',
payload,
}
);
}
async deleteGrading(
gradingId: number
): Promise<BaseApiResponse<unknown> | undefined> {
return await this.customRequest<BaseApiResponse<unknown>>(
`gradings/${gradingId}`,
{
method: 'DELETE',
}
);
}
async nextDayRecording(
projectFlockId: number
): Promise<BaseApiResponse<NextDayRecording> | undefined> {
return await this.customRequest<BaseApiResponse<NextDayRecording>>(
`next-day`,
{
method: 'GET',
params: {
project_flock_kandang_id: projectFlockId,
},
}
);
}
}
export const RecordingApi = new RecordingService('/production/recordings');
+3 -2
View File
@@ -21,7 +21,8 @@ export class ChickinService extends BaseApiService<
*/ */
async singleApproval( async singleApproval(
id: number, id: number,
action: 'APPROVED' | 'REJECTED' action: 'APPROVED' | 'REJECTED',
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
try { try {
const path = `${this.basePath}/approvals`; const path = `${this.basePath}/approvals`;
@@ -30,7 +31,7 @@ export class ChickinService extends BaseApiService<
body: { body: {
action: action, action: action,
approvable_ids: [id], approvable_ids: [id],
notes: `${action} chickin ${id}`, notes: notes ?? `${action} chickin ${id}`,
}, },
}); });
} catch (error) { } catch (error) {
@@ -0,0 +1,11 @@
import { BaseApiService } from '@/services/api/base';
import {
BaseProjectFlockKandang,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
export const ProjectFlockKandangApi = new BaseApiService<
BaseProjectFlockKandang,
ProjectFlockKandang,
unknown
>('project-flock-kandang');
+38 -27
View File
@@ -8,13 +8,10 @@ import {
BaseApiResponse, BaseApiResponse,
BaseGroupedApproval, BaseGroupedApproval,
ErrorApiResponse, ErrorApiResponse,
GroupedApprovals,
SuccessApiResponse, SuccessApiResponse,
} from '@/types/api/api-general'; } from '@/types/api/api-general';
import { sleep } from '@/lib/helper';
import { httpClient } from '@/services/http/client'; import { httpClient } from '@/services/http/client';
import axios from 'axios'; import axios from 'axios';
import { Flock } from '@/types/api/master-data/flock';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { RequestOptions } from '@/services/http/base'; import { RequestOptions } from '@/services/http/base';
@@ -104,25 +101,34 @@ export class ProjectFlockService extends BaseApiService<
/** /**
* Get Next Period of Project Flock * Get Next Period of Project Flock
*/ */
async getNextPeriod(id: string): Promise< async getNextPeriod(locationId: number): Promise<
| BaseApiResponse<{ | BaseApiResponse<
flock: Flock; {
next_period: number; id: number;
}> name: string;
period: number;
}[]
>
| ErrorApiResponse | ErrorApiResponse
| SuccessApiResponse<{ | SuccessApiResponse<
flock: Flock; {
next_period: number; id: number;
}> name: string;
period: number;
}[]
>
| undefined | undefined
> { > {
try { try {
const path = `${this.basePath}/kandangs/${id}`; const path = `${this.basePath}/location/${locationId.toString()}/periods`;
return await httpClient< return await httpClient<
SuccessApiResponse<{ SuccessApiResponse<
flock: Flock; {
next_period: number; id: number;
}> name: string;
period: number;
}[]
>
>(path, { >(path, {
method: 'GET', method: 'GET',
}); });
@@ -139,36 +145,40 @@ export class ProjectFlockService extends BaseApiService<
* Approve single Project Flock * Approve single Project Flock
*/ */
async approve( async approve(
id: number id: number,
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
return await this.bulkApprovalAction([id], 'APPROVED'); return await this.bulkApprovalAction([id], 'APPROVED', notes);
} }
/** /**
* Reject single Project Flock * Reject single Project Flock
*/ */
async reject( async reject(
id: number id: number,
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
return await this.bulkApprovalAction([id], 'REJECTED'); return await this.bulkApprovalAction([id], 'REJECTED', notes);
} }
/** /**
* Approve Bulk Project Flock * Approve Bulk Project Flock
*/ */
async bulkApprove( async bulkApprove(
ids: number[] ids: number[],
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
return await this.bulkApprovalAction(ids, 'APPROVED'); return await this.bulkApprovalAction(ids, 'APPROVED', notes);
} }
/** /**
* Reject Bulk Project Flock * Reject Bulk Project Flock
*/ */
async bulkReject( async bulkReject(
ids: number[] ids: number[],
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
return await this.bulkApprovalAction(ids, 'REJECTED'); return await this.bulkApprovalAction(ids, 'REJECTED', notes);
} }
/** /**
@@ -176,7 +186,8 @@ export class ProjectFlockService extends BaseApiService<
*/ */
async bulkApprovalAction( async bulkApprovalAction(
ids: number[], ids: number[],
action: 'APPROVED' | 'REJECTED' action: 'APPROVED' | 'REJECTED',
notes?: string
): Promise<BaseApiResponse<{ message: string }> | undefined> { ): Promise<BaseApiResponse<{ message: string }> | undefined> {
try { try {
const path = `${this.basePath}/approvals`; const path = `${this.basePath}/approvals`;
@@ -185,7 +196,7 @@ export class ProjectFlockService extends BaseApiService<
body: { body: {
action: action, action: action,
approvable_ids: ids, approvable_ids: ids,
notes: `Bulk ${action} Project Flock ${ids.join(', ')}`, notes: notes ?? `Bulk ${action} Project Flock ${ids.join(', ')}`,
}, },
}); });
} catch (error) { } catch (error) {
-2
View File
@@ -71,5 +71,3 @@ export type CreateMovementPayload = {
}[]; }[];
}[]; }[];
}; };
export type UpdateMovementPayload = CreateMovementPayload;
+86 -18
View File
@@ -6,19 +6,55 @@ import {
} from '@/types/api/api-general'; } from '@/types/api/api-general';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { id } from 'react-day-picker/locale';
import { Warehouse } from '../master-data/warehouse';
/**
* Base Data Response
*/
export type BaseMarketing = { export type BaseMarketing = {
id: number; id: number;
status?: string; status?: string;
so_number: string; so_number: string;
customer: Customer;
so_docs: string;
so_date: string; so_date: string;
customer: Customer;
sales_person: CreatedUser; sales_person: CreatedUser;
notes: string; notes: string;
grand_total: number; latest_approval: BaseApproval;
approval: BaseApproval; sales_order: BaseSalesOrder[];
marketing_products?: MarketingProduct[]; delivery_order: BaseDeliveryOrder[];
};
export type BaseSalesOrder = {
id: number;
marketing_id: number;
product_warehouse_id: number;
qty: number;
unit_price: number;
avg_weight: number;
total_weight: number;
total_price: number;
product_warehouse: ProductWarehouse;
vehicle_number: string;
};
export type BaseDeliveryOrder = {
id: number;
marketing_id: number;
do_number: string;
delivery_date: string;
warehouse: Warehouse;
deliveries: BaseDelivery[];
};
export type BaseDelivery = {
product_warehouse: ProductWarehouse;
qty: number;
unit_price: number;
total_weight: number;
avg_weight: number;
total_price: number;
vehicle_number: string;
}; };
export type MarketingProduct = { export type MarketingProduct = {
@@ -29,7 +65,7 @@ export type MarketingProduct = {
total_weight: number; total_weight: number;
total_price: number; total_price: number;
product_warehouse: ProductWarehouse; product_warehouse: ProductWarehouse;
marketing_delivery_products?: MarketingDeliveryProducts; delivery_product?: MarketingDeliveryProducts;
}; };
export type MarketingDeliveryProducts = { export type MarketingDeliveryProducts = {
@@ -39,34 +75,66 @@ export type MarketingDeliveryProducts = {
avg_weight: number; avg_weight: number;
total_weight: number; total_weight: number;
total_price: number; total_price: number;
delivery_date: string; delivery_date: string | null;
vehicle_number: string; vehicle_number: string;
do_number?: string | undefined; do_number?: string | undefined; // Uncertain
}; };
export type Marketing = BaseMetadata & BaseMarketing; export type Marketing = BaseMetadata & BaseMarketing;
export type CreateMarketingPayload = { /**
* Base Data Payload
*/
export type BaseCreateMarketingPayload = {
customer_id: number; customer_id: number;
sales_person_id: number;
date: string; date: string;
notes: string; notes: string;
marketing_products: CreateMarketingProductPayload[];
}; };
export type UpdateMarketingPayload = CreateMarketingPayload;
export type CreateMarketingProductPayload = { export type BaseCreateMarketingProductPayload = {
id?: number;
vehicle_number: string; vehicle_number: string;
kandang_id: string | number | undefined; kandang_id: string | number | undefined;
kandang: Kandang | undefined;
product_warehouse_id: string | number | undefined; product_warehouse_id: string | number | undefined;
product_warehouse: ProductWarehouse | undefined;
unit_price: string | number | undefined; unit_price: string | number | undefined;
total_weight: string | number | undefined; total_weight: string | number | undefined;
qty: string | number | undefined; qty: string | number | undefined;
uom: string | undefined;
avg_weight: string | number | undefined; avg_weight: string | number | undefined;
total_price: string | number | undefined; total_price: string | number | undefined;
delivery_date?: string | null;
}; };
export type UpdateMarketingProductPayload = CreateMarketingProductPayload;
/**
* Payload Data Types Sales Order
*/
export type CreateSalesOrderPayload = BaseCreateMarketingPayload & {
marketing_products: CreateSalesOrderProductPayload[];
};
export type CreateSalesOrderProductPayload =
BaseCreateMarketingProductPayload & {
id?: number;
kandang?: Kandang | undefined;
product_warehouse?: ProductWarehouse | undefined;
};
export type CreateDeliveryOrderPayload = {
marketing_id?: number;
delivery_products: CreateDeliveryOrderProductPayload[];
};
export type CreateDeliveryOrderProductPayload =
BaseCreateMarketingProductPayload & {
id?: number;
marketing_product_id: number;
delivery_date: string;
};
export type UpdateSalesOrderProductPayload = CreateSalesOrderProductPayload;
export type UpdateDeliveryOrderProductPayload =
CreateDeliveryOrderProductPayload;
export type UpdateSalesOrderPayload = CreateSalesOrderPayload;
export type UpdateDeliveryOrderPayload = CreateDeliveryOrderPayload;
+18 -6
View File
@@ -7,12 +7,9 @@ import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
export type BaseProjectFlock = { export type BaseProjectFlock = {
id: number; id: number;
name: string; name?: string;
flock_name: string; flock_name?: string;
status: string; status: string;
flock?: Flock;
flock_i?: number;
flock_name: string;
area: Area; area: Area;
area_id: number; area_id: number;
category: string; category: string;
@@ -41,7 +38,6 @@ export type CreateProjectFlockPayload = {
category: string; category: string;
fcr_id: number; fcr_id: number;
location_id: number; location_id: number;
period: number;
kandang_ids: number[]; kandang_ids: number[];
}; };
@@ -52,6 +48,16 @@ export type ProjectFlockApprovalPayload = {
approvable_ids: number[]; approvable_ids: number[];
}; };
export type ProjectFlockKandangLookup = {
id: number;
project_flock_kandang_id: number;
project_flock_id: number;
kandang_id: number;
kandang: Kandang;
project_flock: ProjectFlock;
quantity: number;
};
export type ProjectFlockAvailableQuantity = { export type ProjectFlockAvailableQuantity = {
project_flock_id: number; project_flock_id: number;
flock_name: string; flock_name: string;
@@ -61,3 +67,9 @@ export type ProjectFlockAvailableQuantity = {
available_qty: number; available_qty: number;
}[]; }[];
}; };
export type ProjectFlockPeriods = {
id: number;
name: string;
period: number;
}[];
+134 -48
View File
@@ -1,61 +1,147 @@
import { BaseMetadata } from '@/types/api/api-general'; import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general';
import { Location } from '@/types/api/master-data/location'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Kandang } from '@/types/api/master-data/kandang';
import { Flock } from '@/types/api/master-data/flock'; export type ProductionMetrics = {
total_depletion_qty: number;
cum_depletion_rate: number;
daily_gain: number;
avg_daily_gain: number;
cum_intake: number;
fcr_value: number;
total_chick_qty: number;
daily_depletion_rate?: number;
cum_depletion?: number;
};
export type BaseRecording = { export type BaseRecording = {
id: number; id: number;
flock: Flock; project_flock_kandang_id: number;
recording_date: string; record_datetime: string;
location: Location; day: number;
coop: Kandang; created_by: User;
feed_data: { } & ProductionMetrics;
feed_name: string;
feed_qty: number; export type RecordingBW = {
feed_stock: number; id: number;
}[]; recording_id: number;
body_weight: { avg_weight: number;
chicken_weight: number; qty: number;
chicken_count: number; total_weight: number;
average_chicken_weight: number; };
}[];
vaccination: { export type RecordingDepletion = {
vaccine_name: string; id: number;
total_stock: number; recording_id: number;
used_stock: number; product_warehouse_id: number;
}[]; qty: number;
mortality: { product_warehouse: ProductWarehouse;
condition: string; };
count: number;
export type RecordingStock = {
id: number;
recording_id: number;
product_warehouse_id: number;
usage_amount?: number;
usage_qty: number;
qty: number;
pending_qty: number;
product_warehouse: ProductWarehouse;
};
export type RecordingEgg = {
id: number;
recording_id: number;
product_warehouse_id: number;
qty: number;
created_by: User;
product_warehouse: ProductWarehouse;
gradings?: {
grade: string;
qty: number;
}[]; }[];
}; };
export type Recording = BaseMetadata & BaseRecording; export type GradingEgg = {
id: number;
recording_egg_id: number;
qty: number;
grade: string;
created_by: User;
};
export type CreateRecordingPayload = { export type Recording = BaseMetadata &
flock_id: number; BaseRecording & {
recording_date: string; project_flock_category?: 'GROWING' | 'LAYING';
location_id: number; approval?: BaseApproval;
coop_id: number; egg_grading_status?: string | null;
feed_data: { egg_grading_pending_qty?: number | null;
feed_id: string; egg_grading_completed_qty?: number | null;
feed_qty: number; body_weights?: RecordingBW[];
feed_stock: number; depletions?: RecordingDepletion[];
stocks?: RecordingStock[];
eggs?: RecordingEgg[];
recording_bws?: RecordingBW[];
recording_depletions?: RecordingDepletion[];
recording_stocks?: RecordingStock[];
recording_eggs?: RecordingEgg[];
grading_eggs?: GradingEgg[];
};
export type NextDayRecording = {
project_flock_kandang_id: number;
next_day: number;
};
export type CreateGrowingRecordingPayload = {
project_flock_kandang_id: number;
body_weights: {
avg_weight: number;
qty: number;
}[]; }[];
body_weight: { stocks?: {
chicken_weight: number; product_warehouse_id: number;
chicken_count: number; qty: number;
average_chicken_weight: number;
}[]; }[];
vaccination: { depletions?: {
vaccine_id: string; product_warehouse_id: number;
total_stock: number; qty: number;
used_stock: number;
}[];
mortality: {
condition: string;
count: number;
}[]; }[];
}; };
export type CreateGradingPayload = {
eggs_grading: {
recording_egg_id: number;
grade: string;
qty: number;
}[];
};
export type UpdateGradingPayload = CreateGradingPayload;
export type CreateGradingRecordingPayload = {
eggs_grading: {
recording_egg_id: number;
grade: string;
qty: number;
}[];
};
export type CreateEggPayload = {
product_warehouse_id: number;
qty: number;
};
export type CreateLayingRecordingPayload = CreateGrowingRecordingPayload & {
eggs?: CreateEggPayload[];
};
export type CreateRecordingPayload =
| CreateGrowingRecordingPayload
| CreateLayingRecordingPayload
| CreateGradingRecordingPayload;
export type UpdateGrowingRecordingPayload = CreateGrowingRecordingPayload;
export type UpdateLayingRecordingPayload = CreateLayingRecordingPayload;
export type UpdateGradingRecordingPayload = CreateGradingRecordingPayload;
export type UpdateRecordingPayload = CreateRecordingPayload; export type UpdateRecordingPayload = CreateRecordingPayload;