mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-21 22:05:45 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6c25d8b6 |
+3
-23
@@ -15,24 +15,8 @@ stages:
|
|||||||
script:
|
script:
|
||||||
- echo "Installing dependencies..."
|
- echo "Installing dependencies..."
|
||||||
- npm ci --no-audit --no-fund
|
- npm ci --no-audit --no-fund
|
||||||
- echo "Build env used:"
|
|
||||||
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
|
|
||||||
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
|
|
||||||
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
|
|
||||||
- echo "Building Next.js static export..."
|
- echo "Building Next.js static export..."
|
||||||
- npx next build
|
- npx next build
|
||||||
- |
|
|
||||||
mkdir -p out
|
|
||||||
cat <<EOF > out/build-info.json
|
|
||||||
{
|
|
||||||
"commit": "$CI_COMMIT_SHORT_SHA",
|
|
||||||
"pipeline": "$CI_PIPELINE_ID",
|
|
||||||
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
|
||||||
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
|
|
||||||
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
|
|
||||||
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
artifacts:
|
artifacts:
|
||||||
name: 'out-$CI_COMMIT_SHORT_SHA'
|
name: 'out-$CI_COMMIT_SHORT_SHA'
|
||||||
paths:
|
paths:
|
||||||
@@ -122,11 +106,8 @@ build:dev:
|
|||||||
environment:
|
environment:
|
||||||
name: development
|
name: development
|
||||||
variables:
|
variables:
|
||||||
# NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
|
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id'
|
||||||
# NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
|
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-api-sso.mbugroup.id'
|
||||||
NEXT_PUBLIC_LTI_URL: 'https://dev-lti-erp.mbugroup.id'
|
|
||||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
|
|
||||||
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
|
|
||||||
|
|
||||||
deploy:dev:
|
deploy:dev:
|
||||||
<<: *deploy_template
|
<<: *deploy_template
|
||||||
@@ -140,7 +121,6 @@ deploy:dev:
|
|||||||
environment:
|
environment:
|
||||||
name: development
|
name: development
|
||||||
url: https://dev-lti-erp.mbugroup.id
|
url: https://dev-lti-erp.mbugroup.id
|
||||||
|
|
||||||
# ====== PRODUCTION ======
|
# ====== PRODUCTION ======
|
||||||
# build:production:
|
# build:production:
|
||||||
# <<: *build_template
|
# <<: *build_template
|
||||||
@@ -162,5 +142,5 @@ deploy:dev:
|
|||||||
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
|
# CLOUDFRONT_DISTRIBUTION_ID: "ddfd"
|
||||||
# environment:
|
# environment:
|
||||||
# name: production
|
# name: production
|
||||||
|
# url: https://royalgoldcapital.com
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
npm run format
|
npm run format
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run build
|
npm run build
|
||||||
|
|||||||
Generated
+40
-40
@@ -15,7 +15,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.5.7",
|
"next": "15.5.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -1082,9 +1082,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz",
|
||||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
"integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -1098,9 +1098,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz",
|
||||||
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
|
"integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1114,9 +1114,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz",
|
||||||
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
|
"integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1130,9 +1130,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz",
|
||||||
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
|
"integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1146,9 +1146,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz",
|
||||||
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
|
"integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1162,9 +1162,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz",
|
||||||
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
|
"integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1178,9 +1178,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz",
|
||||||
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
|
"integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1194,9 +1194,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz",
|
||||||
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
|
"integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1210,9 +1210,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz",
|
||||||
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
|
"integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5654,12 +5654,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.5.7",
|
"version": "15.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz",
|
||||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
"integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.7",
|
"@next/env": "15.5.3",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -5672,14 +5672,14 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.5.7",
|
"@next/swc-darwin-arm64": "15.5.3",
|
||||||
"@next/swc-darwin-x64": "15.5.7",
|
"@next/swc-darwin-x64": "15.5.3",
|
||||||
"@next/swc-linux-arm64-gnu": "15.5.7",
|
"@next/swc-linux-arm64-gnu": "15.5.3",
|
||||||
"@next/swc-linux-arm64-musl": "15.5.7",
|
"@next/swc-linux-arm64-musl": "15.5.3",
|
||||||
"@next/swc-linux-x64-gnu": "15.5.7",
|
"@next/swc-linux-x64-gnu": "15.5.3",
|
||||||
"@next/swc-linux-x64-musl": "15.5.7",
|
"@next/swc-linux-x64-musl": "15.5.3",
|
||||||
"@next/swc-win32-arm64-msvc": "15.5.7",
|
"@next/swc-win32-arm64-msvc": "15.5.3",
|
||||||
"@next/swc-win32-x64-msvc": "15.5.7",
|
"@next/swc-win32-x64-msvc": "15.5.3",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.5.7",
|
"next": "15.5.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
@@ -34,15 +34,13 @@ const ExpenseEditPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpenseCanBeEdited =
|
const isExpenseRejectedOrApproved =
|
||||||
!isLoadingExpense &&
|
!isLoadingExpense &&
|
||||||
isResponseSuccess(expense) &&
|
isResponseSuccess(expense) &&
|
||||||
expense.data.latest_approval.step_number !== 5 &&
|
(expense.data.approval.action === 'REJECTED' ||
|
||||||
(expense.data.latest_approval.step_number === 1 ||
|
expense.data.approval.step_number === 5);
|
||||||
expense.data.latest_approval.step_number === 2 ||
|
|
||||||
expense.data.latest_approval.step_number === 3);
|
|
||||||
|
|
||||||
if (!isLoadingExpense && !isExpenseCanBeEdited) {
|
if (isExpenseRejectedOrApproved) {
|
||||||
router.back();
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
import ExpenseRealizationForm from '@/components/pages/expense/form/ExpenseRealizationForm';
|
|
||||||
|
|
||||||
import { ExpenseApi } from '@/services/api/expense';
|
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
const ExpenseRealizationEditPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const expenseId = searchParams.get('expenseId');
|
|
||||||
|
|
||||||
const { data: expense, isLoading: isLoadingExpense } = useSWR(
|
|
||||||
expenseId,
|
|
||||||
(id: number) => ExpenseApi.getSingle(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!expenseId) {
|
|
||||||
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 (!isLoadingExpense && (!expense || isResponseError(expense))) {
|
|
||||||
router.replace('/404');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpenseRealizationCanBeEdited =
|
|
||||||
!isLoadingExpense &&
|
|
||||||
isResponseSuccess(expense) &&
|
|
||||||
expense.data.latest_approval.action !== 'REJECTED' &&
|
|
||||||
(expense.data.latest_approval.step_number === 4 ||
|
|
||||||
expense.data.latest_approval.step_number === 5);
|
|
||||||
|
|
||||||
if (!isLoadingExpense && !isExpenseRealizationCanBeEdited) {
|
|
||||||
router.back();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full p-4 flex flex-row justify-center'>
|
|
||||||
{isLoadingExpense && (
|
|
||||||
<span className='loading loading-spinner loading-xl' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingExpense && isResponseSuccess(expense) && (
|
|
||||||
<ExpenseRealizationForm type='edit' initialValues={expense.data} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpenseRealizationEditPage;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
|
||||||
|
|
||||||
const Layout = ({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) => {
|
|
||||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
import ExpenseRealizationForm from '@/components/pages/expense/form/ExpenseRealizationForm';
|
|
||||||
|
|
||||||
import { ExpenseApi } from '@/services/api/expense';
|
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
const ExpenseRealization = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const expenseId = searchParams.get('expenseId');
|
|
||||||
|
|
||||||
const { data: expense, isLoading: isLoadingExpense } = useSWR(
|
|
||||||
expenseId,
|
|
||||||
(id: number) => ExpenseApi.getSingle(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!expenseId) {
|
|
||||||
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 (!isLoadingExpense && (!expense || isResponseError(expense))) {
|
|
||||||
router.replace('/404');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpenseCanBeRealized =
|
|
||||||
isResponseSuccess(expense) &&
|
|
||||||
expense.data.latest_approval.action !== 'REJECTED' &&
|
|
||||||
expense.data.latest_approval.step_number === 3;
|
|
||||||
|
|
||||||
if (isResponseSuccess(expense) && !isExpenseCanBeRealized) {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
|
||||||
<span className='loading loading-spinner loading-xl' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full p-4 flex flex-row justify-center'>
|
|
||||||
{isLoadingExpense && (
|
|
||||||
<span className='loading loading-spinner loading-xl' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoadingExpense && isResponseSuccess(expense) && (
|
|
||||||
<ExpenseRealizationForm initialValues={expense.data} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpenseRealization;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm';
|
|
||||||
|
|
||||||
const AddPurchaseRequest = () => {
|
|
||||||
return (
|
|
||||||
<div className='w-full p-4 flex flex-row justify-center'>
|
|
||||||
<PurchaseRequestForm />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddPurchaseRequest;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import PurchaseRequestForm from '@/components/pages/purchase/form/request/PurchaseRequestForm';
|
|
||||||
import { PurchaseApi } from '@/services/api/purchase';
|
|
||||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
const PurchaseEdit = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const purchaseId = searchParams.get('purchaseId');
|
|
||||||
|
|
||||||
const { data: purchase, isLoading: isLoadingPurchase } = useSWR(
|
|
||||||
purchaseId,
|
|
||||||
(id: number) => PurchaseApi.getSingle(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!purchaseId) {
|
|
||||||
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 (!isLoadingPurchase && (!purchase || isResponseError(purchase))) {
|
|
||||||
router.replace('/404');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full p-4 flex flex-row justify-center'>
|
|
||||||
{isLoadingPurchase && (
|
|
||||||
<span className='loading loading-spinner loading-xl' />
|
|
||||||
)}
|
|
||||||
{!isLoadingPurchase && isResponseSuccess(purchase) && (
|
|
||||||
<PurchaseRequestForm type='edit' initialValues={purchase.data} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PurchaseEdit;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import PurchaseOrderDetail from '@/components/pages/purchase/order/PurchaseOrderDetail';
|
|
||||||
import { PurchaseApi } from '@/services/api/purchase';
|
|
||||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
const PurchaseDetail = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const purchaseId = searchParams.get('purchaseId');
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: purchase,
|
|
||||||
isLoading: isLoadingPurchase,
|
|
||||||
mutate: mutatePurchase,
|
|
||||||
} = useSWR(purchaseId, (id: number) => PurchaseApi.getSingle(id));
|
|
||||||
|
|
||||||
if (!purchaseId) {
|
|
||||||
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 (!isLoadingPurchase && (!purchase || isResponseError(purchase))) {
|
|
||||||
router.replace('/404');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full p-4'>
|
|
||||||
{isLoadingPurchase && (
|
|
||||||
<div className='w-full flex flex-row justify-center items-center'>
|
|
||||||
<span className='loading loading-spinner loading-xl' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingPurchase && isResponseSuccess(purchase) && (
|
|
||||||
<PurchaseOrderDetail
|
|
||||||
type='detail'
|
|
||||||
initialValues={purchase.data}
|
|
||||||
refetchData={mutatePurchase}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PurchaseDetail;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
|
||||||
|
|
||||||
const Layout = ({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) => {
|
|
||||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import PurchaseTable from '@/components/pages/purchase/PurchaseTable';
|
|
||||||
|
|
||||||
const Purchase = () => {
|
|
||||||
return (
|
|
||||||
<section className='w-full p-4'>
|
|
||||||
<PurchaseTable />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Purchase;
|
|
||||||
+33
-127
@@ -1,11 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, ReactNode, useState } from 'react';
|
import { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Collapse from './Collapse';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
|
|
||||||
export interface CardProps
|
export interface CardProps
|
||||||
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
|
extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
|
||||||
@@ -13,13 +11,8 @@ export interface CardProps
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
imageWidth?: number;
|
|
||||||
imageHeight?: number;
|
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
collapsible?: boolean;
|
|
||||||
defaultCollapsed?: boolean;
|
|
||||||
onCollapsedChange?: (collapsed: boolean) => void;
|
|
||||||
className?: {
|
className?: {
|
||||||
wrapper?: string;
|
wrapper?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
@@ -28,7 +21,6 @@ export interface CardProps
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
actions?: string;
|
actions?: string;
|
||||||
footer?: string;
|
footer?: string;
|
||||||
collapsible?: string;
|
|
||||||
};
|
};
|
||||||
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
|
variant?: 'default' | 'compact' | 'bordered' | 'shadow' | 'image-full';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
@@ -39,27 +31,14 @@ const Card = ({
|
|||||||
subtitle,
|
subtitle,
|
||||||
image,
|
image,
|
||||||
imageAlt,
|
imageAlt,
|
||||||
imageWidth,
|
|
||||||
imageHeight,
|
|
||||||
actions,
|
actions,
|
||||||
footer,
|
footer,
|
||||||
collapsible,
|
|
||||||
defaultCollapsed = false,
|
|
||||||
onCollapsedChange,
|
|
||||||
className,
|
className,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: CardProps) => {
|
}: CardProps) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
|
||||||
|
|
||||||
const handleCollapsedChange = (open: boolean) => {
|
|
||||||
const collapsed = !open;
|
|
||||||
setIsCollapsed(collapsed);
|
|
||||||
onCollapsedChange?.(collapsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCardClasses = () => {
|
const getCardClasses = () => {
|
||||||
const baseClasses = 'card bg-base-100';
|
const baseClasses = 'card bg-base-100';
|
||||||
|
|
||||||
@@ -85,31 +64,11 @@ const Card = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getImageDimensions = () => {
|
|
||||||
if (variant === 'image-full') {
|
|
||||||
return {
|
|
||||||
width: imageWidth || 128,
|
|
||||||
height: imageHeight || 128,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardWidths = {
|
|
||||||
sm: 256, // w-64
|
|
||||||
md: 384, // w-96
|
|
||||||
lg: 448, // w-[28rem]
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: imageWidth || cardWidths[size],
|
|
||||||
height: imageHeight || 192,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getImageClasses = () => {
|
const getImageClasses = () => {
|
||||||
if (variant === 'image-full') {
|
if (variant === 'image-full') {
|
||||||
return cn('object-cover', className?.image);
|
return cn('w-32 h-32 object-cover', className?.image);
|
||||||
}
|
}
|
||||||
return cn('w-full object-cover', className?.image);
|
return cn('h-48 object-cover', className?.image);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBodyClasses = () => {
|
const getBodyClasses = () => {
|
||||||
@@ -144,98 +103,45 @@ const Card = ({
|
|||||||
return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
|
return cn('border-t border-base-300 mt-4 pt-4', className?.footer);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCardContent = () => {
|
|
||||||
const hasContent = children || actions || footer;
|
|
||||||
|
|
||||||
const titleContent = (
|
|
||||||
<div className='group flex items-center !justify-between w-full'>
|
|
||||||
<div className='flex-1'>
|
|
||||||
{title && <h2 className={getTitleClasses()}>{title}</h2>}
|
|
||||||
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
{collapsible && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleCollapsedChange(!isCollapsed)}
|
|
||||||
className='btn btn-ghost btn-sm btn-circle'
|
|
||||||
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
isCollapsed
|
|
||||||
? 'material-symbols:expand-more'
|
|
||||||
: 'material-symbols:expand-less'
|
|
||||||
}
|
|
||||||
width={20}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cardContent = (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
{children}
|
|
||||||
{actions && <div className={getActionsClasses()}>{actions}</div>}
|
|
||||||
{footer && <div className={getFooterClasses()}>{footer}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{image && (
|
|
||||||
<figure>
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt={imageAlt || title || 'Card image'}
|
|
||||||
width={getImageDimensions().width}
|
|
||||||
height={getImageDimensions().height}
|
|
||||||
className={getImageClasses()}
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
)}
|
|
||||||
<div className={getBodyClasses()}>
|
|
||||||
{collapsible && hasContent ? (
|
|
||||||
<Collapse
|
|
||||||
variant='default'
|
|
||||||
bordered={false}
|
|
||||||
open={!isCollapsed}
|
|
||||||
onOpenChange={handleCollapsedChange}
|
|
||||||
title={titleContent}
|
|
||||||
titleClassName='w-full cursor-pointer'
|
|
||||||
contentClassName='p-0'
|
|
||||||
fullWidth={true}
|
|
||||||
>
|
|
||||||
{cardContent}
|
|
||||||
</Collapse>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{(title || subtitle) && (
|
|
||||||
<div className='mb-4'>
|
|
||||||
{title && <h2 className={getTitleClasses()}>{title}</h2>}
|
|
||||||
{subtitle && (
|
|
||||||
<p className={getSubtitleClasses()}>{subtitle}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasContent && cardContent}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === 'image-full' && image) {
|
if (variant === 'image-full' && image) {
|
||||||
return (
|
return (
|
||||||
<div className={getCardClasses()} {...props}>
|
<div className={getCardClasses()} {...props}>
|
||||||
{renderCardContent()}
|
<figure>
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={imageAlt || title || 'Card image'}
|
||||||
|
className={getImageClasses()}
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<div className={getBodyClasses()}>
|
||||||
|
{title && <h2 className={getTitleClasses()}>{title}</h2>}
|
||||||
|
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
|
||||||
|
{children}
|
||||||
|
{actions && <div className={getActionsClasses()}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
{footer && <div className={getFooterClasses()}>{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={getCardClasses()} {...props}>
|
<div className={getCardClasses()} {...props}>
|
||||||
{renderCardContent()}
|
{image && (
|
||||||
|
<figure>
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={imageAlt || title || 'Card image'}
|
||||||
|
className={getImageClasses()}
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
<div className={getBodyClasses()}>
|
||||||
|
{title && <h2 className={getTitleClasses()}>{title}</h2>}
|
||||||
|
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
|
||||||
|
{children}
|
||||||
|
{actions && <div className={getActionsClasses()}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
{footer && <div className={getFooterClasses()}>{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ export type CollapseProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Allow only one open at a time by switching to radio input */
|
/** Allow only one open at a time by switching to radio input */
|
||||||
asRadio?: boolean;
|
asRadio?: boolean;
|
||||||
/** Force full width instead of auto-fit when collapsed
|
|
||||||
* (Khusus justify-between dan justify-end) */
|
|
||||||
fullWidth?: boolean;
|
|
||||||
/** Extra classnames */
|
/** Extra classnames */
|
||||||
className?: string;
|
className?: string;
|
||||||
titleClassName?: string;
|
titleClassName?: string;
|
||||||
@@ -47,7 +44,6 @@ export const Collapse = ({
|
|||||||
bordered,
|
bordered,
|
||||||
disabled,
|
disabled,
|
||||||
asRadio = false,
|
asRadio = false,
|
||||||
fullWidth,
|
|
||||||
className,
|
className,
|
||||||
titleClassName,
|
titleClassName,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
@@ -72,9 +68,9 @@ export const Collapse = ({
|
|||||||
'collapse',
|
'collapse',
|
||||||
variant === 'arrow' && 'collapse-arrow',
|
variant === 'arrow' && 'collapse-arrow',
|
||||||
variant === 'plus' && 'collapse-plus',
|
variant === 'plus' && 'collapse-plus',
|
||||||
bordered && 'border base-content/20 border-opacity-20 rounded-box',
|
bordered && 'border base-content/20 border-opacity-20 rounded',
|
||||||
disabled && 'opacity-60 pointer-events-none',
|
disabled && 'opacity-60 pointer-events-none',
|
||||||
!fullWidth && !open && 'w-fit',
|
!open && 'w-fit',
|
||||||
className
|
className
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,15 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
export const useModal = (isNestingModal = false) => {
|
export const useModal = () => {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const openModal = useCallback(() => {
|
const openModal = useCallback(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
if (isNestingModal) {
|
ref.current.show();
|
||||||
ref.current.showModal();
|
|
||||||
} else {
|
|
||||||
ref.current.show();
|
|
||||||
}
|
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, [isNestingModal]);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Menu from '@/components/menu/Menu';
|
import Menu from '@/components/menu/Menu';
|
||||||
import MenuItem from '@/components/menu/MenuItem';
|
import MenuItem from '@/components/menu/MenuItem';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
|
|
||||||
import { useAuth } from '@/services/hooks/useAuth';
|
|
||||||
import { AuthApi } from '@/services/api/auth';
|
|
||||||
import { isResponseError } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
title: string;
|
title: string;
|
||||||
toggleSidebar?: () => void;
|
toggleSidebar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
||||||
const { setUser } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const logoutClickHandler = async () => {
|
|
||||||
const logoutRes = await AuthApi.logout();
|
|
||||||
|
|
||||||
if (isResponseError(logoutRes)) {
|
|
||||||
toast.error('Gagal logout! Coba lagi!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(undefined);
|
|
||||||
router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='navbar px-4 bg-base-100 shadow-sm'>
|
<div className='navbar px-4 bg-base-100 shadow-sm'>
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
@@ -64,7 +42,8 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'>
|
<Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'>
|
||||||
<MenuItem title='Logout' onClick={logoutClickHandler} />
|
<MenuItem title='Settings' href='#' />
|
||||||
|
<MenuItem title='Logout' href='#' />
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,147 @@ import useSWRImmutable from 'swr/immutable';
|
|||||||
|
|
||||||
import { useAuth } from '@/services/hooks/useAuth';
|
import { useAuth } from '@/services/hooks/useAuth';
|
||||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
|
import { GetMeResponse } from '@/types/api/api-general';
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
|
// TODO: delete this later, DONT HARDCODE USER DATA
|
||||||
|
const DUMMY_USER = {
|
||||||
|
id: 1,
|
||||||
|
email: 'admin@mbugroup.id',
|
||||||
|
npk: '0001',
|
||||||
|
name: 'Super Admin',
|
||||||
|
image: null,
|
||||||
|
created_at: '2025-09-30T03:24:20.899229Z',
|
||||||
|
updated_at: '2025-09-30T03:24:20.899229Z',
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: 'mbu.super_admin',
|
||||||
|
name: 'MBU Administrator',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'mbu:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'mbu:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'mbu:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 1,
|
||||||
|
name: 'PT Mitra Berlian Unggas',
|
||||||
|
alias: 'MBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: 'lti.super_admin',
|
||||||
|
name: 'LTI Administrator',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'lti:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'lti:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: 'lti:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 2,
|
||||||
|
name: 'PT Lumbung Telur Indonesia',
|
||||||
|
alias: 'LTI',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
key: 'manbu.super_admin',
|
||||||
|
name: 'MANBU Administrator',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'manbu:purchase:read',
|
||||||
|
action: 'read',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: 'manbu:purchase:create',
|
||||||
|
action: 'create',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: 'manbu:purchase:approve',
|
||||||
|
action: 'approve',
|
||||||
|
client: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PT Mandiri Berlian Unggas',
|
||||||
|
alias: 'MANBU',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
interface RequireAuthProps {
|
interface RequireAuthProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -18,20 +156,17 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setUser, setIsLoadingUser } = useAuth();
|
const { setUser, setIsLoadingUser } = useAuth();
|
||||||
|
|
||||||
const {
|
const { data: userResponse, isLoading: isLoadingUserResponse } =
|
||||||
data: userResponse,
|
useSWRImmutable<GetMeResponse & { ok?: boolean }, unknown, SWRHttpKey>(
|
||||||
isLoading: isLoadingUserResponse,
|
'/auth/sso/userinfo',
|
||||||
error: userErrorResponse,
|
httpClientFetcher,
|
||||||
} = useSWRImmutable<
|
{
|
||||||
GetMeResponse & { ok?: boolean },
|
shouldRetryOnError: false,
|
||||||
AxiosError<BaseApiResponse>,
|
revalidateOnFocus: false,
|
||||||
SWRHttpKey
|
revalidateOnReconnect: false,
|
||||||
>('/sso/userinfo', httpClientFetcher, {
|
refreshInterval: 0,
|
||||||
shouldRetryOnError: false,
|
}
|
||||||
revalidateOnFocus: false,
|
);
|
||||||
revalidateOnReconnect: false,
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoadingUser(isLoadingUserResponse);
|
setIsLoadingUser(isLoadingUserResponse);
|
||||||
@@ -40,25 +175,23 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isResponseSuccess(userResponse)) {
|
if (isResponseSuccess(userResponse)) {
|
||||||
setUser(userResponse.data);
|
setUser(userResponse.data);
|
||||||
} else if (
|
} else {
|
||||||
isResponseError(userErrorResponse?.response?.data) &&
|
// router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
|
||||||
typeof window !== 'undefined'
|
// TODO: remove this later, DONT HARDCODE USER DATA
|
||||||
) {
|
setUser(DUMMY_USER);
|
||||||
router.replace(
|
|
||||||
`${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [userResponse, userErrorResponse, setIsLoadingUser, setUser]);
|
}, [userResponse, setIsLoadingUser, setUser]);
|
||||||
|
|
||||||
if (isLoadingUserResponse && !userResponse && !userErrorResponse) {
|
// TODO: uncomment this later
|
||||||
return (
|
// if (isLoadingUserResponse && !userResponse) {
|
||||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
// return (
|
||||||
<span className='loading loading-spinner loading-xl' />
|
// <div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
</div>
|
// <span className='loading loading-spinner loading-xl' />
|
||||||
);
|
// </div>
|
||||||
}
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
return <>{isResponseSuccess(userResponse) && children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RequireAuth;
|
export default RequireAuth;
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { cn, formatDate } from '@/lib/helper';
|
import { cn, formatDate } from '@/lib/helper';
|
||||||
import Modal, { useModal } from '../Modal';
|
import Modal, { useModal } from '@/components/Modal';
|
||||||
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
|
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
|
||||||
import 'react-day-picker/dist/style.css';
|
import 'react-day-picker/dist/style.css';
|
||||||
import Button from '../Button';
|
import Button from '@/components/Button';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
export interface DateInputProps {
|
export interface DateInputProps {
|
||||||
@@ -34,7 +34,6 @@ export interface DateInputProps {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isRange?: boolean;
|
isRange?: boolean;
|
||||||
isNestedModal?: boolean; // New prop to indicate if used inside another modal
|
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
@@ -59,7 +58,6 @@ const DateInput = ({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isRange = false,
|
isRange = false,
|
||||||
isNestedModal = false,
|
|
||||||
}: DateInputProps) => {
|
}: DateInputProps) => {
|
||||||
const [internalError, setInternalError] = useState<string | null>(null);
|
const [internalError, setInternalError] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<Date | undefined>();
|
const [selected, setSelected] = useState<Date | undefined>();
|
||||||
@@ -76,7 +74,7 @@ const DateInput = ({
|
|||||||
? new Date(max.split('/').reverse().join('-'))
|
? new Date(max.split('/').reverse().join('-'))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const calendarModal = useModal(isNestedModal);
|
const calendarModal = useModal();
|
||||||
|
|
||||||
// --- Sync value props ---
|
// --- Sync value props ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -266,7 +264,7 @@ const DateInput = ({
|
|||||||
ref={calendarModal.ref}
|
ref={calendarModal.ref}
|
||||||
className={{
|
className={{
|
||||||
modal: 'rounded',
|
modal: 'rounded',
|
||||||
modalBox: `!max-w-max min-h-${isRange ? '124' : '110'} flex flex-col`,
|
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`,
|
||||||
}}
|
}}
|
||||||
closeOnBackdrop
|
closeOnBackdrop
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ChangeEvent, ChangeEventHandler, useEffect, useState } from 'react';
|
|
||||||
import { useDebounce } from 'use-debounce';
|
|
||||||
|
|
||||||
import TextArea, { TextAreaProps } from '@/components/input/TextArea';
|
|
||||||
|
|
||||||
interface DebouncedTextAreaProps extends TextAreaProps {
|
|
||||||
delay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DebouncedTextArea = (props: DebouncedTextAreaProps) => {
|
|
||||||
const { delay, onChange } = props;
|
|
||||||
|
|
||||||
const [internalChangeEvent, setInternalChangeEvent] =
|
|
||||||
useState<ChangeEvent<HTMLTextAreaElement>>();
|
|
||||||
const [internalValue, setInternalValue] = useState(props.value);
|
|
||||||
|
|
||||||
const [debouncedChangeEvent] = useDebounce(internalChangeEvent, delay ?? 300);
|
|
||||||
const [debouncedValue] = useDebounce(internalValue, delay ?? 300);
|
|
||||||
|
|
||||||
const internalChangeHandler: ChangeEventHandler<HTMLTextAreaElement> = (
|
|
||||||
e
|
|
||||||
) => {
|
|
||||||
setInternalValue(e.target.value);
|
|
||||||
setInternalChangeEvent(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (debouncedChangeEvent) {
|
|
||||||
onChange?.(debouncedChangeEvent);
|
|
||||||
}
|
|
||||||
}, [debouncedValue]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextArea
|
|
||||||
{...props}
|
|
||||||
value={internalValue}
|
|
||||||
onChange={internalChangeHandler}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DebouncedTextArea;
|
|
||||||
@@ -18,7 +18,6 @@ import { useCallback, useMemo } from 'react';
|
|||||||
export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE';
|
export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE';
|
||||||
|
|
||||||
export type ApprovalStepLog = {
|
export type ApprovalStepLog = {
|
||||||
action: string;
|
|
||||||
action_by?: string;
|
action_by?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
@@ -66,55 +65,28 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => {
|
|||||||
position='right'
|
position='right'
|
||||||
className={{
|
className={{
|
||||||
wrapper: 'md:tooltip-bottom',
|
wrapper: 'md:tooltip-bottom',
|
||||||
content: 'p-0 rounded overflow-hidden',
|
|
||||||
}}
|
}}
|
||||||
content={
|
content={
|
||||||
<>
|
<>
|
||||||
{approval.logs && approval.logs.length > 0 && (
|
{approval.logs && approval.logs.length > 0 && (
|
||||||
<div className='flex flex-col gap-0'>
|
<div className='flex flex-col gap-2'>
|
||||||
{approval.logs?.map((approvalLog, logIdx) => {
|
{approval.logs?.map((approvalLog, logIdx) => (
|
||||||
const action =
|
<div
|
||||||
approvalLog.action === 'CREATED'
|
key={logIdx}
|
||||||
? 'Dibuat'
|
className='flex flex-col text-base text-start'
|
||||||
: approvalLog.action === 'UPDATED'
|
>
|
||||||
? 'Diperbarui'
|
{approvalLog.date && (
|
||||||
: approvalLog.action === 'APPROVED'
|
<span>
|
||||||
? 'Disetujui'
|
{formatDate(
|
||||||
: approvalLog.action === 'REJECTED'
|
approvalLog.date,
|
||||||
? 'Ditolak'
|
'YYYY-MM-DD, HH:mm:ss'
|
||||||
: '-';
|
)}
|
||||||
|
</span>
|
||||||
return (
|
)}
|
||||||
<div
|
<span>Oleh: {approvalLog.action_by ?? '-'}</span>
|
||||||
key={logIdx}
|
<span>Catatan: {approvalLog.notes ?? '-'}</span>
|
||||||
className={cn(
|
</div>
|
||||||
'p-2 flex flex-col text-base text-start',
|
))}
|
||||||
{
|
|
||||||
'bg-success text-success-content':
|
|
||||||
approvalLog.action === 'APPROVED',
|
|
||||||
'bg-error text-error-content':
|
|
||||||
approvalLog.action === 'REJECTED',
|
|
||||||
'bg-info text-info-content':
|
|
||||||
approvalLog.action === 'CREATED',
|
|
||||||
'bg-warning text-warning-content':
|
|
||||||
approvalLog.action === 'UPDATED',
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{approvalLog.date && (
|
|
||||||
<span>
|
|
||||||
{formatDate(
|
|
||||||
approvalLog.date,
|
|
||||||
'YYYY-MM-DD, HH:mm:ss'
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>Aksi: {action}</span>
|
|
||||||
<span>Oleh: {approvalLog.action_by ?? '-'}</span>
|
|
||||||
<span>Catatan: {approvalLog.notes ?? '-'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -158,8 +130,6 @@ export const formatGroupedApprovalsToApprovalSteps = (
|
|||||||
const lastStepNumber =
|
const lastStepNumber =
|
||||||
groupedApprovals[groupedApprovals.length - 1]?.step_number;
|
groupedApprovals[groupedApprovals.length - 1]?.step_number;
|
||||||
|
|
||||||
const isLatestApprovalRejected = latestApproval.action === 'REJECTED';
|
|
||||||
|
|
||||||
if (!approvalGroup && currentStepNumber <= lastStepNumber) {
|
if (!approvalGroup && currentStepNumber <= lastStepNumber) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
|
`Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
|
||||||
@@ -202,10 +172,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (approvalGroup.step_number === latestApproval.step_number + 1) {
|
||||||
approvalGroup.step_number === latestApproval.step_number + 1 &&
|
|
||||||
!isLatestApprovalRejected
|
|
||||||
) {
|
|
||||||
approvalStatus = 'WAITING';
|
approvalStatus = 'WAITING';
|
||||||
} else {
|
} else {
|
||||||
approvalStatus = 'IDLE';
|
approvalStatus = 'IDLE';
|
||||||
@@ -216,7 +183,6 @@ export const formatGroupedApprovalsToApprovalSteps = (
|
|||||||
action_by: approval.action_by.name,
|
action_by: approval.action_by.name,
|
||||||
date: approval.action_at,
|
date: approval.action_at,
|
||||||
notes: approval.notes,
|
notes: approval.notes,
|
||||||
action: approval.action,
|
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,157 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Tabs from '@/components/Tabs';
|
import { useModal } from '@/components/Modal';
|
||||||
import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestContent';
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent';
|
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||||
|
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||||
|
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||||
|
import DropFileInput from '@/components/input/DropFileInput';
|
||||||
|
|
||||||
import { Expense } from '@/types/api/expense';
|
import { Expense } from '@/types/api/expense';
|
||||||
|
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||||
|
import {
|
||||||
|
UploadRequestDocumentsFormSchema,
|
||||||
|
UploadRequestDocumentsFormValues,
|
||||||
|
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||||
|
|
||||||
interface ExpenseDetailProps {
|
interface ExpenseDetailProps {
|
||||||
initialValues?: Expense;
|
initialValues?: Expense;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||||
const [activeTab, setActiveTab] = useState<string>('request');
|
const router = useRouter();
|
||||||
|
|
||||||
const expenseDetailTabs = useMemo(() => {
|
// Modal hooks
|
||||||
const validTabs = [
|
const deleteModal = useModal();
|
||||||
{
|
const approveModal = useModal();
|
||||||
id: 'request',
|
const rejectModal = useModal();
|
||||||
label: 'Pengajuan',
|
|
||||||
content: <ExpenseRequestContent initialValues={initialValues} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (
|
// Modal loading state
|
||||||
initialValues?.latest_approval &&
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
initialValues?.latest_approval.step_number >= 4 &&
|
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||||
initialValues.latest_approval.action !== 'REJECTED'
|
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||||
) {
|
|
||||||
validTabs.push({
|
const isLatestApprovalRejectedOrDone =
|
||||||
id: 'realization',
|
initialValues?.approval &&
|
||||||
label: 'Realisasi',
|
(initialValues.approval.action === 'REJECTED' ||
|
||||||
content: <ExpenseRealizationContent initialValues={initialValues} />,
|
initialValues.approval.step_number === 5);
|
||||||
});
|
|
||||||
|
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||||
|
initialValues: {
|
||||||
|
request_documents: [],
|
||||||
|
},
|
||||||
|
validationSchema: UploadRequestDocumentsFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments(
|
||||||
|
initialValues?.id as number,
|
||||||
|
values.request_documents
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(addRequestDocumentsRes)) {
|
||||||
|
toast.success(addRequestDocumentsRes.message);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
toast.error(String(addRequestDocumentsRes?.message));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteExpenseClickHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveClickHandler = () => {
|
||||||
|
approveModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectClickHandler = () => {
|
||||||
|
rejectModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal confirm click handler
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ExpenseApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
toast.success('Berhasil menghapus data biaya operasional!');
|
||||||
|
router.push('/expense');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Gagal menghapus data biaya operasional!');
|
||||||
|
} finally {
|
||||||
|
deleteModal.closeModal();
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalApproveClickHandler = async (notes: string) => {
|
||||||
|
setIsApproveLoading(true);
|
||||||
|
|
||||||
|
const approveResponse = await ExpenseApi.approve(
|
||||||
|
initialValues?.id as number,
|
||||||
|
notes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(approveResponse)) {
|
||||||
|
approveModal.closeModal();
|
||||||
|
|
||||||
|
toast.success('Berhasil approve pengajuan biaya operasional!');
|
||||||
|
router.push('/expense');
|
||||||
|
} else {
|
||||||
|
approveModal.closeModal();
|
||||||
|
|
||||||
|
toast.error('Gagal approve pengajuan biaya operasional!');
|
||||||
}
|
}
|
||||||
|
|
||||||
return validTabs;
|
setIsApproveLoading(false);
|
||||||
}, [initialValues]);
|
};
|
||||||
|
|
||||||
|
const confirmationModalRejectClickHandler = async (notes: string) => {
|
||||||
|
setIsRejectLoading(true);
|
||||||
|
|
||||||
|
const rejectResponse = await ExpenseApi.reject(
|
||||||
|
initialValues?.id as number,
|
||||||
|
notes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(rejectResponse)) {
|
||||||
|
rejectModal.closeModal();
|
||||||
|
|
||||||
|
toast.success('Berhasil reject pengajuan biaya operasional!');
|
||||||
|
router.push('/expense');
|
||||||
|
} else {
|
||||||
|
rejectModal.closeModal();
|
||||||
|
|
||||||
|
toast.error('Gagal reject pengajuan biaya operasional!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRejectLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDocumentsChangeHandler = (val: File[]) => {
|
||||||
|
formik.setFieldTouched('request_documents', true);
|
||||||
|
formik.setFieldValue('request_documents', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
|
||||||
|
const newRequestDocuments = formik.values.request_documents;
|
||||||
|
|
||||||
|
newRequestDocuments?.splice(deletedFileIdx, 1);
|
||||||
|
|
||||||
|
formik.setFieldValue('request_documents', newRequestDocuments);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -59,16 +171,335 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
|||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Tabs
|
<div className='w-full mt-4 flex flex-col gap-4'>
|
||||||
activeTabId={activeTab}
|
{/* TODO: apply RBAC */}
|
||||||
onTabChange={setActiveTab}
|
{!isLatestApprovalRejectedOrDone && (
|
||||||
tabs={expenseDetailTabs}
|
<div className='w-full max-w-3xl mx-auto flex flex-row justify-end gap-2'>
|
||||||
variant='lifted'
|
<Button
|
||||||
className={{
|
variant='outline'
|
||||||
wrapper: 'max-w-5xl mx-auto mt-4',
|
color='success'
|
||||||
}}
|
onClick={approveClickHandler}
|
||||||
/>
|
className='w-full sm:w-fit'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:check' width={24} height={24} />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='error'
|
||||||
|
onClick={rejectClickHandler}
|
||||||
|
className='w-full sm:w-fit'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||||
|
className='px-4 ml-2'
|
||||||
|
>
|
||||||
|
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteExpenseClickHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TODO: add and integrate ApprovalSteps component with API */}
|
||||||
|
|
||||||
|
<div className='overflow-x-auto w-full max-w-3xl mx-auto'>
|
||||||
|
<table className='table table-sm table-zebra'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Nomor PO</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.po_number ?? '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nomor Referensi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.reference_number}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Lokasi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.location.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Kandang</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
{initialValues?.kandangs
|
||||||
|
.map((item) => item.name)
|
||||||
|
.join(', ')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Vendor</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.vendor.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Tanggal Transaksi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
{formatDate(
|
||||||
|
initialValues?.transaction_date,
|
||||||
|
'DD MMMM YYYY'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Tanggal Realisasi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
{initialValues?.realization_date
|
||||||
|
? formatDate(
|
||||||
|
initialValues?.realization_date,
|
||||||
|
'DD MMMM YYYY'
|
||||||
|
)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nama Pengaju</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.created_user.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nominal Biaya</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{formatCurrency(initialValues?.nominal ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nominal Sudah Bayar</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{formatCurrency(initialValues?.paid ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nominal Sisa Bayar</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{formatCurrency(initialValues?.remaining_cost ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status Pencairan</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
<RealizationStatusBadge
|
||||||
|
approval={initialValues?.approval}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status Biaya</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
<ExpenseStatusBadge approval={initialValues?.approval} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Dokumen Pengajuan</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
{initialValues?.request_documents.length === 0 && '-'}
|
||||||
|
|
||||||
|
{initialValues?.request_documents &&
|
||||||
|
initialValues?.request_documents.length > 0 && (
|
||||||
|
<ul className='list-disc'>
|
||||||
|
{initialValues?.request_documents.map(
|
||||||
|
(requestDocument, requestDocumentIdx) => (
|
||||||
|
<li key={requestDocumentIdx}>
|
||||||
|
<Link
|
||||||
|
href={requestDocument.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='text-blue-500 underline'
|
||||||
|
>
|
||||||
|
{requestDocument.name}{' '}
|
||||||
|
<Icon
|
||||||
|
icon='cuida:open-in-new-tab-outline'
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
className='inline'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<DropFileInput
|
||||||
|
name='request_documents'
|
||||||
|
values={formik.values.request_documents}
|
||||||
|
onChange={requestDocumentsChangeHandler}
|
||||||
|
onDelete={requestDocumentsDeleteHandler}
|
||||||
|
accept={{
|
||||||
|
...ACCEPTED_FILE_TYPE.PDF,
|
||||||
|
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||||
|
}}
|
||||||
|
maxFiles={10}
|
||||||
|
className={{
|
||||||
|
wrapper: 'mt-2',
|
||||||
|
inputWrapper: 'flex items-center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formik.values.request_documents &&
|
||||||
|
formik.values.request_documents.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={formik.submitForm}
|
||||||
|
disabled={formik.isSubmitting}
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
className='w-fit self-end'
|
||||||
|
>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
||||||
|
<h2 className='font-bold text-xl text-center'>
|
||||||
|
Rincian Pengajuan Biaya Operasional
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className='w-full mt-2 flex flex-col gap-4'>
|
||||||
|
{initialValues?.kandang_expenses.map(
|
||||||
|
(kandangExpense, kandangExpenseIdx) => {
|
||||||
|
let expenseGrandTotal = 0;
|
||||||
|
|
||||||
|
kandangExpense.expenses.forEach(
|
||||||
|
(item) => (expenseGrandTotal += item.total_expense)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={kandangExpenseIdx}
|
||||||
|
className='overflow-x-auto w-full mx-auto'
|
||||||
|
>
|
||||||
|
<table className='table table-sm table-zebra'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
colSpan={5}
|
||||||
|
className='font-bold text-center text-base-content text-lg'
|
||||||
|
>
|
||||||
|
Biaya {kandangExpense.kandang.name}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nonstock</th>
|
||||||
|
<th>Total Kuantitas</th>
|
||||||
|
<th>Total Biaya</th>
|
||||||
|
<th>Catatan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{kandangExpense.expenses.map(
|
||||||
|
(expenseItem, expenseIdx) => (
|
||||||
|
<tr key={expenseIdx}>
|
||||||
|
<td>{expenseItem.nonstock.name}</td>
|
||||||
|
<td>{expenseItem.total_quantity}</td>
|
||||||
|
<td>
|
||||||
|
{formatCurrency(expenseItem.total_expense)}
|
||||||
|
</td>
|
||||||
|
<td className='w-xs'>
|
||||||
|
{expenseItem.notes ?? '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className='border-y'>
|
||||||
|
<th colSpan={2} className='text-right'>
|
||||||
|
Total Biaya Keseluruhan:
|
||||||
|
</th>
|
||||||
|
<th colSpan={2}>
|
||||||
|
{formatCurrency(expenseGrandTotal)}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin menghapus data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModalWithNotes
|
||||||
|
ref={approveModal.ref}
|
||||||
|
type='success'
|
||||||
|
text='Apakah anda yakin ingin approve data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'success',
|
||||||
|
isLoading: isApproveLoading,
|
||||||
|
onClick: confirmationModalApproveClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModalWithNotes
|
||||||
|
ref={rejectModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin reject data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isRejectLoading,
|
||||||
|
onClick: confirmationModalRejectClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
import { useFormik } from 'formik';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import DropFileInput from '@/components/input/DropFileInput';
|
|
||||||
|
|
||||||
import { Expense } from '@/types/api/expense';
|
|
||||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
|
||||||
import {
|
|
||||||
UploadRequestDocumentsFormSchema,
|
|
||||||
UploadRequestDocumentsFormValues,
|
|
||||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
|
||||||
import { ExpenseApi } from '@/services/api/expense';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
|
||||||
|
|
||||||
interface ExpenseRealizationContentProps {
|
|
||||||
initialValues?: Expense;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpenseRealizationContent = ({
|
|
||||||
initialValues,
|
|
||||||
}: ExpenseRealizationContentProps) => {
|
|
||||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
|
||||||
initialValues: {
|
|
||||||
documents: [],
|
|
||||||
},
|
|
||||||
validationSchema: UploadRequestDocumentsFormSchema,
|
|
||||||
onSubmit: async (values) => {
|
|
||||||
const addRealizationDocumentsRes =
|
|
||||||
await ExpenseApi.uploadRealizationDocuments(
|
|
||||||
initialValues?.id as number,
|
|
||||||
values.documents
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isResponseSuccess(addRealizationDocumentsRes)) {
|
|
||||||
toast.success(addRealizationDocumentsRes.message);
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(String(addRealizationDocumentsRes?.message));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const realizationDocumentsChangeHandler = (val: File[]) => {
|
|
||||||
formik.setFieldTouched('documents', true);
|
|
||||||
formik.setFieldValue('documents', val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const realizationDocumentsDeleteHandler = (deletedFileIdx: number) => {
|
|
||||||
const newRealizationDocuments = formik.values.documents;
|
|
||||||
|
|
||||||
newRealizationDocuments?.splice(deletedFileIdx, 1);
|
|
||||||
|
|
||||||
formik.setFieldValue('documents', newRealizationDocuments);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
|
||||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
|
||||||
{/* TODO: apply RBAC */}
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='warning'
|
|
||||||
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
|
|
||||||
className='px-4 grow sm:grow-0'
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
|
||||||
Edit Realisasi
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
|
|
||||||
<table className='table table-sm table-zebra'>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th>Tanggal Realisasi</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
{initialValues?.realization_date
|
|
||||||
? formatDate(initialValues?.realization_date, 'DD MMMM YYYY')
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Dokumen Realisasi</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
<div>
|
|
||||||
{!initialValues?.realization_docs ||
|
|
||||||
(initialValues?.realization_docs &&
|
|
||||||
initialValues?.realization_docs.length === 0 &&
|
|
||||||
'-')}
|
|
||||||
|
|
||||||
{initialValues?.realization_docs &&
|
|
||||||
initialValues?.realization_docs.length > 0 && (
|
|
||||||
<ul className='list-disc'>
|
|
||||||
{initialValues?.realization_docs.map(
|
|
||||||
(realizationDocument, realizationDocumentIdx) => (
|
|
||||||
<li key={realizationDocumentIdx}>
|
|
||||||
<Link
|
|
||||||
href={realizationDocument.path}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-500 underline'
|
|
||||||
>
|
|
||||||
{realizationDocument.path}{' '}
|
|
||||||
<Icon
|
|
||||||
icon='cuida:open-in-new-tab-outline'
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className='inline'
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<DropFileInput
|
|
||||||
name='documents'
|
|
||||||
values={formik.values.documents}
|
|
||||||
onChange={realizationDocumentsChangeHandler}
|
|
||||||
onDelete={realizationDocumentsDeleteHandler}
|
|
||||||
accept={{
|
|
||||||
...ACCEPTED_FILE_TYPE.PDF,
|
|
||||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
|
||||||
}}
|
|
||||||
maxFiles={10}
|
|
||||||
className={{
|
|
||||||
wrapper: 'mt-2',
|
|
||||||
inputWrapper: 'flex items-center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{formik.values.documents &&
|
|
||||||
formik.values.documents.length > 0 && (
|
|
||||||
<Button
|
|
||||||
onClick={formik.submitForm}
|
|
||||||
disabled={formik.isSubmitting}
|
|
||||||
isLoading={formik.isSubmitting}
|
|
||||||
className='w-fit self-end'
|
|
||||||
>
|
|
||||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
|
||||||
Tambah
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
|
||||||
<div className='flex flex-row gap-4'>
|
|
||||||
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
|
|
||||||
<div className='w-full flex flex-col gap-2'>
|
|
||||||
<h3 className='text-sm'>Nominal Pengajuan</h3>
|
|
||||||
|
|
||||||
<span className='text-xl'>
|
|
||||||
{formatCurrency(initialValues?.total_pengajuan as number)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className='text-sm'>
|
|
||||||
Terbayar{' '}
|
|
||||||
{formatCurrency(initialValues?.total_realisasi as number)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
|
|
||||||
<div className='w-full flex flex-col gap-2'>
|
|
||||||
<h3 className='text-sm'>Nominal Realisasi</h3>
|
|
||||||
|
|
||||||
<span className='text-xl'>
|
|
||||||
{formatCurrency(initialValues?.total_realisasi as number)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className='text-sm'>
|
|
||||||
Selisih{' '}
|
|
||||||
{formatCurrency(
|
|
||||||
(initialValues?.total_realisasi as number) -
|
|
||||||
(initialValues?.total_pengajuan as number)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
|
||||||
<h2 className='font-bold text-xl text-center'>
|
|
||||||
Rincian Pengajuan Biaya Operasional
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
|
||||||
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
|
|
||||||
let expenseGrandTotal = 0;
|
|
||||||
|
|
||||||
kandangExpense.pengajuans?.forEach(
|
|
||||||
(item) => (expenseGrandTotal += item.total_price)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={kandangExpenseIdx}
|
|
||||||
className='overflow-x-auto w-full mx-auto'
|
|
||||||
>
|
|
||||||
<table className='table table-sm table-zebra'>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
colSpan={5}
|
|
||||||
className='font-bold text-center text-base-content text-lg'
|
|
||||||
>
|
|
||||||
Biaya {kandangExpense.name}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Nonstock</th>
|
|
||||||
<th>Total Kuantitas</th>
|
|
||||||
<th>Total Biaya</th>
|
|
||||||
<th>Catatan</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{kandangExpense.pengajuans?.map(
|
|
||||||
(pengajuanItem, pengajuanIdx) => (
|
|
||||||
<tr key={pengajuanIdx}>
|
|
||||||
<td>{pengajuanItem.nonstock.name}</td>
|
|
||||||
<td>{pengajuanItem.qty}</td>
|
|
||||||
<td>{formatCurrency(pengajuanItem.total_price)}</td>
|
|
||||||
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className='border-y'>
|
|
||||||
<th colSpan={2} className='text-right'>
|
|
||||||
Total Biaya Keseluruhan:
|
|
||||||
</th>
|
|
||||||
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
|
||||||
<h2 className='font-bold text-xl text-center'>
|
|
||||||
Rincian Realisasi Biaya Operasional
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
|
||||||
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
|
|
||||||
let expenseGrandTotal = 0;
|
|
||||||
|
|
||||||
kandangExpense.realisasi?.forEach(
|
|
||||||
(item) => (expenseGrandTotal += item.total_price)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={kandangExpenseIdx}
|
|
||||||
className='overflow-x-auto w-full mx-auto'
|
|
||||||
>
|
|
||||||
<table className='table table-sm table-zebra'>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
colSpan={5}
|
|
||||||
className='font-bold text-center text-base-content text-lg'
|
|
||||||
>
|
|
||||||
Biaya {kandangExpense.name}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Nonstock</th>
|
|
||||||
<th>Total Kuantitas</th>
|
|
||||||
<th>Total Biaya</th>
|
|
||||||
<th>Catatan</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{kandangExpense.realisasi?.map(
|
|
||||||
(realisasiItem, realisasiIdx) => (
|
|
||||||
<tr key={realisasiIdx}>
|
|
||||||
<td>{realisasiItem.nonstock.name}</td>
|
|
||||||
<td>{realisasiItem.qty}</td>
|
|
||||||
<td>{formatCurrency(realisasiItem.total_price)}</td>
|
|
||||||
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className='border-y'>
|
|
||||||
<th colSpan={2} className='text-right'>
|
|
||||||
Total Biaya Keseluruhan:
|
|
||||||
</th>
|
|
||||||
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpenseRealizationContent;
|
|
||||||
@@ -1,655 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useFormik } from 'formik';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
|
||||||
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
|
||||||
import DropFileInput from '@/components/input/DropFileInput';
|
|
||||||
import ApprovalSteps, {
|
|
||||||
useApprovalSteps,
|
|
||||||
} from '@/components/pages/ApprovalSteps';
|
|
||||||
import { useModal } from '@/components/Modal';
|
|
||||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
|
||||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
|
||||||
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
|
|
||||||
|
|
||||||
import { Expense } from '@/types/api/expense';
|
|
||||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
|
||||||
import {
|
|
||||||
UploadRequestDocumentsFormSchema,
|
|
||||||
UploadRequestDocumentsFormValues,
|
|
||||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
|
||||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
|
||||||
import { ExpenseApi } from '@/services/api/expense';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
|
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
|
||||||
|
|
||||||
interface ExpenseRequestContentProps {
|
|
||||||
initialValues?: Expense;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpenseRequestContent = ({
|
|
||||||
initialValues,
|
|
||||||
}: ExpenseRequestContentProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
|
|
||||||
useApprovalSteps({
|
|
||||||
latestApproval: initialValues?.latest_approval,
|
|
||||||
approvalLines: EXPENSE_REQUEST_APPROVAL_LINE,
|
|
||||||
moduleName: 'EXPENSES',
|
|
||||||
moduleId: initialValues?.id.toString() ?? '',
|
|
||||||
params: {
|
|
||||||
page: 1,
|
|
||||||
limit: 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isLatestApprovalRejected =
|
|
||||||
initialValues?.latest_approval.action === 'REJECTED';
|
|
||||||
|
|
||||||
const isLatestApprovalRejectedOrDone =
|
|
||||||
isLatestApprovalRejected ||
|
|
||||||
initialValues?.latest_approval.step_number === 5;
|
|
||||||
|
|
||||||
const isCurrentApprovalOnManager =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
initialValues?.latest_approval.step_number === 1;
|
|
||||||
|
|
||||||
const isCurrentApprovalOnFinance =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
initialValues?.latest_approval.step_number === 2;
|
|
||||||
|
|
||||||
const isCurrentApprovalOnRealization =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
initialValues?.latest_approval.step_number === 4;
|
|
||||||
|
|
||||||
const showEditButton =
|
|
||||||
initialValues?.latest_approval.step_number !== 5 &&
|
|
||||||
(initialValues?.latest_approval.step_number === 1 ||
|
|
||||||
initialValues?.latest_approval.step_number === 2 ||
|
|
||||||
initialValues?.latest_approval.step_number === 3);
|
|
||||||
|
|
||||||
const showRejectButton =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
(initialValues?.latest_approval.step_number === 1 ||
|
|
||||||
initialValues?.latest_approval.step_number === 2);
|
|
||||||
|
|
||||||
const isExpenseCanBeRealized =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
initialValues?.latest_approval.step_number === 3;
|
|
||||||
|
|
||||||
// Modal hooks
|
|
||||||
const deleteModal = useModal();
|
|
||||||
const completeModal = useModal();
|
|
||||||
const approveModal = useModal();
|
|
||||||
const rejectModal = useModal();
|
|
||||||
|
|
||||||
// Modal loading state
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
|
||||||
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
|
|
||||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
|
||||||
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
|
||||||
|
|
||||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
|
||||||
initialValues: {
|
|
||||||
documents: [],
|
|
||||||
},
|
|
||||||
validationSchema: UploadRequestDocumentsFormSchema,
|
|
||||||
onSubmit: async (values) => {
|
|
||||||
const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments(
|
|
||||||
initialValues?.id as number,
|
|
||||||
values.documents
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isResponseSuccess(addRequestDocumentsRes)) {
|
|
||||||
toast.success(addRequestDocumentsRes.message);
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(String(addRequestDocumentsRes?.message));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteExpenseClickHandler = () => {
|
|
||||||
deleteModal.openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeExpenseClickHandler = () => {
|
|
||||||
completeModal.openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const approveClickHandler = () => {
|
|
||||||
approveModal.openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const rejectClickHandler = () => {
|
|
||||||
rejectModal.openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modal confirm click handler
|
|
||||||
const confirmationModalDeleteClickHandler = async () => {
|
|
||||||
setIsDeleteLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ExpenseApi.delete(initialValues?.id as number);
|
|
||||||
|
|
||||||
toast.success('Berhasil menghapus data biaya operasional!');
|
|
||||||
router.push('/expense');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Gagal menghapus data biaya operasional!');
|
|
||||||
} finally {
|
|
||||||
deleteModal.closeModal();
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmationModalCompleteClickHandler = async () => {
|
|
||||||
setIsCompleteLoading(true);
|
|
||||||
|
|
||||||
const completeRes = await ExpenseApi.complete(initialValues?.id as number);
|
|
||||||
|
|
||||||
if (isResponseSuccess(completeRes)) {
|
|
||||||
toast.success(completeRes.message);
|
|
||||||
router.push('/expense');
|
|
||||||
} else {
|
|
||||||
toast.error(completeRes?.message as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
completeModal.closeModal();
|
|
||||||
setIsCompleteLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmationModalApproveClickHandler = async (notes: string) => {
|
|
||||||
setIsApproveLoading(true);
|
|
||||||
|
|
||||||
let approveResponse: BaseApiResponse<Expense> | undefined = undefined;
|
|
||||||
|
|
||||||
if (isCurrentApprovalOnManager) {
|
|
||||||
approveResponse = await ExpenseApi.approveManager(
|
|
||||||
initialValues.id,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCurrentApprovalOnFinance) {
|
|
||||||
approveResponse = await ExpenseApi.approveFinance(
|
|
||||||
initialValues.id,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isResponseSuccess(approveResponse)) {
|
|
||||||
approveModal.closeModal();
|
|
||||||
|
|
||||||
toast.success(approveResponse?.message);
|
|
||||||
router.push('/expense');
|
|
||||||
} else {
|
|
||||||
approveModal.closeModal();
|
|
||||||
|
|
||||||
toast.error(approveResponse?.message as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsApproveLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmationModalRejectClickHandler = async (notes: string) => {
|
|
||||||
setIsRejectLoading(true);
|
|
||||||
|
|
||||||
let rejectResponse: BaseApiResponse<Expense> | undefined = undefined;
|
|
||||||
|
|
||||||
if (isCurrentApprovalOnManager) {
|
|
||||||
rejectResponse = await ExpenseApi.rejectManager(initialValues.id, notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCurrentApprovalOnFinance) {
|
|
||||||
rejectResponse = await ExpenseApi.rejectFinance(initialValues.id, notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isResponseSuccess(rejectResponse)) {
|
|
||||||
rejectModal.closeModal();
|
|
||||||
|
|
||||||
toast.success(rejectResponse.message);
|
|
||||||
router.push('/expense');
|
|
||||||
} else {
|
|
||||||
rejectModal.closeModal();
|
|
||||||
|
|
||||||
toast.error(rejectResponse?.message as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsRejectLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestDocumentsChangeHandler = (val: File[]) => {
|
|
||||||
formik.setFieldTouched('documents', true);
|
|
||||||
formik.setFieldValue('documents', val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
|
|
||||||
const newRequestDocuments = formik.values.documents;
|
|
||||||
|
|
||||||
newRequestDocuments?.splice(deletedFileIdx, 1);
|
|
||||||
|
|
||||||
formik.setFieldValue('documents', newRequestDocuments);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
{initialValues && !isLoadingApprovalHistory && approvalHistory && (
|
|
||||||
<div className='w-full max-w-5xl my-4 mx-auto'>
|
|
||||||
<ApprovalSteps approvals={approvalHistory} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='w-full mt-4 flex flex-col gap-4'>
|
|
||||||
{/* TODO: apply RBAC */}
|
|
||||||
|
|
||||||
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
|
||||||
{isCurrentApprovalOnManager && (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
color='info'
|
|
||||||
onClick={approveClickHandler}
|
|
||||||
className='w-full sm:w-fit'
|
|
||||||
>
|
|
||||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
|
||||||
Approve Manager
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCurrentApprovalOnFinance && (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
color='success'
|
|
||||||
onClick={approveClickHandler}
|
|
||||||
className='w-full sm:w-fit'
|
|
||||||
>
|
|
||||||
<Icon icon='tdesign:money' width={24} height={24} />
|
|
||||||
Approve Finance
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCurrentApprovalOnRealization && (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
color='success'
|
|
||||||
onClick={completeExpenseClickHandler}
|
|
||||||
className='w-full sm:w-fit'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:done-all-rounded'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
Selesai
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showRejectButton && (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
color='error'
|
|
||||||
onClick={rejectClickHandler}
|
|
||||||
className='w-full:w-fit'
|
|
||||||
>
|
|
||||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isExpenseCanBeRealized && (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
color='info'
|
|
||||||
href={`/expense/realization/?expenseId=${initialValues?.id}`}
|
|
||||||
className='w-full sm:w-fit'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:money-bag-rounded'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
Realisasi
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
|
||||||
{showEditButton && (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='warning'
|
|
||||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
|
||||||
className='px-4 grow sm:grow-0'
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='error'
|
|
||||||
onClick={deleteExpenseClickHandler}
|
|
||||||
className='px-4 grow sm:grow-0'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
|
|
||||||
<table className='table table-sm table-zebra'>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th>Nomor PO</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
{!initialValues?.po_number && '-'}
|
|
||||||
{initialValues?.po_number && (
|
|
||||||
<ExpensePDFPreviewButton expense={initialValues} />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Nomor Referensi</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>{initialValues?.reference_number}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Kategori</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
{initialValues?.category === 'BOP'
|
|
||||||
? 'Biaya Operasional'
|
|
||||||
: 'Non Biaya Operasional'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Lokasi</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>{initialValues?.location.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Kandang</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
{initialValues?.kandangs
|
|
||||||
.map((item) => item.name)
|
|
||||||
.join(', ')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Vendor</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>{initialValues?.supplier.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Tanggal Transaksi</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
{formatDate(initialValues?.expense_date, 'DD MMMM YYYY')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Tanggal Realisasi</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
{initialValues?.realization_date
|
|
||||||
? formatDate(
|
|
||||||
initialValues?.realization_date,
|
|
||||||
'DD MMMM YYYY'
|
|
||||||
)
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Nama Pengaju</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>{initialValues?.created_user.name}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Nominal Biaya</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>{formatCurrency(initialValues?.grand_total ?? 0)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Status Pencairan</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
<RealizationStatusBadge
|
|
||||||
approval={initialValues?.latest_approval}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Status Biaya</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
<ExpenseStatusBadge
|
|
||||||
approval={initialValues?.latest_approval}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Dokumen Pengajuan</th>
|
|
||||||
<th>:</th>
|
|
||||||
<td>
|
|
||||||
<div>
|
|
||||||
{!initialValues?.documents ||
|
|
||||||
(initialValues?.documents &&
|
|
||||||
initialValues?.documents.length === 0 &&
|
|
||||||
'-')}
|
|
||||||
|
|
||||||
{initialValues?.documents &&
|
|
||||||
initialValues?.documents.length > 0 && (
|
|
||||||
<ul className='list-disc'>
|
|
||||||
{initialValues?.documents.map(
|
|
||||||
(requestDocument, requestDocumentIdx) => (
|
|
||||||
<li key={requestDocumentIdx}>
|
|
||||||
<Link
|
|
||||||
href={requestDocument.path}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-500 underline'
|
|
||||||
>
|
|
||||||
{requestDocument.path}{' '}
|
|
||||||
<Icon
|
|
||||||
icon='cuida:open-in-new-tab-outline'
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className='inline'
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<DropFileInput
|
|
||||||
name='documents'
|
|
||||||
values={formik.values.documents}
|
|
||||||
onChange={requestDocumentsChangeHandler}
|
|
||||||
onDelete={requestDocumentsDeleteHandler}
|
|
||||||
accept={{
|
|
||||||
...ACCEPTED_FILE_TYPE.PDF,
|
|
||||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
|
||||||
}}
|
|
||||||
maxFiles={10}
|
|
||||||
className={{
|
|
||||||
wrapper: 'mt-2',
|
|
||||||
inputWrapper: 'flex items-center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{formik.values.documents &&
|
|
||||||
formik.values.documents.length > 0 && (
|
|
||||||
<Button
|
|
||||||
onClick={formik.submitForm}
|
|
||||||
disabled={formik.isSubmitting}
|
|
||||||
isLoading={formik.isSubmitting}
|
|
||||||
className='w-fit self-end'
|
|
||||||
>
|
|
||||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
|
||||||
Tambah
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
|
||||||
<h2 className='font-bold text-xl text-center'>
|
|
||||||
Rincian Pengajuan Biaya Operasional
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
|
||||||
{initialValues?.kandangs.map(
|
|
||||||
(kandangExpense, kandangExpenseIdx) => {
|
|
||||||
let expenseGrandTotal = 0;
|
|
||||||
|
|
||||||
kandangExpense.pengajuans?.forEach(
|
|
||||||
(item) => (expenseGrandTotal += item.total_price)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={kandangExpenseIdx}
|
|
||||||
className='overflow-x-auto w-full mx-auto'
|
|
||||||
>
|
|
||||||
<table className='table table-sm table-zebra'>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
colSpan={5}
|
|
||||||
className='font-bold text-center text-base-content text-lg'
|
|
||||||
>
|
|
||||||
Biaya {kandangExpense.name}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Nonstock</th>
|
|
||||||
<th>Total Kuantitas</th>
|
|
||||||
<th>Total Biaya</th>
|
|
||||||
<th>Catatan</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{kandangExpense.pengajuans?.map(
|
|
||||||
(pengajuanItem, pengajuanIdx) => (
|
|
||||||
<tr key={pengajuanIdx}>
|
|
||||||
<td>{pengajuanItem.nonstock.name}</td>
|
|
||||||
<td>{pengajuanItem.qty}</td>
|
|
||||||
<td>
|
|
||||||
{formatCurrency(pengajuanItem.total_price)}
|
|
||||||
</td>
|
|
||||||
<td className='w-xs'>
|
|
||||||
{pengajuanItem.note ?? '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className='border-y'>
|
|
||||||
<th colSpan={2} className='text-right'>
|
|
||||||
Total Biaya Keseluruhan:
|
|
||||||
</th>
|
|
||||||
<th colSpan={2}>
|
|
||||||
{formatCurrency(expenseGrandTotal)}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmationModal
|
|
||||||
ref={deleteModal.ref}
|
|
||||||
type='error'
|
|
||||||
text='Apakah anda yakin ingin menghapus data biaya operasional ini?'
|
|
||||||
secondaryButton={{
|
|
||||||
text: 'Tidak',
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: 'Ya',
|
|
||||||
color: 'error',
|
|
||||||
isLoading: isDeleteLoading,
|
|
||||||
onClick: confirmationModalDeleteClickHandler,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmationModal
|
|
||||||
ref={completeModal.ref}
|
|
||||||
type='success'
|
|
||||||
text='Apakah anda yakin ingin menyelesaikan biaya operasional ini?'
|
|
||||||
secondaryButton={{
|
|
||||||
text: 'Tidak',
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: 'Ya',
|
|
||||||
color: 'success',
|
|
||||||
isLoading: isCompleteLoading,
|
|
||||||
onClick: confirmationModalCompleteClickHandler,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmationModalWithNotes
|
|
||||||
ref={approveModal.ref}
|
|
||||||
type='success'
|
|
||||||
text='Apakah anda yakin ingin approve data biaya operasional ini?'
|
|
||||||
secondaryButton={{
|
|
||||||
text: 'Tidak',
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: 'Ya',
|
|
||||||
color: 'success',
|
|
||||||
isLoading: isApproveLoading,
|
|
||||||
onClick: confirmationModalApproveClickHandler,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmationModalWithNotes
|
|
||||||
ref={rejectModal.ref}
|
|
||||||
type='error'
|
|
||||||
text='Apakah anda yakin ingin reject data biaya operasional ini?'
|
|
||||||
secondaryButton={{
|
|
||||||
text: 'Tidak',
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: 'Ya',
|
|
||||||
color: 'error',
|
|
||||||
isLoading: isRejectLoading,
|
|
||||||
onClick: confirmationModalRejectClickHandler,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpenseRequestContent;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {
|
import {
|
||||||
CellContext,
|
CellContext,
|
||||||
@@ -31,14 +31,13 @@ import DateInput from '@/components/input/DateInput';
|
|||||||
|
|
||||||
import { Expense } from '@/types/api/expense';
|
import { Expense } from '@/types/api/expense';
|
||||||
import { ExpenseApi } from '@/services/api/expense';
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
import { cn, formatCurrency } from '@/lib/helper';
|
||||||
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 { LocationApi, SupplierApi } from '@/services/api/master-data';
|
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||||
import { Location } from '@/types/api/master-data/location';
|
import { Location } from '@/types/api/master-data/location';
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
const RowOptionsMenu = ({
|
||||||
type = 'dropdown',
|
type = 'dropdown',
|
||||||
@@ -54,57 +53,66 @@ const RowOptionsMenu = ({
|
|||||||
deleteClickHandler: () => void;
|
deleteClickHandler: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const showEditButton =
|
const showEditButton =
|
||||||
props.row.original.latest_approval.step_number !== 5 &&
|
props.row.original.approval.action !== 'REJECTED' &&
|
||||||
(props.row.original.latest_approval.step_number === 1 ||
|
props.row.original.approval.step_number !== 5 &&
|
||||||
props.row.original.latest_approval.step_number === 2 ||
|
props.row.original.approval.action !== 'APPROVED';
|
||||||
props.row.original.latest_approval.step_number === 3);
|
|
||||||
|
const showDeleteButton = showEditButton;
|
||||||
|
|
||||||
// TODO: apply RBAC
|
// TODO: apply RBAC
|
||||||
const showRealizationButton =
|
const showApproveButton = showEditButton;
|
||||||
props.row.original.latest_approval.action !== 'REJECTED' &&
|
const showRejectButton = showEditButton;
|
||||||
props.row.original.latest_approval.step_number === 3;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RowOptionsMenuWrapper type={type}>
|
<RowOptionsMenuWrapper type={type}>
|
||||||
<div className='w-full max-h-40 overflow-auto flex flex-col gap-1'>
|
<Button
|
||||||
|
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='primary'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showEditButton && (
|
||||||
<Button
|
<Button
|
||||||
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='primary'
|
color='warning'
|
||||||
className='justify-start text-sm'
|
className='justify-start text-sm'
|
||||||
>
|
>
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||||
Detail
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{showEditButton && (
|
{/* TODO: apply RBAC */}
|
||||||
<Button
|
{showApproveButton && (
|
||||||
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color='warning'
|
color='success'
|
||||||
className='justify-start text-sm'
|
onClick={approveClickHandler}
|
||||||
>
|
className='justify-start text-sm'
|
||||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
>
|
||||||
Edit
|
<Icon icon='material-symbols:check' width={24} height={24} />
|
||||||
</Button>
|
Approve
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{showRealizationButton && (
|
{showRejectButton && (
|
||||||
<Button
|
<Button
|
||||||
href={`/expense/realization/?expenseId=${props.row.original.id}`}
|
variant='ghost'
|
||||||
variant='ghost'
|
color='error'
|
||||||
color='info'
|
onClick={rejectClickHandler}
|
||||||
className='justify-start text-sm text-info focus-visible:text-info-content hover:text-info-content'
|
className='justify-start text-sm'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||||
icon='material-symbols:money-bag-rounded'
|
Reject
|
||||||
width={16}
|
</Button>
|
||||||
height={16}
|
)}
|
||||||
/>
|
|
||||||
Realisasi
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{showDeleteButton && (
|
||||||
<Button
|
<Button
|
||||||
onClick={deleteClickHandler}
|
onClick={deleteClickHandler}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -119,7 +127,7 @@ const RowOptionsMenu = ({
|
|||||||
/>
|
/>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</RowOptionsMenuWrapper>
|
</RowOptionsMenuWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -170,7 +178,6 @@ const ExpensesTable = () => {
|
|||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
|
|
||||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||||
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||||
|
|
||||||
@@ -180,57 +187,6 @@ const ExpensesTable = () => {
|
|||||||
parseInt(item)
|
parseInt(item)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAllSelectedRowLatestApprovalOnManager = useMemo(() => {
|
|
||||||
return selectedRowIds.every((rowId) => {
|
|
||||||
if (!isResponseSuccess(expenses)) return false;
|
|
||||||
|
|
||||||
const expenseItem = expenses.data.find((item) => item.id === rowId);
|
|
||||||
|
|
||||||
const isLatestApprovalRejected =
|
|
||||||
expenseItem?.latest_approval.action === 'REJECTED';
|
|
||||||
|
|
||||||
const isCurrentApprovalOnManager =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
expenseItem?.latest_approval.step_number === 1;
|
|
||||||
|
|
||||||
return isCurrentApprovalOnManager;
|
|
||||||
});
|
|
||||||
}, [expenses, selectedRowIds]);
|
|
||||||
|
|
||||||
const isAllSelectedRowLatestApprovalOnFinance = useMemo(() => {
|
|
||||||
return selectedRowIds.every((rowId) => {
|
|
||||||
if (!isResponseSuccess(expenses)) return false;
|
|
||||||
|
|
||||||
const expenseItem = expenses.data.find((item) => item.id === rowId);
|
|
||||||
|
|
||||||
const isLatestApprovalRejected =
|
|
||||||
expenseItem?.latest_approval.action === 'REJECTED';
|
|
||||||
|
|
||||||
const isCurrentApprovalOnFinance =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
expenseItem?.latest_approval.step_number === 2;
|
|
||||||
|
|
||||||
return isCurrentApprovalOnFinance;
|
|
||||||
});
|
|
||||||
}, [expenses, selectedRowIds]);
|
|
||||||
|
|
||||||
const isAllSelectedRowLatestApprovalOnRealization = useMemo(() => {
|
|
||||||
return selectedRowIds.every((rowId) => {
|
|
||||||
if (!isResponseSuccess(expenses)) return false;
|
|
||||||
|
|
||||||
const expenseItem = expenses.data.find((item) => item.id === rowId);
|
|
||||||
|
|
||||||
const isLatestApprovalRejected =
|
|
||||||
expenseItem?.latest_approval.action === 'REJECTED';
|
|
||||||
|
|
||||||
const isCurrentApprovalOnRealization =
|
|
||||||
!isLatestApprovalRejected &&
|
|
||||||
expenseItem?.latest_approval.step_number === 4;
|
|
||||||
|
|
||||||
return isCurrentApprovalOnRealization;
|
|
||||||
});
|
|
||||||
}, [expenses, selectedRowIds]);
|
|
||||||
|
|
||||||
const expensesColumns: ColumnDef<Expense>[] = [
|
const expensesColumns: ColumnDef<Expense>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
@@ -246,8 +202,7 @@ const ExpensesTable = () => {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isCheckboxDisabled =
|
const isCheckboxDisabled =
|
||||||
!row.getCanSelect() ||
|
!row.getCanSelect() || row.original.approval.action === 'REJECTED';
|
||||||
row.original.latest_approval.action === 'REJECTED';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -263,52 +218,61 @@ const ExpensesTable = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'expense_date',
|
accessorKey: 'transaction_date',
|
||||||
header: 'Tanggal Pengajuan',
|
header: 'Tanggal Pengajuan',
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.expense_date
|
|
||||||
? formatDate(props.row.original.expense_date, 'DD MMM YYYY')
|
|
||||||
: '-',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'realization_date',
|
accessorKey: 'realization_date',
|
||||||
header: 'Tanggal Realisasi',
|
header: 'Tanggal Realisasi',
|
||||||
cell: (props) =>
|
cell: (props) => props.getValue() ?? '-',
|
||||||
props.row.original.realization_date
|
|
||||||
? formatDate(props.row.original.realization_date, 'DD MMM YYYY')
|
|
||||||
: '-',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'location',
|
accessorKey: 'location',
|
||||||
header: 'Lokasi',
|
header: 'Lokasi',
|
||||||
cell: (props) => props.row.original.location?.name ?? '-',
|
cell: (props) => props.row.original.location.name ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (row) => row.created_user.name ?? '-',
|
accessorFn: (row) => row.created_user.name ?? '-',
|
||||||
header: 'Nama Pengaju',
|
header: 'Nama Pengaju',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: (row) => row.supplier.name ?? '-',
|
accessorFn: (row) => row.vendor.name ?? '-',
|
||||||
header: 'Vendor',
|
header: 'Vendor',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'grand_total',
|
accessorKey: 'nominal',
|
||||||
header: 'Nominal',
|
header: 'Nominal',
|
||||||
cell: (props) =>
|
cell: (props) =>
|
||||||
props.row.original.grand_total
|
props.row.original.nominal
|
||||||
? formatCurrency(props.row.original.grand_total)
|
? `Rp${formatCurrency(props.row.original.nominal)}`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'paid',
|
||||||
|
header: 'Sudah Bayar',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.paid
|
||||||
|
? `Rp${formatCurrency(props.row.original.paid)}`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'remaining_cost',
|
||||||
|
header: 'Sisa Bayar',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.remaining_cost
|
||||||
|
? `Rp${formatCurrency(props.row.original.remaining_cost)}`
|
||||||
: '-',
|
: '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status Pencairan',
|
header: 'Status Pencairan',
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<RealizationStatusBadge approval={props.row.original.latest_approval} />
|
<RealizationStatusBadge approval={props.row.original.approval} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status BOP',
|
header: 'Status BOP',
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<ExpenseStatusBadge approval={props.row.original.latest_approval} />
|
<ExpenseStatusBadge approval={props.row.original.approval} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -319,7 +283,7 @@ const ExpensesTable = () => {
|
|||||||
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 - 3;
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
const approveClickHandler = () => {
|
const approveClickHandler = () => {
|
||||||
setSelectedExpense(props.row.original);
|
setSelectedExpense(props.row.original);
|
||||||
@@ -350,7 +314,7 @@ const ExpensesTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{currentPageSize > 3 && (
|
{currentPageSize > 2 && (
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||||
<RowOptionsMenu
|
<RowOptionsMenu
|
||||||
type='dropdown'
|
type='dropdown'
|
||||||
@@ -362,10 +326,10 @@ const ExpensesTable = () => {
|
|||||||
</RowDropdownOptions>
|
</RowDropdownOptions>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPageSize <= 3 && (
|
{currentPageSize <= 2 && (
|
||||||
<RowCollapseOptions>
|
<RowCollapseOptions>
|
||||||
<RowOptionsMenu
|
<RowOptionsMenu
|
||||||
type='collapse'
|
type='dropdown'
|
||||||
props={props}
|
props={props}
|
||||||
approveClickHandler={approveClickHandler}
|
approveClickHandler={approveClickHandler}
|
||||||
rejectClickHandler={rejectClickHandler}
|
rejectClickHandler={rejectClickHandler}
|
||||||
@@ -382,20 +346,9 @@ const ExpensesTable = () => {
|
|||||||
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
|
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
|
||||||
row
|
row
|
||||||
) => {
|
) => {
|
||||||
return (
|
return row.original.approval.action !== 'REJECTED';
|
||||||
row.original.latest_approval.action !== 'REJECTED' &&
|
|
||||||
row.original.latest_approval.step_number !== 5
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// const bulkApproveClickHandler = () => {
|
|
||||||
// approveModal.openModal();
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const bulkRejectClickHandler = () => {
|
|
||||||
// rejectModal.openModal();
|
|
||||||
// };
|
|
||||||
|
|
||||||
const bulkApproveClickHandler = () => {
|
const bulkApproveClickHandler = () => {
|
||||||
approveModal.openModal();
|
approveModal.openModal();
|
||||||
};
|
};
|
||||||
@@ -418,26 +371,17 @@ const ExpensesTable = () => {
|
|||||||
const confirmationModalApproveClickHandler = async (notes: string) => {
|
const confirmationModalApproveClickHandler = async (notes: string) => {
|
||||||
setIsApproveLoading(true);
|
setIsApproveLoading(true);
|
||||||
|
|
||||||
let bulkApproveResponse: BaseApiResponse<Expense> | undefined = undefined;
|
const bulkApproveResponse = await ExpenseApi.bulkApprove(
|
||||||
|
selectedRowIds,
|
||||||
if (isAllSelectedRowLatestApprovalOnManager) {
|
notes
|
||||||
bulkApproveResponse = await ExpenseApi.bulkApproveManager(
|
);
|
||||||
selectedRowIds,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
} else if (isAllSelectedRowLatestApprovalOnFinance) {
|
|
||||||
bulkApproveResponse = await ExpenseApi.bulkApproveFinance(
|
|
||||||
selectedRowIds,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isResponseSuccess(bulkApproveResponse)) {
|
if (isResponseSuccess(bulkApproveResponse)) {
|
||||||
refreshExpenses();
|
refreshExpenses();
|
||||||
approveModal.closeModal();
|
approveModal.closeModal();
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Berhasil approve ${selectedRowIds.length} data biaya operasional!`
|
`Berhasil approve ${selectedRowIds.length} data transfer ke laying!`
|
||||||
);
|
);
|
||||||
|
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
@@ -445,7 +389,7 @@ const ExpensesTable = () => {
|
|||||||
approveModal.closeModal();
|
approveModal.closeModal();
|
||||||
|
|
||||||
toast.error(
|
toast.error(
|
||||||
`Gagal approve ${selectedRowIds.length} data biaya operasional!`
|
`Gagal approve ${selectedRowIds.length} data transfer ke laying!`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,33 +399,24 @@ const ExpensesTable = () => {
|
|||||||
const confirmationModalRejectClickHandler = async (notes: string) => {
|
const confirmationModalRejectClickHandler = async (notes: string) => {
|
||||||
setIsRejectLoading(true);
|
setIsRejectLoading(true);
|
||||||
|
|
||||||
let bulkRejectResponse: BaseApiResponse<Expense> | undefined = undefined;
|
const bulkRejectResponse = await ExpenseApi.bulkReject(
|
||||||
|
selectedRowIds,
|
||||||
if (isAllSelectedRowLatestApprovalOnManager) {
|
notes
|
||||||
bulkRejectResponse = await ExpenseApi.bulkRejectManager(
|
);
|
||||||
selectedRowIds,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
} else if (isAllSelectedRowLatestApprovalOnFinance) {
|
|
||||||
bulkRejectResponse = await ExpenseApi.bulkRejectFinance(
|
|
||||||
selectedRowIds,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isResponseSuccess(bulkRejectResponse)) {
|
if (isResponseSuccess(bulkRejectResponse)) {
|
||||||
refreshExpenses();
|
refreshExpenses();
|
||||||
rejectModal.closeModal();
|
rejectModal.closeModal();
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Berhasil reject ${selectedRowIds.length} data biaya operasional!`
|
`Berhasil reject ${selectedRowIds.length} data transfer ke laying!`
|
||||||
);
|
);
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
} else {
|
} else {
|
||||||
rejectModal.closeModal();
|
rejectModal.closeModal();
|
||||||
|
|
||||||
toast.error(
|
toast.error(
|
||||||
`Gagal reject ${selectedRowIds.length} data biaya operasional!`
|
`Gagal reject ${selectedRowIds.length} data transfer ke laying!`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,36 +506,27 @@ const ExpensesTable = () => {
|
|||||||
|
|
||||||
{selectedRowIds.length > 0 && (
|
{selectedRowIds.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Button
|
{/* TODO: apply RBAC */}
|
||||||
variant='outline'
|
|
||||||
color='info'
|
|
||||||
onClick={bulkApproveClickHandler}
|
|
||||||
disabled={!isAllSelectedRowLatestApprovalOnManager}
|
|
||||||
className='w-full sm:w-fit'
|
|
||||||
>
|
|
||||||
<Icon icon='lucide-lab:farm' width={24} height={24} />
|
|
||||||
Approve Manager
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='success'
|
color='success'
|
||||||
onClick={bulkApproveClickHandler}
|
onClick={bulkApproveClickHandler}
|
||||||
disabled={!isAllSelectedRowLatestApprovalOnFinance}
|
disabled={selectedRowIds.length === 0}
|
||||||
className='w-full sm:w-fit'
|
className='w-full sm:w-fit'
|
||||||
>
|
>
|
||||||
<Icon icon='tdesign:money' width={24} height={24} />
|
<Icon
|
||||||
Approve Finance
|
icon='material-symbols:check'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
color='error'
|
color='error'
|
||||||
onClick={bulkRejectClickHandler}
|
onClick={bulkRejectClickHandler}
|
||||||
disabled={
|
disabled={selectedRowIds.length === 0}
|
||||||
!isAllSelectedRowLatestApprovalOnManager &&
|
|
||||||
!isAllSelectedRowLatestApprovalOnFinance
|
|
||||||
}
|
|
||||||
className='w-full sm:w-fit'
|
className='w-full sm:w-fit'
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -740,7 +666,7 @@ const ExpensesTable = () => {
|
|||||||
<ConfirmationModalWithNotes
|
<ConfirmationModalWithNotes
|
||||||
ref={approveModal.ref}
|
ref={approveModal.ref}
|
||||||
type='success'
|
type='success'
|
||||||
text='Apakah anda yakin ingin approve data biaya operasional ini?'
|
text={`Apakah anda yakin ingin approve data biaya operasional ini (${selectedRowIds.length} data)?`}
|
||||||
secondaryButton={{
|
secondaryButton={{
|
||||||
text: 'Tidak',
|
text: 'Tidak',
|
||||||
}}
|
}}
|
||||||
@@ -755,7 +681,7 @@ const ExpensesTable = () => {
|
|||||||
<ConfirmationModalWithNotes
|
<ConfirmationModalWithNotes
|
||||||
ref={rejectModal.ref}
|
ref={rejectModal.ref}
|
||||||
type='error'
|
type='error'
|
||||||
text='Apakah anda yakin ingin reject data biaya operasional ini?'
|
text={`Apakah anda yakin ingin reject data biaya operasional ini (${selectedRowIds.length} data)?`}
|
||||||
secondaryButton={{
|
secondaryButton={{
|
||||||
text: 'Tidak',
|
text: 'Tidak',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import * as Yup from 'yup';
|
|
||||||
import { Expense } from '@/types/api/expense';
|
|
||||||
import { formatDate } from '@/lib/helper';
|
|
||||||
|
|
||||||
type ExpenseRealizationFormSchemaType = {
|
|
||||||
category?: {
|
|
||||||
value: 'BOP' | 'NON-BOP';
|
|
||||||
label: 'BOP' | 'NON-BOP';
|
|
||||||
};
|
|
||||||
location?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
realization_date?: string;
|
|
||||||
kandangs?: { id: number; name: string }[];
|
|
||||||
supplier?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
existing_documents?: { name: string; url: string }[];
|
|
||||||
documents?: File[];
|
|
||||||
realizations: {
|
|
||||||
kandang_id: number;
|
|
||||||
cost_items: {
|
|
||||||
nonstock?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
quantity?: number;
|
|
||||||
total_cost?: number;
|
|
||||||
notes?: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFormSchemaType> =
|
|
||||||
Yup.object({
|
|
||||||
category: Yup.object({
|
|
||||||
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
|
|
||||||
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
|
|
||||||
}).required('Kategori wajib diisi!'),
|
|
||||||
|
|
||||||
location: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).required('Lokasi wajib diisi!'),
|
|
||||||
|
|
||||||
realization_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
|
|
||||||
kandangs: Yup.array()
|
|
||||||
.of(
|
|
||||||
Yup.object({
|
|
||||||
id: Yup.number().required('Kandang wajib dipilih!'),
|
|
||||||
name: Yup.string().required('Kandang wajib dipilih!'),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.min(1, 'Kandang wajib dipilih!')
|
|
||||||
.required('Kandang wajib dipilih!'),
|
|
||||||
|
|
||||||
supplier: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).required('Vendor wajib diisi!'),
|
|
||||||
|
|
||||||
existing_documents: Yup.array().of(
|
|
||||||
Yup.object({
|
|
||||||
name: Yup.string().required(),
|
|
||||||
url: Yup.string().required(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
|
|
||||||
documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
|
|
||||||
|
|
||||||
realizations: Yup.array()
|
|
||||||
.of(
|
|
||||||
Yup.object({
|
|
||||||
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
|
|
||||||
cost_items: Yup.array()
|
|
||||||
.of(
|
|
||||||
Yup.object({
|
|
||||||
nonstock: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).required('Nonstock wajib diisi!'),
|
|
||||||
quantity: Yup.number().required('Total kuantitas wajib diisi!'),
|
|
||||||
total_cost: Yup.number().required('Total biaya wajib diisi!'),
|
|
||||||
notes: Yup.string(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.min(1, 'Kandang harus memiliki setidaknya 1 biaya!')
|
|
||||||
.required('Biaya kandang wajib diisi!'),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.min(1, 'Biaya kandang wajib diisi!')
|
|
||||||
.required('Biaya kandang wajib diisi!'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema;
|
|
||||||
|
|
||||||
export const UploadRealizationDocumentsFormSchema = Yup.object({
|
|
||||||
realization_documents: Yup.array()
|
|
||||||
.of(Yup.mixed<File>().required())
|
|
||||||
.required(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ExpenseRealizationFormValues = Yup.InferType<
|
|
||||||
typeof ExpenseRealizationFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type UploadRealizationDocumentsFormValues = Yup.InferType<
|
|
||||||
typeof UploadRealizationDocumentsFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const getExpenseRealizationFormInitialValues = (
|
|
||||||
initialValues?: Expense
|
|
||||||
): ExpenseRealizationFormValues => {
|
|
||||||
return {
|
|
||||||
category: initialValues?.category
|
|
||||||
? {
|
|
||||||
value: initialValues.category,
|
|
||||||
label: initialValues.category,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
location: initialValues?.location
|
|
||||||
? {
|
|
||||||
value: initialValues.location.id,
|
|
||||||
label: initialValues.location.name,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
realization_date: initialValues?.realization_date
|
|
||||||
? formatDate(initialValues?.realization_date, 'YYYY-MM-DD')
|
|
||||||
: undefined,
|
|
||||||
kandangs: initialValues?.kandangs.map((kandang) => ({
|
|
||||||
id: kandang.kandang_id,
|
|
||||||
name: kandang.name,
|
|
||||||
})),
|
|
||||||
supplier: initialValues?.supplier
|
|
||||||
? {
|
|
||||||
value: initialValues.supplier.id,
|
|
||||||
label: initialValues.supplier.name,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
existing_documents: initialValues?.realization_docs?.map((doc) => ({
|
|
||||||
name: doc.path,
|
|
||||||
url: doc.path,
|
|
||||||
})),
|
|
||||||
documents: [],
|
|
||||||
realizations: initialValues?.kandangs
|
|
||||||
? initialValues.kandangs.map((kandangExpense) => {
|
|
||||||
const costItemsInitialValue = kandangExpense.realisasi
|
|
||||||
? kandangExpense.realisasi.map((realisasiItem, realisasiIdx) => {
|
|
||||||
return {
|
|
||||||
nonstock: {
|
|
||||||
value: kandangExpense.pengajuans?.[realisasiIdx]
|
|
||||||
.id as number,
|
|
||||||
label: realisasiItem.nonstock.name,
|
|
||||||
},
|
|
||||||
quantity: realisasiItem.qty,
|
|
||||||
total_cost: realisasiItem.total_price,
|
|
||||||
notes: realisasiItem.note,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: kandangExpense.pengajuans
|
|
||||||
? kandangExpense.pengajuans.map((expenseItem) => ({
|
|
||||||
nonstock: {
|
|
||||||
value: expenseItem.id,
|
|
||||||
label: expenseItem.nonstock.name,
|
|
||||||
},
|
|
||||||
quantity: expenseItem.qty,
|
|
||||||
total_cost: expenseItem.total_price,
|
|
||||||
notes: expenseItem.note,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
kandang_id: kandangExpense.kandang_id,
|
|
||||||
cost_items: costItemsInitialValue,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useFormik } from 'formik';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import DropFileInput from '@/components/input/DropFileInput';
|
|
||||||
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
|
|
||||||
import ExpenseRealizationKandangDetailExpense from '@/components/pages/expense/form/ExpenseRealizationKandangDetailExpense';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CreateExpenseRealizationPayload,
|
|
||||||
Expense,
|
|
||||||
UpdateExpenseRealizationPayload,
|
|
||||||
} from '@/types/api/expense';
|
|
||||||
import {
|
|
||||||
ExpenseRealizationFormSchema,
|
|
||||||
ExpenseRealizationFormValues,
|
|
||||||
getExpenseRealizationFormInitialValues,
|
|
||||||
UpdateExpenseRealizationFormSchema,
|
|
||||||
} from '@/components/pages/expense/form/ExpenseRealizationForm.schema';
|
|
||||||
import { ExpenseApi } from '@/services/api/expense';
|
|
||||||
import { isResponseError } from '@/lib/api-helper';
|
|
||||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
|
||||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
|
||||||
import { cn } from '@/lib/helper';
|
|
||||||
|
|
||||||
interface ExpenseRealizationFormProps {
|
|
||||||
type?: 'add' | 'edit' | 'detail';
|
|
||||||
initialValues?: Expense;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpenseRealizationForm = ({
|
|
||||||
type = 'add',
|
|
||||||
initialValues,
|
|
||||||
}: ExpenseRealizationFormProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
|
|
||||||
|
|
||||||
const createExpenseHandler = useCallback(
|
|
||||||
async (payload: CreateExpenseRealizationPayload) => {
|
|
||||||
const createExpenseRes = await ExpenseApi.createRealization(
|
|
||||||
initialValues?.id as number,
|
|
||||||
ExpenseApi.convertExpenseRealizationPayloadToFormData(payload)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isResponseError(createExpenseRes)) {
|
|
||||||
setExpenseFormErrorMessage(createExpenseRes.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(createExpenseRes?.message as string);
|
|
||||||
router.push('/expense');
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateExpenseHandler = useCallback(
|
|
||||||
async (expenseId: number, payload: UpdateExpenseRealizationPayload) => {
|
|
||||||
const updateExpenseRes = await ExpenseApi.updateRealization(
|
|
||||||
expenseId,
|
|
||||||
ExpenseApi.convertExpenseRealizationPayloadToFormData(payload)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updateExpenseRes?.status === 'error') {
|
|
||||||
setExpenseFormErrorMessage(updateExpenseRes.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(updateExpenseRes?.message as string);
|
|
||||||
router.refresh();
|
|
||||||
router.push('/expense');
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const formik = useFormik<ExpenseRealizationFormValues>({
|
|
||||||
initialValues: getExpenseRealizationFormInitialValues(initialValues),
|
|
||||||
validationSchema:
|
|
||||||
type === 'edit'
|
|
||||||
? UpdateExpenseRealizationFormSchema
|
|
||||||
: ExpenseRealizationFormSchema,
|
|
||||||
onSubmit: async (values) => {
|
|
||||||
setExpenseFormErrorMessage('');
|
|
||||||
|
|
||||||
const realizations: CreateExpenseRealizationPayload['realizations'] = [];
|
|
||||||
|
|
||||||
values.realizations.forEach((realization) => {
|
|
||||||
realization.cost_items.forEach((costItem) => {
|
|
||||||
const unitPrice =
|
|
||||||
parseFloat(String(costItem.total_cost)) /
|
|
||||||
parseFloat(String(costItem.quantity));
|
|
||||||
|
|
||||||
const realizationItem = {
|
|
||||||
expense_nonstock_id: costItem.nonstock?.value as number,
|
|
||||||
qty: parseFloat(String(costItem.quantity)) as number,
|
|
||||||
unit_price: unitPrice,
|
|
||||||
total_price: parseFloat(String(costItem.total_cost)) as number,
|
|
||||||
notes: costItem.notes ?? '',
|
|
||||||
};
|
|
||||||
|
|
||||||
realizations.push(realizationItem);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const expensePayload: CreateExpenseRealizationPayload = {
|
|
||||||
realization_date: values.realization_date as string,
|
|
||||||
documents: values.documents as File[],
|
|
||||||
realizations,
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'add':
|
|
||||||
await createExpenseHandler(expensePayload);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'edit':
|
|
||||||
await updateExpenseHandler(
|
|
||||||
initialValues?.id as number,
|
|
||||||
expensePayload
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { setValues: formikSetValues } = formik;
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setLocationInputValue,
|
|
||||||
options: locationOptions,
|
|
||||||
isLoadingOptions: isLoadingLocationOptions,
|
|
||||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setVendorInputValue,
|
|
||||||
options: vendorOptions,
|
|
||||||
isLoadingOptions: isLoadingVendorOptions,
|
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
formik.setFieldTouched('location', true);
|
|
||||||
formik.setFieldValue('location', val);
|
|
||||||
|
|
||||||
formik.setFieldValue('kandangs', []);
|
|
||||||
formik.setFieldValue('realizations', []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
|
|
||||||
formik.setFieldTouched('kandangs', true);
|
|
||||||
formik.setFieldValue('kandangs', kandangs);
|
|
||||||
|
|
||||||
const newRealizations = [...(formik.values.realizations ?? [])];
|
|
||||||
|
|
||||||
// add new realizations
|
|
||||||
kandangs.forEach((kandangItem) => {
|
|
||||||
const isKandangExistInRealization = newRealizations.find(
|
|
||||||
(realizationItem) => realizationItem.kandang_id === kandangItem.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isKandangExistInRealization) return;
|
|
||||||
|
|
||||||
newRealizations.push({
|
|
||||||
kandang_id: kandangItem.id,
|
|
||||||
cost_items: [
|
|
||||||
{
|
|
||||||
nonstock: undefined,
|
|
||||||
quantity: undefined,
|
|
||||||
total_cost: undefined,
|
|
||||||
notes: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// prune realizations
|
|
||||||
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
|
|
||||||
const deletedRealizationsIdx: number[] = [];
|
|
||||||
|
|
||||||
newRealizations.forEach((realization, idx) => {
|
|
||||||
const isRealizationValid = kandangIds.has(realization.kandang_id);
|
|
||||||
|
|
||||||
if (!isRealizationValid) {
|
|
||||||
deletedRealizationsIdx.push(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
deletedRealizationsIdx.forEach((deletedRealizationIdx) => {
|
|
||||||
newRealizations.splice(deletedRealizationIdx, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
formik.setFieldValue('realizations', newRealizations);
|
|
||||||
};
|
|
||||||
|
|
||||||
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
formik.setFieldTouched('vendor', true);
|
|
||||||
formik.setFieldValue('vendor', val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const realizationDocumentsChangeHandler = (val: File[]) => {
|
|
||||||
formik.setFieldTouched('documents', true);
|
|
||||||
formik.setFieldValue('documents', val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const realizationDocumentsDeleteHandler = (deletedFileIdx: number) => {
|
|
||||||
const newRequestDocuments = formik.values.documents;
|
|
||||||
|
|
||||||
newRequestDocuments?.splice(deletedFileIdx, 1);
|
|
||||||
|
|
||||||
formik.setFieldValue('documents', newRequestDocuments);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
formikSetValues(getExpenseRealizationFormInitialValues(initialValues));
|
|
||||||
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className='w-full max-w-5xl'>
|
|
||||||
<header className='flex flex-col gap-4'>
|
|
||||||
<Button
|
|
||||||
href='/expense'
|
|
||||||
variant='link'
|
|
||||||
className='w-fit p-0 text-primary'
|
|
||||||
>
|
|
||||||
<Icon icon='uil:arrow-left' width={24} height={24} />
|
|
||||||
Kembali
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h1 className='text-2xl font-bold text-center'>
|
|
||||||
Realisasi Biaya Operasional
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={formik.handleSubmit}
|
|
||||||
onReset={formik.handleReset}
|
|
||||||
className='w-full mt-8 flex flex-col gap-6'
|
|
||||||
>
|
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Lokasi'
|
|
||||||
required
|
|
||||||
placeholder='Pilih Lokasi'
|
|
||||||
value={formik.values.location}
|
|
||||||
onChange={locationChangeHandler}
|
|
||||||
options={locationOptions}
|
|
||||||
isLoading={isLoadingLocationOptions}
|
|
||||||
onInputChange={setLocationInputValue}
|
|
||||||
isDisabled
|
|
||||||
className={{ wrapper: 'col-span-12 sm:col-span-6' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DateInput
|
|
||||||
name='realization_date'
|
|
||||||
label='Tanggal Realisasi'
|
|
||||||
required
|
|
||||||
value={formik.values.realization_date}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12 sm:col-span-6',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ExpenseKandangsTable
|
|
||||||
type='detail'
|
|
||||||
locationId={formik.values.location?.value}
|
|
||||||
selectedKandangs={formik.values.kandangs ?? []}
|
|
||||||
onChange={kandangsChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full col-span-12',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Vendor'
|
|
||||||
required
|
|
||||||
placeholder='Pilih Vendor'
|
|
||||||
value={formik.values.supplier}
|
|
||||||
onChange={vendorChangeHandler}
|
|
||||||
options={vendorOptions}
|
|
||||||
isLoading={isLoadingVendorOptions}
|
|
||||||
onInputChange={setVendorInputValue}
|
|
||||||
isDisabled
|
|
||||||
className={{ wrapper: 'col-span-12' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DropFileInput
|
|
||||||
label='Dokumen Realisasi'
|
|
||||||
name='documents'
|
|
||||||
values={formik.values.documents}
|
|
||||||
onChange={realizationDocumentsChangeHandler}
|
|
||||||
onDelete={realizationDocumentsDeleteHandler}
|
|
||||||
accept={{
|
|
||||||
...ACCEPTED_FILE_TYPE.PDF,
|
|
||||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
|
||||||
}}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12',
|
|
||||||
inputWrapper: 'h-12 flex items-center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{formik.values.existing_documents &&
|
|
||||||
formik.values.existing_documents.length > 0 && (
|
|
||||||
<div className='w-full col-span-12'>
|
|
||||||
<ul className='pl-4 list-disc'>
|
|
||||||
{formik.values.existing_documents.map(
|
|
||||||
(existingDocument, existingDocumentIdx) => (
|
|
||||||
<li key={existingDocumentIdx}>
|
|
||||||
<Link
|
|
||||||
href={existingDocument.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-blue-500 underline'
|
|
||||||
>
|
|
||||||
{existingDocument.name}{' '}
|
|
||||||
<Icon
|
|
||||||
icon='cuida:open-in-new-tab-outline'
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className='inline'
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ExpenseRealizationKandangDetailExpense
|
|
||||||
formik={formik}
|
|
||||||
className={{
|
|
||||||
wrapper: 'col-span-12',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expenseFormErrorMessage && (
|
|
||||||
<div role='alert' className='alert alert-error w-full'>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:error-outline'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
<span>{expenseFormErrorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
|
||||||
{type !== 'add' && (
|
|
||||||
<div className='flex flex-row justify-start gap-2'>
|
|
||||||
{type !== 'edit' && (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='warning'
|
|
||||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
|
||||||
className='px-4'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:edit-outline'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-row justify-end gap-2', {
|
|
||||||
'w-full': type === 'add',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button type='reset' color='warning' className='px-4'>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='submit'
|
|
||||||
color='primary'
|
|
||||||
isLoading={formik.isSubmitting}
|
|
||||||
disabled={!formik.isValid || formik.isSubmitting}
|
|
||||||
className='px-4'
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpenseRealizationForm;
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { FormikContextType } from 'formik';
|
|
||||||
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import NumberInput from '@/components/input/NumberInput';
|
|
||||||
import TextInput from '@/components/input/TextInput';
|
|
||||||
|
|
||||||
import { ExpenseRealizationFormValues } from '@/components/pages/expense/form/ExpenseRealizationForm.schema';
|
|
||||||
import { cn } from '@/lib/helper';
|
|
||||||
import { NonstockApi } from '@/services/api/master-data';
|
|
||||||
import { Nonstock } from '@/types/api/master-data/nonstock';
|
|
||||||
|
|
||||||
interface ExpenseRealizationKandangDetailExpenseProps {
|
|
||||||
type?: 'add' | 'edit' | 'detail';
|
|
||||||
formik: FormikContextType<ExpenseRealizationFormValues>;
|
|
||||||
className?: {
|
|
||||||
wrapper?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpenseRealizationKandangDetailExpense: React.FC<
|
|
||||||
ExpenseRealizationKandangDetailExpenseProps
|
|
||||||
> = ({ type, formik, className }) => {
|
|
||||||
const {
|
|
||||||
setInputValue: setNonstockInputValue,
|
|
||||||
options: nonstockOptions,
|
|
||||||
isLoadingOptions: isLoadingNonstockOptions,
|
|
||||||
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
|
||||||
|
|
||||||
const nonstockChangeHandler = (
|
|
||||||
kandangExpenseIdx: number,
|
|
||||||
costItemIdx: number,
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
formik.setFieldTouched(
|
|
||||||
`realizations[${kandangExpenseIdx}].cost_items[${costItemIdx}].nonstock`,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
formik.setFieldValue(
|
|
||||||
`realizations[${kandangExpenseIdx}].cost_items[${costItemIdx}].nonstock`,
|
|
||||||
val
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isExpenseRepeaterInputError = (
|
|
||||||
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes',
|
|
||||||
kandangExpenseIdx: number,
|
|
||||||
expenseIdx: number
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
formik.touched.realizations?.[kandangExpenseIdx]?.cost_items?.[
|
|
||||||
expenseIdx
|
|
||||||
]?.[column] &&
|
|
||||||
Boolean(
|
|
||||||
formik.errors.realizations?.[kandangExpenseIdx] instanceof Object &&
|
|
||||||
formik.errors.realizations?.[kandangExpenseIdx].cost_items?.[
|
|
||||||
expenseIdx
|
|
||||||
] instanceof Object &&
|
|
||||||
formik.errors.realizations?.[kandangExpenseIdx].cost_items?.[
|
|
||||||
expenseIdx
|
|
||||||
]?.[column]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={{
|
|
||||||
wrapper: cn('w-full', className?.wrapper),
|
|
||||||
body: 'p-4 shadow',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='mb-4 text-center'>
|
|
||||||
<h4 className='font-bold text-xl'>
|
|
||||||
Rincian Realisasi Biaya Operasional
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='w-full flex flex-col gap-6'>
|
|
||||||
{formik.values.realizations.length === 0 && (
|
|
||||||
<div>
|
|
||||||
<p className='text-sm text-gray-400 text-center'>
|
|
||||||
Pilih kandang terlebih dahulu!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formik.values.realizations.map((kandangExpense, kandangExpenseIdx) => {
|
|
||||||
const kandangName = formik.values.kandangs?.find(
|
|
||||||
(kandang) => kandang.id === kandangExpense.kandang_id
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
kandangName?.name && (
|
|
||||||
<div
|
|
||||||
key={`kandangExpense-${kandangExpenseIdx}`}
|
|
||||||
className='w-full flex flex-col gap-4'
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h5 className='mb-2 text-lg font-bold text-center'>
|
|
||||||
Biaya {kandangName?.name}
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<div className='overflow-x-auto'>
|
|
||||||
<table className='table'>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Nonstock</th>
|
|
||||||
<th>Total Kuantitas</th>
|
|
||||||
<th>Total Biaya</th>
|
|
||||||
<th>Catatan</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{kandangExpense.cost_items.map(
|
|
||||||
(expenseItem, expenseIdx) => (
|
|
||||||
<tr key={`expense-${expenseIdx}`}>
|
|
||||||
<td className='p-2'>
|
|
||||||
<SelectInput
|
|
||||||
placeholder='Pilih Nonstock'
|
|
||||||
value={expenseItem.nonstock}
|
|
||||||
onChange={(val) => {
|
|
||||||
nonstockChangeHandler(
|
|
||||||
kandangExpenseIdx,
|
|
||||||
expenseIdx,
|
|
||||||
val
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
options={nonstockOptions}
|
|
||||||
isLoading={isLoadingNonstockOptions}
|
|
||||||
onInputChange={setNonstockInputValue}
|
|
||||||
className={{ wrapper: 'min-w-48' }}
|
|
||||||
isDisabled
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className='p-2'>
|
|
||||||
<NumberInput
|
|
||||||
required
|
|
||||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
|
|
||||||
placeholder='Masukkan Total Kuantitas'
|
|
||||||
value={
|
|
||||||
formik.values.realizations[
|
|
||||||
kandangExpenseIdx
|
|
||||||
].cost_items[expenseIdx].quantity ?? ''
|
|
||||||
}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={isExpenseRepeaterInputError(
|
|
||||||
'quantity',
|
|
||||||
kandangExpenseIdx,
|
|
||||||
expenseIdx
|
|
||||||
)}
|
|
||||||
className={{ wrapper: 'min-w-24' }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className='p-2'>
|
|
||||||
<NumberInput
|
|
||||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`}
|
|
||||||
placeholder='Masukkan Total Biaya'
|
|
||||||
value={
|
|
||||||
formik.values.realizations[
|
|
||||||
kandangExpenseIdx
|
|
||||||
].cost_items[expenseIdx].total_cost ?? ''
|
|
||||||
}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={isExpenseRepeaterInputError(
|
|
||||||
'total_cost',
|
|
||||||
kandangExpenseIdx,
|
|
||||||
expenseIdx
|
|
||||||
)}
|
|
||||||
inputPrefix={
|
|
||||||
<span className='text-gray-600 font-medium'>
|
|
||||||
Rp
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
className={{ wrapper: 'min-w-24' }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className='p-2'>
|
|
||||||
<TextInput
|
|
||||||
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
|
|
||||||
placeholder='Tuliskan catatan'
|
|
||||||
value={
|
|
||||||
formik.values.realizations[
|
|
||||||
kandangExpenseIdx
|
|
||||||
].cost_items[expenseIdx].notes ?? ''
|
|
||||||
}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={isExpenseRepeaterInputError(
|
|
||||||
'notes',
|
|
||||||
kandangExpenseIdx,
|
|
||||||
expenseIdx
|
|
||||||
)}
|
|
||||||
className={{ wrapper: 'min-w-24' }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpenseRealizationKandangDetailExpense;
|
|
||||||
@@ -3,32 +3,27 @@ import { Expense } from '@/types/api/expense';
|
|||||||
import { formatDate } from '@/lib/helper';
|
import { formatDate } from '@/lib/helper';
|
||||||
|
|
||||||
type ExpenseFormSchemaType = {
|
type ExpenseFormSchemaType = {
|
||||||
category?: {
|
|
||||||
value: 'BOP' | 'NON-BOP';
|
|
||||||
label: 'BOP' | 'NON-BOP';
|
|
||||||
};
|
|
||||||
location?: {
|
location?: {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
transaction_date?: string;
|
transaction_date?: string;
|
||||||
kandangs?: { id: number; name: string }[];
|
kandangs?: { id: number; name: string }[];
|
||||||
supplier?: {
|
vendor?: {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
existing_documents?: { id: number; name: string; url: string }[];
|
existing_documents?: { name: string; url: string }[];
|
||||||
deleted_documents?: number[];
|
request_documents?: File[];
|
||||||
documents?: File[];
|
kandangExpenses: {
|
||||||
cost_per_kandangs: {
|
kandangId: number;
|
||||||
kandang_id: number;
|
expenses: {
|
||||||
cost_items: {
|
|
||||||
nonstock?: {
|
nonstock?: {
|
||||||
value: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
quantity?: number;
|
totalQuantity?: number;
|
||||||
total_cost?: number;
|
totalExpense?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
@@ -36,11 +31,6 @@ type ExpenseFormSchemaType = {
|
|||||||
|
|
||||||
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||||
Yup.object({
|
Yup.object({
|
||||||
category: Yup.object({
|
|
||||||
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
|
|
||||||
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
|
|
||||||
}).required('Kategori wajib diisi!'),
|
|
||||||
|
|
||||||
location: Yup.object({
|
location: Yup.object({
|
||||||
value: Yup.number().min(1).required(),
|
value: Yup.number().min(1).required(),
|
||||||
label: Yup.string().required(),
|
label: Yup.string().required(),
|
||||||
@@ -57,36 +47,35 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
|||||||
.min(1, 'Kandang wajib dipilih!')
|
.min(1, 'Kandang wajib dipilih!')
|
||||||
.required('Kandang wajib dipilih!'),
|
.required('Kandang wajib dipilih!'),
|
||||||
|
|
||||||
supplier: Yup.object({
|
vendor: Yup.object({
|
||||||
value: Yup.number().min(1).required(),
|
value: Yup.number().min(1).required(),
|
||||||
label: Yup.string().required(),
|
label: Yup.string().required(),
|
||||||
}).required('Vendor wajib diisi!'),
|
}).required('Vendor wajib diisi!'),
|
||||||
|
|
||||||
existing_documents: Yup.array().of(
|
existing_documents: Yup.array().of(
|
||||||
Yup.object({
|
Yup.object({
|
||||||
id: Yup.number().required(),
|
|
||||||
name: Yup.string().required(),
|
name: Yup.string().required(),
|
||||||
url: Yup.string().required(),
|
url: Yup.string().required(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
|
||||||
deleted_documents: Yup.array().of(Yup.number().required()).optional(),
|
request_documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
|
||||||
|
|
||||||
documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
|
kandangExpenses: Yup.array()
|
||||||
|
|
||||||
cost_per_kandangs: Yup.array()
|
|
||||||
.of(
|
.of(
|
||||||
Yup.object({
|
Yup.object({
|
||||||
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
|
kandangId: Yup.number().min(1, 'Wajib memilih kandang!').required(),
|
||||||
cost_items: Yup.array()
|
expenses: Yup.array()
|
||||||
.of(
|
.of(
|
||||||
Yup.object({
|
Yup.object({
|
||||||
nonstock: Yup.object({
|
nonstock: Yup.object({
|
||||||
value: Yup.number().min(1).required(),
|
value: Yup.number().min(1).required(),
|
||||||
label: Yup.string().required(),
|
label: Yup.string().required(),
|
||||||
}).required('Nonstock wajib diisi!'),
|
}).required('Nonstock wajib diisi!'),
|
||||||
quantity: Yup.number().required('Total kuantitas wajib diisi!'),
|
totalQuantity: Yup.number().required(
|
||||||
total_cost: Yup.number().required('Total biaya wajib diisi!'),
|
'Total kuantitas wajib diisi!'
|
||||||
|
),
|
||||||
|
totalExpense: Yup.number().required('Total biaya wajib diisi!'),
|
||||||
notes: Yup.string(),
|
notes: Yup.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -101,7 +90,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
|||||||
export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema;
|
export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema;
|
||||||
|
|
||||||
export const UploadRequestDocumentsFormSchema = Yup.object({
|
export const UploadRequestDocumentsFormSchema = Yup.object({
|
||||||
documents: Yup.array().of(Yup.mixed<File>().required()).required(),
|
request_documents: Yup.array().of(Yup.mixed<File>().required()).required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ExpenseRequestFormValues = Yup.InferType<
|
export type ExpenseRequestFormValues = Yup.InferType<
|
||||||
@@ -116,52 +105,39 @@ export const getExpenseFormInitialValues = (
|
|||||||
initialValues?: Expense
|
initialValues?: Expense
|
||||||
): ExpenseRequestFormValues => {
|
): ExpenseRequestFormValues => {
|
||||||
return {
|
return {
|
||||||
category: initialValues?.category
|
|
||||||
? {
|
|
||||||
value: initialValues.category,
|
|
||||||
label: initialValues.category,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
location: initialValues?.location
|
location: initialValues?.location
|
||||||
? {
|
? {
|
||||||
value: initialValues.location.id,
|
value: initialValues.location.id,
|
||||||
label: initialValues.location.name,
|
label: initialValues.location.name,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
transaction_date: initialValues?.expense_date
|
transaction_date: initialValues?.transaction_date
|
||||||
? formatDate(initialValues.expense_date, 'YYYY-MM-DD')
|
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
|
||||||
: undefined,
|
: undefined,
|
||||||
kandangs: initialValues?.kandangs.map((kandang) => ({
|
kandangs: initialValues?.kandangs.map((kandang) => ({
|
||||||
id: kandang.kandang_id,
|
id: kandang.id,
|
||||||
name: kandang.name,
|
name: kandang.name,
|
||||||
})),
|
})),
|
||||||
supplier: initialValues?.supplier
|
vendor: initialValues?.vendor
|
||||||
? {
|
? {
|
||||||
value: initialValues.supplier.id,
|
value: initialValues.vendor.id,
|
||||||
label: initialValues.supplier.name,
|
label: initialValues.vendor.name,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
existing_documents: initialValues?.documents?.map((doc) => ({
|
existing_documents: initialValues?.request_documents,
|
||||||
id: doc.id,
|
request_documents: [],
|
||||||
name: doc.path,
|
kandangExpenses: initialValues?.kandang_expenses
|
||||||
url: doc.path,
|
? initialValues.kandang_expenses.map((kandangExpense) => ({
|
||||||
})),
|
kandangId: kandangExpense.kandang.id,
|
||||||
deleted_documents: [],
|
expenses: kandangExpense.expenses.map((expenseItem) => ({
|
||||||
documents: [],
|
nonstock: {
|
||||||
cost_per_kandangs: initialValues?.kandangs
|
value: expenseItem.nonstock.id,
|
||||||
? initialValues.kandangs.map((kandangExpense) => ({
|
label: expenseItem.nonstock.name,
|
||||||
kandang_id: kandangExpense.kandang_id,
|
},
|
||||||
cost_items: kandangExpense.pengajuans
|
totalQuantity: expenseItem.total_quantity,
|
||||||
? kandangExpense.pengajuans.map((expenseItem) => ({
|
totalExpense: expenseItem.total_expense,
|
||||||
nonstock: {
|
notes: expenseItem.notes,
|
||||||
value: expenseItem.nonstock.id,
|
})),
|
||||||
label: expenseItem.nonstock.name,
|
|
||||||
},
|
|
||||||
quantity: expenseItem.qty,
|
|
||||||
total_cost: expenseItem.total_price,
|
|
||||||
notes: expenseItem.note,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface ExpenseFormProps {
|
|||||||
initialValues?: Expense;
|
initialValues?: Expense;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: integrate this with real API
|
||||||
const ExpenseRequestForm = ({
|
const ExpenseRequestForm = ({
|
||||||
type = 'add',
|
type = 'add',
|
||||||
initialValues,
|
initialValues,
|
||||||
@@ -58,7 +59,7 @@ const ExpenseRequestForm = ({
|
|||||||
const createExpenseHandler = useCallback(
|
const createExpenseHandler = useCallback(
|
||||||
async (payload: CreateExpensePayload) => {
|
async (payload: CreateExpensePayload) => {
|
||||||
const createExpenseRes = await ExpenseApi.create(
|
const createExpenseRes = await ExpenseApi.create(
|
||||||
ExpenseApi.convertExpenseRequestPayloadToFormData(payload)
|
ExpenseApi.convertPayloadToFormData(payload)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isResponseError(createExpenseRes)) {
|
if (isResponseError(createExpenseRes)) {
|
||||||
@@ -73,15 +74,10 @@ const ExpenseRequestForm = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateExpenseHandler = useCallback(
|
const updateExpenseHandler = useCallback(
|
||||||
async (
|
async (expenseId: number, payload: UpdateExpensePayload) => {
|
||||||
expenseId: number,
|
|
||||||
payload: UpdateExpensePayload,
|
|
||||||
deletedDocumentIds: number[]
|
|
||||||
) => {
|
|
||||||
const updateExpenseRes = await ExpenseApi.update(
|
const updateExpenseRes = await ExpenseApi.update(
|
||||||
expenseId,
|
expenseId,
|
||||||
ExpenseApi.convertExpenseRequestUpdatePayloadToFormData(payload),
|
ExpenseApi.convertPayloadToFormData(payload)
|
||||||
deletedDocumentIds
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updateExpenseRes?.status === 'error') {
|
if (updateExpenseRes?.status === 'error') {
|
||||||
@@ -106,17 +102,20 @@ const ExpenseRequestForm = ({
|
|||||||
setExpenseFormErrorMessage('');
|
setExpenseFormErrorMessage('');
|
||||||
|
|
||||||
const expensePayload: CreateExpensePayload = {
|
const expensePayload: CreateExpensePayload = {
|
||||||
category: formik.values.category?.value as 'BOP' | 'NON-BOP',
|
locationId: values.location?.value as number,
|
||||||
transaction_date: values?.transaction_date as string,
|
kandangIds: values.kandangs
|
||||||
supplier_id: values.supplier?.value as number,
|
? values.kandangs.map((item) => item.id)
|
||||||
documents: values.documents as File[],
|
: [],
|
||||||
cost_per_kandangs: values.cost_per_kandangs.map((costPerKandang) => ({
|
transaction_date: values.transaction_date as string,
|
||||||
kandang_id: costPerKandang.kandang_id,
|
vendorId: values.vendor?.value as number,
|
||||||
cost_items: costPerKandang.cost_items.map((costItem) => ({
|
request_documents: values.request_documents as File[],
|
||||||
nonstock_id: costItem.nonstock?.value as number,
|
kandang_expenses: values.kandangExpenses.map((kandangExpense) => ({
|
||||||
quantity: parseFloat(String(costItem.quantity)) as number,
|
kandangId: kandangExpense.kandangId,
|
||||||
total_cost: parseFloat(String(costItem.total_cost)) as number,
|
expenses: kandangExpense.expenses.map((expenseItem) => ({
|
||||||
notes: costItem.notes ?? '',
|
nonstockId: expenseItem.nonstock?.value as number,
|
||||||
|
total_quantity: expenseItem.totalQuantity as number,
|
||||||
|
total_expense: expenseItem.totalExpense as number,
|
||||||
|
notes: expenseItem.notes,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -127,28 +126,9 @@ const ExpenseRequestForm = ({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'edit':
|
case 'edit':
|
||||||
const expenseUpdatePayload: UpdateExpensePayload = {
|
|
||||||
category: formik.values.category?.value as 'BOP' | 'NON-BOP',
|
|
||||||
transaction_date: values?.transaction_date as string,
|
|
||||||
supplier_id: values.supplier?.value as number,
|
|
||||||
documents: values.documents as File[],
|
|
||||||
cost_per_kandang: values.cost_per_kandangs.map(
|
|
||||||
(costPerKandang) => ({
|
|
||||||
kandang_id: costPerKandang.kandang_id,
|
|
||||||
cost_items: costPerKandang.cost_items.map((costItem) => ({
|
|
||||||
nonstock_id: costItem.nonstock?.value as number,
|
|
||||||
quantity: parseFloat(String(costItem.quantity)) as number,
|
|
||||||
total_cost: parseFloat(String(costItem.total_cost)) as number,
|
|
||||||
notes: costItem.notes ?? '',
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateExpenseHandler(
|
await updateExpenseHandler(
|
||||||
initialValues?.id as number,
|
initialValues?.id as number,
|
||||||
expenseUpdatePayload,
|
expensePayload
|
||||||
formik.values.deleted_documents ?? []
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -165,103 +145,72 @@ const ExpenseRequestForm = ({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
setInputValue: setVendorInputValue,
|
setInputValue: setVendorInputValue,
|
||||||
options: supplierOptions,
|
options: vendorOptions,
|
||||||
isLoadingOptions: isLoadingVendorOptions,
|
isLoadingOptions: isLoadingVendorOptions,
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
|
|
||||||
formik.setFieldTouched('category', true);
|
|
||||||
formik.setFieldValue('category', val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
formik.setFieldTouched('location', true);
|
formik.setFieldTouched('location', true);
|
||||||
formik.setFieldValue('location', val);
|
formik.setFieldValue('location', val);
|
||||||
|
|
||||||
formik.setFieldValue('kandangs', []);
|
formik.setFieldValue('kandangs', []);
|
||||||
formik.setFieldValue('cost_per_kandangs', []);
|
formik.setFieldValue('kandangExpenses', []);
|
||||||
};
|
};
|
||||||
|
|
||||||
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
|
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
|
||||||
formik.setFieldTouched('kandangs', true);
|
formik.setFieldTouched('kandangs', true);
|
||||||
formik.setFieldValue('kandangs', kandangs);
|
formik.setFieldValue('kandangs', kandangs);
|
||||||
|
|
||||||
const newCostPerKandangs = [...(formik.values.cost_per_kandangs ?? [])];
|
const newKandangExpenses = [...(formik.values.kandangExpenses ?? [])];
|
||||||
|
|
||||||
// add new cost_per_kandangs
|
// add new kandangExpenses
|
||||||
kandangs.forEach((kandangItem) => {
|
kandangs.forEach((kandangItem) => {
|
||||||
const isKandangExistInCostPerKandangs = newCostPerKandangs.find(
|
const isKandangExistInKandangExpense = newKandangExpenses.find(
|
||||||
(costPerKandangItem) => costPerKandangItem.kandang_id === kandangItem.id
|
(kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isKandangExistInCostPerKandangs) return;
|
if (isKandangExistInKandangExpense) return;
|
||||||
|
|
||||||
newCostPerKandangs.push({
|
newKandangExpenses.push({
|
||||||
kandang_id: kandangItem.id,
|
kandangId: kandangItem.id,
|
||||||
cost_items: [
|
expenses: [
|
||||||
{
|
{
|
||||||
nonstock: undefined,
|
nonstock: undefined,
|
||||||
quantity: undefined,
|
totalExpense: undefined,
|
||||||
total_cost: undefined,
|
totalQuantity: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// prune cost_per_kandangs
|
// prune kandangExpenses
|
||||||
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
|
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
|
||||||
const deletedCostPerKandangsIdx: number[] = [];
|
const deletedKandangExpensesIdx: number[] = [];
|
||||||
|
|
||||||
newCostPerKandangs.forEach((costPerKandang, idx) => {
|
newKandangExpenses.forEach((kandangExpense, idx) => {
|
||||||
const isCostPerKandangValid = kandangIds.has(costPerKandang.kandang_id);
|
const isKandangExpenseValid = kandangIds.has(kandangExpense.kandangId);
|
||||||
|
|
||||||
if (!isCostPerKandangValid) {
|
if (!isKandangExpenseValid) {
|
||||||
deletedCostPerKandangsIdx.push(idx);
|
deletedKandangExpensesIdx.push(idx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
deletedCostPerKandangsIdx.forEach((deletedCostPerKandangIdx) => {
|
deletedKandangExpensesIdx.forEach((deletedKandangExpenseIdx) => {
|
||||||
newCostPerKandangs.splice(deletedCostPerKandangIdx, 1);
|
newKandangExpenses.splice(deletedKandangExpenseIdx, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
formik.setFieldValue('cost_per_kandangs', newCostPerKandangs);
|
formik.setFieldValue('kandangExpenses', newKandangExpenses);
|
||||||
};
|
};
|
||||||
|
|
||||||
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
|
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
formik.setFieldTouched('supplier', true);
|
formik.setFieldTouched('vendor', true);
|
||||||
formik.setFieldValue('supplier', val);
|
formik.setFieldValue('vendor', val);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestDocumentsChangeHandler = (val: File[]) => {
|
const requestDocumentsChangeHandler = (val: File[]) => {
|
||||||
formik.setFieldTouched('documents', true);
|
formik.setFieldTouched('request_documents', true);
|
||||||
formik.setFieldValue('documents', val);
|
formik.setFieldValue('request_documents', val);
|
||||||
};
|
|
||||||
|
|
||||||
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
|
|
||||||
const newRequestDocuments = formik.values.documents;
|
|
||||||
|
|
||||||
newRequestDocuments?.splice(deletedFileIdx, 1);
|
|
||||||
|
|
||||||
formik.setFieldValue('documents', newRequestDocuments);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteDocumentClickHandler = (
|
|
||||||
deletedDocumentIdx: number,
|
|
||||||
deletedDocumentId: number
|
|
||||||
) => {
|
|
||||||
const newDeletedDocumentIds = [...(formik.values.deleted_documents ?? [])];
|
|
||||||
const newExistingDocuments = [
|
|
||||||
...(formik.values.existing_documents ?? []),
|
|
||||||
].filter((_, idx) => idx !== deletedDocumentIdx);
|
|
||||||
|
|
||||||
newDeletedDocumentIds.push(deletedDocumentId);
|
|
||||||
|
|
||||||
formik.setFieldTouched('deleted_documents', true);
|
|
||||||
formik.setFieldValue('deleted_documents', newDeletedDocumentIds);
|
|
||||||
|
|
||||||
formik.setFieldTouched('existing_documents', true);
|
|
||||||
formik.setFieldValue('existing_documents', newExistingDocuments);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteExpenseClickHandler = () => {
|
const deleteExpenseClickHandler = () => {
|
||||||
@@ -320,25 +269,6 @@ const ExpenseRequestForm = ({
|
|||||||
className='w-full mt-8 flex flex-col gap-6'
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
>
|
>
|
||||||
<div className='grid grid-cols-12 gap-4'>
|
<div className='grid grid-cols-12 gap-4'>
|
||||||
<SelectInput
|
|
||||||
label='Kategori'
|
|
||||||
required
|
|
||||||
placeholder='Pilih Kategori'
|
|
||||||
value={formik.values.category}
|
|
||||||
onChange={categoryChangeHandler}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: 'BOP',
|
|
||||||
label: 'BOP',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'NON-BOP',
|
|
||||||
label: 'NON-BOP',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className={{ wrapper: 'col-span-12 sm:col-span-4' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label='Lokasi'
|
label='Lokasi'
|
||||||
required
|
required
|
||||||
@@ -348,7 +278,7 @@ const ExpenseRequestForm = ({
|
|||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isLoading={isLoadingLocationOptions}
|
isLoading={isLoadingLocationOptions}
|
||||||
onInputChange={setLocationInputValue}
|
onInputChange={setLocationInputValue}
|
||||||
className={{ wrapper: 'col-span-12 sm:col-span-4' }}
|
className={{ wrapper: 'col-span-12 sm:col-span-6' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DateInput
|
<DateInput
|
||||||
@@ -358,7 +288,7 @@ const ExpenseRequestForm = ({
|
|||||||
value={formik.values.transaction_date}
|
value={formik.values.transaction_date}
|
||||||
onChange={formik.handleChange}
|
onChange={formik.handleChange}
|
||||||
className={{
|
className={{
|
||||||
wrapper: 'col-span-12 sm:col-span-4',
|
wrapper: 'col-span-12 sm:col-span-6',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -376,9 +306,9 @@ const ExpenseRequestForm = ({
|
|||||||
label='Vendor'
|
label='Vendor'
|
||||||
required
|
required
|
||||||
placeholder='Pilih Vendor'
|
placeholder='Pilih Vendor'
|
||||||
value={formik.values.supplier}
|
value={formik.values.vendor}
|
||||||
onChange={supplierChangeHandler}
|
onChange={vendorChangeHandler}
|
||||||
options={supplierOptions}
|
options={vendorOptions}
|
||||||
isLoading={isLoadingVendorOptions}
|
isLoading={isLoadingVendorOptions}
|
||||||
onInputChange={setVendorInputValue}
|
onInputChange={setVendorInputValue}
|
||||||
className={{ wrapper: 'col-span-12' }}
|
className={{ wrapper: 'col-span-12' }}
|
||||||
@@ -386,10 +316,9 @@ const ExpenseRequestForm = ({
|
|||||||
|
|
||||||
<DropFileInput
|
<DropFileInput
|
||||||
label='Dokumen Pengajuan'
|
label='Dokumen Pengajuan'
|
||||||
name='documents'
|
name='request_documents'
|
||||||
values={formik.values.documents}
|
values={formik.values.request_documents}
|
||||||
onChange={requestDocumentsChangeHandler}
|
onChange={requestDocumentsChangeHandler}
|
||||||
onDelete={requestDocumentsDeleteHandler}
|
|
||||||
accept={{
|
accept={{
|
||||||
...ACCEPTED_FILE_TYPE.PDF,
|
...ACCEPTED_FILE_TYPE.PDF,
|
||||||
...ACCEPTED_FILE_TYPE.IMAGE,
|
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||||
@@ -407,41 +336,20 @@ const ExpenseRequestForm = ({
|
|||||||
{formik.values.existing_documents.map(
|
{formik.values.existing_documents.map(
|
||||||
(existingDocument, existingDocumentIdx) => (
|
(existingDocument, existingDocumentIdx) => (
|
||||||
<li key={existingDocumentIdx}>
|
<li key={existingDocumentIdx}>
|
||||||
<div className='w-full flex flex-wrap justify-between'>
|
<Link
|
||||||
<Link
|
href={existingDocument.url}
|
||||||
href={existingDocument.url}
|
target='_blank'
|
||||||
target='_blank'
|
rel='noopener noreferrer'
|
||||||
rel='noopener noreferrer'
|
className='text-blue-500 underline'
|
||||||
className='text-blue-500 underline'
|
>
|
||||||
>
|
{existingDocument.name}{' '}
|
||||||
{existingDocument.name}{' '}
|
<Icon
|
||||||
<Icon
|
icon='cuida:open-in-new-tab-outline'
|
||||||
icon='cuida:open-in-new-tab-outline'
|
width={12}
|
||||||
width={12}
|
height={12}
|
||||||
height={12}
|
className='inline'
|
||||||
className='inline'
|
/>
|
||||||
/>
|
</Link>
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
color='error'
|
|
||||||
onClick={() => {
|
|
||||||
deleteDocumentClickHandler(
|
|
||||||
existingDocumentIdx,
|
|
||||||
existingDocument.id
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className='p-1 rounded-full text-error focus-visible:text-error-content hover:text-error-content'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='fluent:delete-12-regular'
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -494,17 +402,6 @@ const ExpenseRequestForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{expenseFormErrorMessage && (
|
|
||||||
<div role='alert' className='alert alert-error w-full'>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:error-outline'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
<span>{expenseFormErrorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type !== 'detail' && (
|
{type !== 'detail' && (
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-row justify-end gap-2', {
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
@@ -527,6 +424,17 @@ const ExpenseRequestForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{expenseFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{expenseFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -41,28 +41,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
|||||||
val: OptionType | OptionType[] | null
|
val: OptionType | OptionType[] | null
|
||||||
) => {
|
) => {
|
||||||
formik.setFieldTouched(
|
formik.setFieldTouched(
|
||||||
`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
|
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
formik.setFieldValue(
|
formik.setFieldValue(
|
||||||
`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
|
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
|
||||||
val
|
val
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addExpenseItemHandler = (kandangExpenseIdx: number) => {
|
const addExpenseItemHandler = (kandangExpenseIdx: number) => {
|
||||||
const newExpensesValue = [
|
const newExpensesValue = [
|
||||||
...formik.values.cost_per_kandangs[kandangExpenseIdx].cost_items,
|
...formik.values.kandangExpenses[kandangExpenseIdx].expenses,
|
||||||
{
|
{
|
||||||
nonstock: undefined,
|
nonstock: undefined,
|
||||||
total_cost: undefined,
|
totalExpense: undefined,
|
||||||
quantity: undefined,
|
totalQuantity: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
formik.setFieldValue(
|
formik.setFieldValue(
|
||||||
`cost_per_kandangs[${kandangExpenseIdx}].cost_items`,
|
`kandangExpenses[${kandangExpenseIdx}].expenses`,
|
||||||
newExpensesValue
|
newExpensesValue
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -71,28 +71,27 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
|||||||
kandangExpenseIdx: number,
|
kandangExpenseIdx: number,
|
||||||
expenseIdx: number
|
expenseIdx: number
|
||||||
) => {
|
) => {
|
||||||
const path = `cost_per_kandangs[${kandangExpenseIdx}].cost_items`;
|
const path = `kandangExpenses[${kandangExpenseIdx}].expenses`;
|
||||||
|
|
||||||
// trims values, errors, and touched at expenseIdx
|
// trims values, errors, and touched at expenseIdx
|
||||||
removeArrayItemAndSync(formik, path, expenseIdx);
|
removeArrayItemAndSync(formik, path, expenseIdx);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isExpenseRepeaterInputError = (
|
const isExpenseRepeaterInputError = (
|
||||||
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes',
|
column: 'nonstock' | 'totalQuantity' | 'totalExpense' | 'notes',
|
||||||
kandangExpenseIdx: number,
|
kandangExpenseIdx: number,
|
||||||
expenseIdx: number
|
expenseIdx: number
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
formik.touched.cost_per_kandangs?.[kandangExpenseIdx]?.cost_items?.[
|
formik.touched.kandangExpenses?.[kandangExpenseIdx]?.expenses?.[
|
||||||
expenseIdx
|
expenseIdx
|
||||||
]?.[column] &&
|
]?.[column] &&
|
||||||
Boolean(
|
Boolean(
|
||||||
formik.errors.cost_per_kandangs?.[kandangExpenseIdx] instanceof
|
formik.errors.kandangExpenses?.[kandangExpenseIdx] instanceof Object &&
|
||||||
Object &&
|
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
|
||||||
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[
|
|
||||||
expenseIdx
|
expenseIdx
|
||||||
] instanceof Object &&
|
] instanceof Object &&
|
||||||
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[
|
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
|
||||||
expenseIdx
|
expenseIdx
|
||||||
]?.[column]
|
]?.[column]
|
||||||
)
|
)
|
||||||
@@ -113,8 +112,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full flex flex-col gap-6'>
|
<div className='w-full flex flex-col gap-6'>
|
||||||
{(formik.values.cost_per_kandangs.length === 0 ||
|
{formik.values.kandangExpenses.length === 0 && (
|
||||||
!formik.values.supplier?.value) && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className='text-sm text-gray-400 text-center'>
|
<p className='text-sm text-gray-400 text-center'>
|
||||||
Pilih kandang terlebih dahulu!
|
Pilih kandang terlebih dahulu!
|
||||||
@@ -122,171 +120,168 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formik.values.cost_per_kandangs.length > 0 &&
|
{formik.values.kandangExpenses.map(
|
||||||
formik.values.supplier?.value &&
|
(kandangExpense, kandangExpenseIdx) => {
|
||||||
formik.values.cost_per_kandangs.map(
|
const kandangName = formik.values.kandangs?.find(
|
||||||
(kandangExpense, kandangExpenseIdx) => {
|
(kandang) => kandang.id === kandangExpense.kandangId
|
||||||
const kandangName = formik.values.kandangs?.find(
|
);
|
||||||
(kandang) => kandang.id === kandangExpense.kandang_id
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
kandangName?.name && (
|
kandangName?.name && (
|
||||||
<div
|
<div
|
||||||
key={`kandangExpense-${kandangExpenseIdx}`}
|
key={`kandangExpense-${kandangExpenseIdx}`}
|
||||||
className='w-full flex flex-col gap-4'
|
className='w-full flex flex-col gap-4'
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h5 className='mb-2 text-lg font-bold text-center'>
|
<h5 className='mb-2 text-lg font-bold text-center'>
|
||||||
Biaya {kandangName?.name}
|
Biaya {kandangName?.name}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<div className='overflow-x-auto'>
|
<div className='overflow-x-auto'>
|
||||||
<table className='table'>
|
<table className='table'>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nonstock</th>
|
<th>Nonstock</th>
|
||||||
<th>Total Kuantitas</th>
|
<th>Total Kuantitas</th>
|
||||||
<th>Total Biaya</th>
|
<th>Total Biaya</th>
|
||||||
<th>Catatan</th>
|
<th>Catatan</th>
|
||||||
{type !== 'detail' && <th>Aksi</th>}
|
{type !== 'detail' && <th>Aksi</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{kandangExpense.cost_items.map(
|
{kandangExpense.expenses.map(
|
||||||
(expenseItem, expenseIdx) => (
|
(expenseItem, expenseIdx) => (
|
||||||
<tr key={`expense-${expenseIdx}`}>
|
<tr key={`expense-${expenseIdx}`}>
|
||||||
<td className='p-2'>
|
<td className='p-2'>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
placeholder='Pilih Nonstock'
|
placeholder='Pilih Nonstock'
|
||||||
value={expenseItem.nonstock}
|
value={expenseItem.nonstock}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
nonstockChangeHandler(
|
nonstockChangeHandler(
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx,
|
||||||
|
val
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
options={nonstockOptions}
|
||||||
|
isLoading={isLoadingNonstockOptions}
|
||||||
|
onInputChange={setNonstockInputValue}
|
||||||
|
className={{ wrapper: 'min-w-48' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<NumberInput
|
||||||
|
required
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalQuantity`}
|
||||||
|
placeholder='Masukkan Total Kuantitas'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].totalQuantity ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'totalQuantity',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<NumberInput
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalExpense`}
|
||||||
|
placeholder='Masukkan Total Biaya'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].totalExpense ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'totalExpense',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
inputPrefix={
|
||||||
|
<span className='text-gray-600 font-medium'>
|
||||||
|
Rp
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<TextInput
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].notes`}
|
||||||
|
placeholder='Tuliskan catatan'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].notes ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'notes',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={() =>
|
||||||
|
deleteExpenseItemHandler(
|
||||||
kandangExpenseIdx,
|
kandangExpenseIdx,
|
||||||
expenseIdx,
|
expenseIdx
|
||||||
val
|
)
|
||||||
);
|
|
||||||
}}
|
|
||||||
options={nonstockOptions}
|
|
||||||
isLoading={isLoadingNonstockOptions}
|
|
||||||
onInputChange={setNonstockInputValue}
|
|
||||||
className={{ wrapper: 'min-w-48' }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className='p-2'>
|
|
||||||
<NumberInput
|
|
||||||
required
|
|
||||||
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
|
|
||||||
placeholder='Masukkan Total Kuantitas'
|
|
||||||
value={
|
|
||||||
formik.values.cost_per_kandangs[
|
|
||||||
kandangExpenseIdx
|
|
||||||
].cost_items[expenseIdx].quantity ?? ''
|
|
||||||
}
|
}
|
||||||
onChange={formik.handleChange}
|
>
|
||||||
onBlur={formik.handleBlur}
|
<Icon
|
||||||
isError={isExpenseRepeaterInputError(
|
icon='material-symbols:delete-outline-rounded'
|
||||||
'quantity',
|
width={24}
|
||||||
kandangExpenseIdx,
|
height={24}
|
||||||
expenseIdx
|
/>
|
||||||
)}
|
</Button>
|
||||||
className={{ wrapper: 'min-w-24' }}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
<td className='p-2'>
|
</tr>
|
||||||
<NumberInput
|
)
|
||||||
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`}
|
)}
|
||||||
placeholder='Masukkan Total Biaya'
|
</tbody>
|
||||||
value={
|
</table>
|
||||||
formik.values.cost_per_kandangs[
|
|
||||||
kandangExpenseIdx
|
|
||||||
].cost_items[expenseIdx].total_cost ??
|
|
||||||
''
|
|
||||||
}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={isExpenseRepeaterInputError(
|
|
||||||
'total_cost',
|
|
||||||
kandangExpenseIdx,
|
|
||||||
expenseIdx
|
|
||||||
)}
|
|
||||||
inputPrefix={
|
|
||||||
<span className='text-gray-600 font-medium'>
|
|
||||||
Rp
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
className={{ wrapper: 'min-w-24' }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className='p-2'>
|
|
||||||
<TextInput
|
|
||||||
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
|
|
||||||
placeholder='Tuliskan catatan'
|
|
||||||
value={
|
|
||||||
formik.values.cost_per_kandangs[
|
|
||||||
kandangExpenseIdx
|
|
||||||
].cost_items[expenseIdx].notes ?? ''
|
|
||||||
}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={isExpenseRepeaterInputError(
|
|
||||||
'notes',
|
|
||||||
kandangExpenseIdx,
|
|
||||||
expenseIdx
|
|
||||||
)}
|
|
||||||
className={{ wrapper: 'min-w-24' }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<td>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='error'
|
|
||||||
onClick={() =>
|
|
||||||
deleteExpenseItemHandler(
|
|
||||||
kandangExpenseIdx,
|
|
||||||
expenseIdx
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
color='success'
|
|
||||||
onClick={() => addExpenseItemHandler(kandangExpenseIdx)}
|
|
||||||
className='w-fit mx-auto'
|
|
||||||
>
|
|
||||||
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
|
|
||||||
Tambah
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
);
|
{type !== 'detail' && (
|
||||||
}
|
<Button
|
||||||
)}
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
color='success'
|
||||||
|
onClick={() => addExpenseItemHandler(kandangExpenseIdx)}
|
||||||
|
className='w-fit mx-auto'
|
||||||
|
>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,651 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Document,
|
|
||||||
Image,
|
|
||||||
Link,
|
|
||||||
Page,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
import { Expense } from '@/types/api/expense';
|
|
||||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
|
||||||
|
|
||||||
interface ExpensePDFProps {
|
|
||||||
expense?: Expense;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpensePDFStyle = StyleSheet.create({
|
|
||||||
page: {
|
|
||||||
paddingTop: 24,
|
|
||||||
paddingBottom: 64,
|
|
||||||
paddingHorizontal: 32,
|
|
||||||
},
|
|
||||||
|
|
||||||
companyInfoHeader: {
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
companyLogo: {
|
|
||||||
width: 64,
|
|
||||||
height: 'auto',
|
|
||||||
},
|
|
||||||
companyInfoHeaderDate: {
|
|
||||||
paddingTop: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
companyName: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
companyAddress: {
|
|
||||||
fontSize: 8,
|
|
||||||
maxWidth: 400,
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
|
|
||||||
title: {
|
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: '150%',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontFamily: 'Times-Roman',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
|
|
||||||
footer: {
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 32,
|
|
||||||
|
|
||||||
position: 'absolute',
|
|
||||||
fontSize: 10,
|
|
||||||
bottom: 30,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'grey',
|
|
||||||
},
|
|
||||||
|
|
||||||
// wrapper
|
|
||||||
generalInfoTable: {
|
|
||||||
width: '100%',
|
|
||||||
marginTop: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
|
|
||||||
generalInfoTableRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
},
|
|
||||||
|
|
||||||
// columns
|
|
||||||
generalInfoTableColLabel: {
|
|
||||||
width: '30%',
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
},
|
|
||||||
generalInfoTableColSeparator: {
|
|
||||||
width: '3%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: 6,
|
|
||||||
},
|
|
||||||
generalInfoTableColValue: {
|
|
||||||
width: '67%',
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
},
|
|
||||||
|
|
||||||
generalInfoTableLabelText: {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
generalInfoTableValueText: {},
|
|
||||||
|
|
||||||
// expense detail table
|
|
||||||
expenseDetailContainer: {
|
|
||||||
width: '100%',
|
|
||||||
marginTop: 12,
|
|
||||||
},
|
|
||||||
expenseDetailTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: '150%',
|
|
||||||
fontFamily: 'Times-Roman',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
kandangExpenseContainer: {
|
|
||||||
width: '100%',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
kandangExpenseTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: '150%',
|
|
||||||
fontFamily: 'Times-Roman',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
kandangExpenseTable: {
|
|
||||||
width: '100%',
|
|
||||||
marginTop: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#000000',
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
kandangExpenseTableRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#000000',
|
|
||||||
},
|
|
||||||
kandangExpenseTableColLabel: {
|
|
||||||
width: '20%',
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
},
|
|
||||||
kandangExpenseTableColLabelBorderRight: {
|
|
||||||
borderRight: '1px solid #000000',
|
|
||||||
},
|
|
||||||
kandangExpenseTableColNonstock: {
|
|
||||||
width: '20%',
|
|
||||||
},
|
|
||||||
kandangExpenseTableColNote: {
|
|
||||||
width: '40%',
|
|
||||||
},
|
|
||||||
kandangExpenseHeaderLabelText: {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
kandangExpenseLabelText: {
|
|
||||||
fontSize: 10,
|
|
||||||
},
|
|
||||||
kandangExpenseTableFooterColTotalExpenseCaption: {
|
|
||||||
width: '40%',
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
kandangExpenseTableFooterColTotalExpenseValue: {
|
|
||||||
width: '60%',
|
|
||||||
paddingVertical: 6,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
},
|
|
||||||
|
|
||||||
// utils
|
|
||||||
doubleDivider: {
|
|
||||||
width: '100%',
|
|
||||||
height: 6,
|
|
||||||
borderTop: '2px solid black',
|
|
||||||
borderBottom: '2px solid black',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
|
||||||
const isLatestApprovalRejected =
|
|
||||||
expense?.latest_approval?.action === 'REJECTED';
|
|
||||||
const isExpenseRealized =
|
|
||||||
expense?.latest_approval?.step_number &&
|
|
||||||
expense?.latest_approval.step_number >= 4;
|
|
||||||
|
|
||||||
const realizationStatus = isExpenseRealized
|
|
||||||
? 'Sudah Realisasi'
|
|
||||||
: 'Belum Realisasi';
|
|
||||||
|
|
||||||
const rows = [
|
|
||||||
{ label: 'Nomor PO', value: expense?.po_number },
|
|
||||||
{ label: 'Nomor Referensi', value: expense?.reference_number },
|
|
||||||
{
|
|
||||||
label: 'Kategori',
|
|
||||||
value:
|
|
||||||
expense?.category === 'BOP'
|
|
||||||
? 'Biaya Operasional'
|
|
||||||
: expense?.category === 'NON-BOP'
|
|
||||||
? 'Non Biaya Operasional'
|
|
||||||
: '',
|
|
||||||
},
|
|
||||||
{ label: 'Lokasi', value: expense?.location.name },
|
|
||||||
{
|
|
||||||
label: 'Kandang',
|
|
||||||
value: expense?.kandangs.map((item) => item.name).join(', '),
|
|
||||||
},
|
|
||||||
{ label: 'Vendor', value: expense?.supplier.name },
|
|
||||||
{
|
|
||||||
label: 'Tanggal Transaksi',
|
|
||||||
value: formatDate(expense?.expense_date, 'DD MMMM YYYY'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tanggal Realisasi',
|
|
||||||
value: expense?.realization_date
|
|
||||||
? formatDate(expense?.realization_date, 'DD MMMM YYYY')
|
|
||||||
: '-',
|
|
||||||
},
|
|
||||||
{ label: 'Nama Pengaju', value: expense?.created_user.name },
|
|
||||||
{
|
|
||||||
label: 'Nominal Biaya',
|
|
||||||
value: formatCurrency(expense?.grand_total ?? 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Nominal Pengajuan',
|
|
||||||
value: formatCurrency(expense?.total_pengajuan ?? 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Nominal Realisasi',
|
|
||||||
value: expense?.total_realisasi
|
|
||||||
? formatCurrency(expense?.total_realisasi ?? 0)
|
|
||||||
: '-',
|
|
||||||
},
|
|
||||||
{ label: 'Status Pencairan', value: realizationStatus },
|
|
||||||
{
|
|
||||||
label: 'Status Biaya',
|
|
||||||
value: isLatestApprovalRejected
|
|
||||||
? 'Ditolak'
|
|
||||||
: expense?.latest_approval?.step_name,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Document>
|
|
||||||
<Page style={ExpensePDFStyle.page}>
|
|
||||||
<View>
|
|
||||||
<View style={ExpensePDFStyle.companyInfoHeader}>
|
|
||||||
<Image
|
|
||||||
style={ExpensePDFStyle.companyLogo}
|
|
||||||
src='/assets/img/lti-logo.png'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={ExpensePDFStyle.companyInfoHeaderDate}>
|
|
||||||
{formatDate(Date.now(), 'DD MMMM YYYY')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text style={ExpensePDFStyle.companyName}>
|
|
||||||
PT LUMBUNG TELUR INDONESIA
|
|
||||||
</Text>
|
|
||||||
<Text style={ExpensePDFStyle.companyAddress}>
|
|
||||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
|
||||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={ExpensePDFStyle.doubleDivider} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={ExpensePDFStyle.title}>
|
|
||||||
Laporan{' '}
|
|
||||||
{expense?.category === 'BOP'
|
|
||||||
? 'Biaya Operasional'
|
|
||||||
: 'Non-Biaya Operasional'}{' '}
|
|
||||||
{expense?.po_number}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* General info table */}
|
|
||||||
<View style={ExpensePDFStyle.generalInfoTable}>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<View style={ExpensePDFStyle.generalInfoTableRow} key={row.label}>
|
|
||||||
<View style={ExpensePDFStyle.generalInfoTableColLabel}>
|
|
||||||
<Text style={ExpensePDFStyle.generalInfoTableLabelText}>
|
|
||||||
{row.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={ExpensePDFStyle.generalInfoTableColSeparator}>
|
|
||||||
<Text>:</Text>
|
|
||||||
</View>
|
|
||||||
<View style={ExpensePDFStyle.generalInfoTableColValue}>
|
|
||||||
<Text style={ExpensePDFStyle.generalInfoTableValueText}>
|
|
||||||
{row.value}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Detail expense request */}
|
|
||||||
<View
|
|
||||||
minPresenceAhead={80}
|
|
||||||
style={ExpensePDFStyle.expenseDetailContainer}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.expenseDetailTitle}>
|
|
||||||
Rincian Pengajuan Biaya Operasional
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
|
|
||||||
let expenseRequestTotal = 0;
|
|
||||||
|
|
||||||
kandangExpense.pengajuans?.forEach(
|
|
||||||
(item) => (expenseRequestTotal += item.total_price)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={kandangExpenseIdx}
|
|
||||||
style={ExpensePDFStyle.kandangExpenseContainer}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
|
|
||||||
{kandangExpense.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={ExpensePDFStyle.kandangExpenseTable}>
|
|
||||||
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNonstock,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Nonstock
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Kuantitas
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Total Biaya
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNote,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Catatan
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{kandangExpense.pengajuans?.map((pengajuan, pengajuanIdx) => (
|
|
||||||
<View
|
|
||||||
key={pengajuanIdx}
|
|
||||||
style={ExpensePDFStyle.kandangExpenseTableRow}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNonstock,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{pengajuan.nonstock.name}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{formatNumber(pengajuan.qty)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{formatCurrency(pengajuan.total_price)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNote,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{pengajuan.note}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Total Biaya Keseluruhan
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
{formatCurrency(expenseRequestTotal)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Detail expense realization */}
|
|
||||||
<View
|
|
||||||
minPresenceAhead={80}
|
|
||||||
style={ExpensePDFStyle.expenseDetailContainer}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.expenseDetailTitle}>
|
|
||||||
Rincian Realisasi Biaya Operasional
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
|
|
||||||
let expenseRealizationTotal = 0;
|
|
||||||
|
|
||||||
kandangExpense.realisasi?.forEach(
|
|
||||||
(item) => (expenseRealizationTotal += item.total_price)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={kandangExpenseIdx}
|
|
||||||
style={ExpensePDFStyle.kandangExpenseContainer}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseTitle}>
|
|
||||||
{kandangExpense.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={ExpensePDFStyle.kandangExpenseTable}>
|
|
||||||
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNonstock,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Nonstock
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Kuantitas
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Total Biaya
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNote,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Catatan
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{kandangExpense.realisasi?.map((realisasi, realisasiIdx) => (
|
|
||||||
<View
|
|
||||||
key={realisasiIdx}
|
|
||||||
style={ExpensePDFStyle.kandangExpenseTableRow}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNonstock,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{realisasi.nonstock.name}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{formatNumber(realisasi.qty)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{formatCurrency(realisasi.total_price)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabel,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColNote,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
|
||||||
{realisasi.note}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
|
|
||||||
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
Total Biaya Keseluruhan
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
|
|
||||||
>
|
|
||||||
{formatCurrency(expenseRealizationTotal)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={ExpensePDFStyle.footer} fixed>
|
|
||||||
<Link
|
|
||||||
src={`${process.env.NEXT_PUBLIC_LTI_URL}expense/detail?expenseId=${expense?.id}`}
|
|
||||||
>
|
|
||||||
{expense?.po_number}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
render={({ pageNumber, totalPages }) =>
|
|
||||||
`${pageNumber} / ${totalPages}`
|
|
||||||
}
|
|
||||||
fixed
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Page>
|
|
||||||
</Document>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpensePDF;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { pdf } from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import ExpensePDF from '@/components/pages/expense/pdf/ExpensePDF';
|
|
||||||
|
|
||||||
import { Expense } from '@/types/api/expense';
|
|
||||||
|
|
||||||
interface ExpensePDFPreviewButtonProps {
|
|
||||||
expense?: Expense;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpensePDFPreviewButton = ({ expense }: ExpensePDFPreviewButtonProps) => {
|
|
||||||
const openPdf = async () => {
|
|
||||||
const expensePdfBlob = await pdf(<ExpensePDF expense={expense} />).toBlob();
|
|
||||||
|
|
||||||
const expensePdfUrl = URL.createObjectURL(expensePdfBlob);
|
|
||||||
window.open(expensePdfUrl, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadPdf = async () => {
|
|
||||||
const blob = await pdf(<ExpensePDF expense={expense} />).toBlob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `${expense?.po_number}.pdf`;
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-fit flex flex-col'>
|
|
||||||
<Button onClick={downloadPdf} className='text-xs'>
|
|
||||||
<Icon icon='bx:file' width={16} height={16} />
|
|
||||||
{expense?.po_number}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={openPdf}
|
|
||||||
variant='link'
|
|
||||||
className='p-0 mt-1 text-xs justify-start'
|
|
||||||
>
|
|
||||||
Lihat Dokumen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ExpensePDFPreviewButton;
|
|
||||||
@@ -9,10 +9,12 @@ import { Icon } from '@iconify/react';
|
|||||||
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 { OptionType } from '@/components/input/SelectInput';
|
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
import SelectInput from '@/components/input/SelectInput';
|
import SelectInput from '@/components/input/SelectInput';
|
||||||
@@ -50,15 +52,38 @@ const MovementTable = () => {
|
|||||||
} = useTableFilter({
|
} = useTableFilter({
|
||||||
initial: {
|
initial: {
|
||||||
search: '',
|
search: '',
|
||||||
|
product: '',
|
||||||
|
warehouse: '',
|
||||||
},
|
},
|
||||||
paramMap: {
|
paramMap: {
|
||||||
page: 'page',
|
page: 'page',
|
||||||
pageSize: 'limit',
|
pageSize: 'limit',
|
||||||
|
product: 'product_id',
|
||||||
|
warehouse: 'warehouse_id',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setProductInputValue,
|
||||||
|
options: productOptions,
|
||||||
|
isLoadingOptions: isLoadingProductOptions,
|
||||||
|
} = 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(
|
const { data: movements, isLoading } = useSWR(
|
||||||
`${MovementApi.basePath}${getTableFilterQueryString()}`,
|
`${MovementApi.basePath}${getTableFilterQueryString()}`,
|
||||||
MovementApi.getAllFetcher
|
MovementApi.getAllFetcher
|
||||||
@@ -74,6 +99,16 @@ const MovementTable = () => {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
setSelectedProduct(val as OptionType);
|
||||||
|
updateFilter('product', val ? ((val as OptionType).value as string) : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
setSelectedWarehouse(val as OptionType);
|
||||||
|
updateFilter('warehouse', val ? ((val as OptionType).value as string) : '');
|
||||||
|
};
|
||||||
|
|
||||||
const movementColumns: ColumnDef<Movement>[] = [
|
const movementColumns: ColumnDef<Movement>[] = [
|
||||||
{
|
{
|
||||||
header: '#',
|
header: '#',
|
||||||
@@ -165,7 +200,33 @@ const MovementTable = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-end gap-4'>
|
<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
|
<SelectInput
|
||||||
label='Baris'
|
label='Baris'
|
||||||
options={ROWS_OPTIONS}
|
options={ROWS_OPTIONS}
|
||||||
|
|||||||
@@ -171,17 +171,7 @@ export const MovementFormSchema: Yup.ObjectSchema<MovementFormSchemaType> =
|
|||||||
}).nullable(),
|
}).nullable(),
|
||||||
destination_warehouse_id: Yup.number()
|
destination_warehouse_id: Yup.number()
|
||||||
.required('Gudang tujuan wajib diisi!')
|
.required('Gudang tujuan wajib diisi!')
|
||||||
.typeError('Gudang tujuan wajib diisi!')
|
.typeError('Gudang tujuan wajib diisi!'),
|
||||||
.test(
|
|
||||||
'different-warehouse',
|
|
||||||
'Gudang tujuan tidak boleh sama dengan gudang asal!',
|
|
||||||
function (value) {
|
|
||||||
const { source_warehouse_id } = this.parent;
|
|
||||||
return (
|
|
||||||
!value || !source_warehouse_id || value !== source_warehouse_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
products: Yup.array()
|
products: Yup.array()
|
||||||
.of(ProductObjectSchema)
|
.of(ProductObjectSchema)
|
||||||
.min(1, 'Minimal harus ada 1 produk!')
|
.min(1, 'Minimal harus ada 1 produk!')
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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 NumberInput from '@/components/input/NumberInput';
|
import NumberInput from '@/components/input/NumberInput';
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import SelectInput, {
|
import SelectInput, {
|
||||||
OptionType,
|
OptionType,
|
||||||
useSelect,
|
useSelect,
|
||||||
@@ -27,7 +26,6 @@ import {
|
|||||||
DeliverySchema,
|
DeliverySchema,
|
||||||
} from '@/components/pages/inventory/movement/form/MovementForm.schema';
|
} from '@/components/pages/inventory/movement/form/MovementForm.schema';
|
||||||
import { SupplierApi, WarehouseApi } from '@/services/api/master-data';
|
import { SupplierApi, WarehouseApi } from '@/services/api/master-data';
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
|
||||||
import { ProductWarehouseApi } from '@/services/api/inventory';
|
import { ProductWarehouseApi } from '@/services/api/inventory';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { MovementApi } from '@/services/api/inventory';
|
import { MovementApi } from '@/services/api/inventory';
|
||||||
@@ -102,14 +100,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
isLoadingOptions: isLoadingWarehouses,
|
isLoadingOptions: isLoadingWarehouses,
|
||||||
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
|
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
|
||||||
|
|
||||||
// ===== SELECT INPUT DATA =====
|
|
||||||
const {
|
const {
|
||||||
setInputValue: setSupplierSelectInputValue,
|
setInputValue: setSupplierSelectInputValue,
|
||||||
options: supplierOptions,
|
options: supplierOptions,
|
||||||
isLoadingOptions: isLoadingSuppliers,
|
isLoadingOptions: isLoadingSuppliers,
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
|
} = useSelect(SupplierApi.basePath, 'id', 'name', 'search');
|
||||||
category: 'BOP',
|
|
||||||
});
|
|
||||||
|
|
||||||
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
|
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
|
||||||
const { data: warehouses } = useSWR(
|
const { data: warehouses } = useSWR(
|
||||||
@@ -176,22 +171,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
onSubmit: async (values) => {
|
onSubmit: async (values) => {
|
||||||
setMovementFormErrorMessage('');
|
setMovementFormErrorMessage('');
|
||||||
if (values.source_warehouse_id === values.destination_warehouse_id) {
|
|
||||||
const sourceWarehouseName =
|
|
||||||
(values.source_warehouse as WarehouseOptionType)?.label ||
|
|
||||||
'Gudang asal';
|
|
||||||
const destinationWarehouseName =
|
|
||||||
(values.destination_warehouse as WarehouseOptionType)?.label ||
|
|
||||||
'gudang tujuan';
|
|
||||||
|
|
||||||
setMovementFormErrorMessage(
|
|
||||||
`Tidak bisa submit form. ${sourceWarehouseName} tidak boleh sama dengan ${destinationWarehouseName}.`
|
|
||||||
);
|
|
||||||
toast.error(
|
|
||||||
`Tidak bisa submit form. Gudang asal dan tujuan tidak boleh sama!`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const documents: File[] = [];
|
const documents: File[] = [];
|
||||||
const deliveriesPayload = values.deliveries.map((d) => {
|
const deliveriesPayload = values.deliveries.map((d) => {
|
||||||
let documentIndex = 0;
|
let documentIndex = 0;
|
||||||
@@ -326,10 +305,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTransferDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
formik.setFieldValue('transfer_date', e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== EVENT HANDLERS =====
|
// ===== EVENT HANDLERS =====
|
||||||
// Product Handlers
|
// Product Handlers
|
||||||
const addProduct = () => {
|
const addProduct = () => {
|
||||||
@@ -770,31 +745,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
}
|
}
|
||||||
}, [formik.values.source_warehouse_id]);
|
}, [formik.values.source_warehouse_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
formik.values.source_warehouse_id &&
|
|
||||||
formik.values.destination_warehouse_id &&
|
|
||||||
formik.values.source_warehouse_id ===
|
|
||||||
formik.values.destination_warehouse_id
|
|
||||||
) {
|
|
||||||
formik.setFieldError(
|
|
||||||
'destination_warehouse_id',
|
|
||||||
'Gudang tujuan tidak boleh sama dengan gudang asal!'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (
|
|
||||||
formik.errors.destination_warehouse_id ===
|
|
||||||
'Gudang tujuan tidak boleh sama dengan gudang asal!'
|
|
||||||
) {
|
|
||||||
formik.setFieldError('destination_warehouse_id', undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
formik.values.source_warehouse_id,
|
|
||||||
formik.values.destination_warehouse_id,
|
|
||||||
formik.errors.destination_warehouse_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className='w-full'>
|
<section className='w-full'>
|
||||||
@@ -842,12 +792,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
errorMessage={formik.errors.transfer_reason}
|
errorMessage={formik.errors.transfer_reason}
|
||||||
readOnly={type === 'detail'}
|
readOnly={type === 'detail'}
|
||||||
/>
|
/>
|
||||||
<DateInput
|
<TextInput
|
||||||
required
|
required
|
||||||
label='Tanggal Transfer'
|
label='Tanggal Transfer'
|
||||||
|
type='date'
|
||||||
name='transfer_date'
|
name='transfer_date'
|
||||||
value={formik.values.transfer_date}
|
value={formik.values.transfer_date}
|
||||||
onChange={handleTransferDateChange}
|
onChange={formik.handleChange}
|
||||||
onBlur={formik.handleBlur}
|
onBlur={formik.handleBlur}
|
||||||
isError={
|
isError={
|
||||||
formik.touched.transfer_date &&
|
formik.touched.transfer_date &&
|
||||||
@@ -874,41 +825,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
placeholder='Pilih gudang asal...'
|
placeholder='Pilih gudang asal...'
|
||||||
value={formik.values.source_warehouse}
|
value={formik.values.source_warehouse}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
const newSourceWarehouseId = (val as WarehouseOptionType)
|
|
||||||
?.value;
|
|
||||||
|
|
||||||
if (newSourceWarehouseId) {
|
|
||||||
if (
|
|
||||||
newSourceWarehouseId ===
|
|
||||||
formik.values.destination_warehouse_id
|
|
||||||
) {
|
|
||||||
const destinationWarehouseName =
|
|
||||||
(
|
|
||||||
formik.values
|
|
||||||
.destination_warehouse as WarehouseOptionType
|
|
||||||
)?.label || 'gudang tujuan';
|
|
||||||
|
|
||||||
toast.error(
|
|
||||||
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formik.setFieldTouched('source_warehouse', true);
|
formik.setFieldTouched('source_warehouse', true);
|
||||||
formik.setFieldValue('source_warehouse', val);
|
formik.setFieldValue('source_warehouse', val);
|
||||||
formik.setFieldTouched('source_warehouse_id', true);
|
formik.setFieldTouched('source_warehouse_id', true);
|
||||||
formik.setFieldValue(
|
formik.setFieldValue(
|
||||||
'source_warehouse_id',
|
'source_warehouse_id',
|
||||||
newSourceWarehouseId
|
(val as WarehouseOptionType)?.value
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
|
||||||
formik.errors.destination_warehouse_id ===
|
|
||||||
'Gudang tujuan tidak boleh sama dengan gudang asal!'
|
|
||||||
) {
|
|
||||||
formik.setFieldError('destination_warehouse_id', undefined);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
options={warehouseOptions}
|
options={warehouseOptions}
|
||||||
onInputChange={setWarehouseSelectInputValue}
|
onInputChange={setWarehouseSelectInputValue}
|
||||||
@@ -973,39 +896,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
placeholder='Pilih gudang tujuan...'
|
placeholder='Pilih gudang tujuan...'
|
||||||
value={formik.values.destination_warehouse}
|
value={formik.values.destination_warehouse}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
const newDestinationWarehouseId = (val as WarehouseOptionType)
|
|
||||||
?.value;
|
|
||||||
|
|
||||||
if (newDestinationWarehouseId) {
|
|
||||||
if (
|
|
||||||
newDestinationWarehouseId ===
|
|
||||||
formik.values.source_warehouse_id
|
|
||||||
) {
|
|
||||||
const sourceWarehouseName =
|
|
||||||
(formik.values.source_warehouse as WarehouseOptionType)
|
|
||||||
?.label || 'gudang asal';
|
|
||||||
|
|
||||||
toast.error(
|
|
||||||
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formik.setFieldTouched('destination_warehouse', true);
|
formik.setFieldTouched('destination_warehouse', true);
|
||||||
formik.setFieldValue('destination_warehouse', val);
|
formik.setFieldValue('destination_warehouse', val);
|
||||||
formik.setFieldTouched('destination_warehouse_id', true);
|
formik.setFieldTouched('destination_warehouse_id', true);
|
||||||
formik.setFieldValue(
|
formik.setFieldValue(
|
||||||
'destination_warehouse_id',
|
'destination_warehouse_id',
|
||||||
newDestinationWarehouseId
|
(val as WarehouseOptionType)?.value
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
|
||||||
formik.errors.destination_warehouse_id ===
|
|
||||||
'Gudang tujuan tidak boleh sama dengan gudang asal!'
|
|
||||||
) {
|
|
||||||
formik.setFieldError('destination_warehouse_id', undefined);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
options={warehouseOptions}
|
options={warehouseOptions}
|
||||||
onInputChange={setWarehouseSelectInputValue}
|
onInputChange={setWarehouseSelectInputValue}
|
||||||
@@ -1725,11 +1622,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
|||||||
hasInvalidQty ||
|
hasInvalidQty ||
|
||||||
hasExceededStock ||
|
hasExceededStock ||
|
||||||
!formik.isValid ||
|
!formik.isValid ||
|
||||||
formik.isSubmitting ||
|
formik.isSubmitting
|
||||||
(formik.values.source_warehouse_id ===
|
|
||||||
formik.values.destination_warehouse_id &&
|
|
||||||
formik.values.source_warehouse_id !== 0 &&
|
|
||||||
formik.values.destination_warehouse_id !== 0)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
|
|||||||
+1
-3
@@ -10,9 +10,7 @@ export const ProductCategoryFormSchema: Yup.ObjectSchema<ProductCategoryFormSche
|
|||||||
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()
|
name: Yup.string().required('Nama wajib diisi!'),
|
||||||
.required('Nama wajib diisi!')
|
|
||||||
.max(50, 'Nama kategori produk melebihi 50 karakter!'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
|
export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import ApprovalSteps, {
|
|||||||
} 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';
|
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||||
import NumberInput from '@/components/input/NumberInput';
|
|
||||||
|
|
||||||
interface ProjectFlockFormProps {
|
interface ProjectFlockFormProps {
|
||||||
formType?: 'add' | 'edit' | 'detail';
|
formType?: 'add' | 'edit' | 'detail';
|
||||||
@@ -549,18 +548,10 @@ const ProjectFlockForm = ({
|
|||||||
setIsApproveLoading(false);
|
setIsApproveLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedPeriod = isResponseSuccess(periodFlocks)
|
|
||||||
? periodFlocks.data.find((kandang) =>
|
|
||||||
formik.values.kandang_ids?.includes(kandang.id)
|
|
||||||
)?.period
|
|
||||||
: undefined;
|
|
||||||
const inputPeriod =
|
|
||||||
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className='w-full'>
|
<section className='w-full'>
|
||||||
<header className='flex flex-col gap-4 mb-6'>
|
<header className='flex flex-col gap-4'>
|
||||||
<Button
|
<Button
|
||||||
href='/production/project-flock'
|
href='/production/project-flock'
|
||||||
variant='link'
|
variant='link'
|
||||||
@@ -572,7 +563,6 @@ const ProjectFlockForm = ({
|
|||||||
|
|
||||||
<h1 className='text-2xl font-bold text-center'>
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
{formType === 'add' && 'Tambah Project Flock'}
|
{formType === 'add' && 'Tambah Project Flock'}
|
||||||
{formType === 'edit' && 'Edit Project Flock'}
|
|
||||||
{formType === 'detail' && 'Detail Project Flock'}
|
{formType === 'detail' && 'Detail Project Flock'}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -750,14 +740,6 @@ const ProjectFlockForm = ({
|
|||||||
isClearable
|
isClearable
|
||||||
isDisabled={formType === 'detail'}
|
isDisabled={formType === 'detail'}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
|
||||||
name='period'
|
|
||||||
label='Periode'
|
|
||||||
disabled
|
|
||||||
readOnly
|
|
||||||
placeholder='Period'
|
|
||||||
value={selectedLocation ? inputPeriod : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -799,7 +781,7 @@ const ProjectFlockForm = ({
|
|||||||
setRowSelection={setRowSelection}
|
setRowSelection={setRowSelection}
|
||||||
selectedIds={formik.values.kandang_ids}
|
selectedIds={formik.values.kandang_ids}
|
||||||
formType={formType}
|
formType={formType}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues?.kandangs ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ 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 {
|
import { ProjectFlockPeriods } from '@/types/api/production/project-flock';
|
||||||
ProjectFlock,
|
|
||||||
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';
|
||||||
|
|
||||||
@@ -26,11 +23,11 @@ const ProjectFlockKandangTable = ({
|
|||||||
rowSelection: Record<string, boolean>;
|
rowSelection: Record<string, boolean>;
|
||||||
setRowSelection: OnChangeFn<Record<string, boolean>>;
|
setRowSelection: OnChangeFn<Record<string, boolean>>;
|
||||||
selectedIds: (number | undefined)[];
|
selectedIds: (number | undefined)[];
|
||||||
initialValues?: ProjectFlock;
|
initialValues?: Kandang[];
|
||||||
formType: 'add' | 'edit' | 'detail';
|
formType: 'add' | 'edit' | 'detail';
|
||||||
}) => {
|
}) => {
|
||||||
const initialKandangIdSet = useMemo(() => {
|
const initialKandangIdSet = useMemo(() => {
|
||||||
return initialValues?.kandangs.map((k) => k.id) ?? [];
|
return initialValues?.map((k) => k.id) ?? [];
|
||||||
}, [initialValues]);
|
}, [initialValues]);
|
||||||
const isRowEnabled = (row: Row<Kandang>) => {
|
const isRowEnabled = (row: Row<Kandang>) => {
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
@@ -150,18 +147,7 @@ const ProjectFlockKandangTable = ({
|
|||||||
listPeriods.length > 0
|
listPeriods.length > 0
|
||||||
? listPeriods.find((p) => p.id == props.row.original.id)
|
? listPeriods.find((p) => p.id == props.row.original.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
const calcPeriod = period?.period == 0 ? 1 : period?.period;
|
return period?.period ?? '-';
|
||||||
const selected = props.row.getIsSelected();
|
|
||||||
const initPeriod = initialValues?.period;
|
|
||||||
return formType == 'detail'
|
|
||||||
? selected
|
|
||||||
? initPeriod
|
|
||||||
: '-'
|
|
||||||
: formType == 'add'
|
|
||||||
? (calcPeriod ?? '-')
|
|
||||||
: selected
|
|
||||||
? (initPeriod ?? '-')
|
|
||||||
: (calcPeriod ?? '-');
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,338 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ChangeEventHandler, useCallback, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import Table from '@/components/Table';
|
|
||||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { useModal } from '@/components/Modal';
|
|
||||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
|
||||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
|
||||||
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
|
||||||
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
|
||||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
|
||||||
|
|
||||||
import { cn, formatDate, formatCurrency } from '@/lib/helper';
|
|
||||||
import { isResponseSuccess } from '@/lib/api-helper';
|
|
||||||
|
|
||||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
|
||||||
import { ROWS_OPTIONS } from '@/config/constant';
|
|
||||||
import { Purchase } from '@/types/api/purchase/purchase';
|
|
||||||
import { PurchaseApi } from '@/services/api/purchase';
|
|
||||||
|
|
||||||
// ===== INTERFACES =====
|
|
||||||
interface RowOptionsMenuProps {
|
|
||||||
type: 'dropdown' | 'collapse';
|
|
||||||
props: CellContext<Purchase, unknown>;
|
|
||||||
deleteClickHandler: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RowOptionsMenu = ({
|
|
||||||
type = 'dropdown',
|
|
||||||
props,
|
|
||||||
deleteClickHandler,
|
|
||||||
}: RowOptionsMenuProps) => {
|
|
||||||
return (
|
|
||||||
<RowOptionsMenuWrapper type={type}>
|
|
||||||
<Button
|
|
||||||
href={`/purchase/detail/?purchaseId=${props.row.original.id}`}
|
|
||||||
variant='ghost'
|
|
||||||
color='primary'
|
|
||||||
className='justify-start text-sm'
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
|
||||||
Detail
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/*<Button*/}
|
|
||||||
{/* href={`/purchase/detail/edit/?purchaseId=${props.row.original.id}`}*/}
|
|
||||||
{/* variant='ghost'*/}
|
|
||||||
{/* color='warning'*/}
|
|
||||||
{/* className='justify-start text-sm'*/}
|
|
||||||
{/*>*/}
|
|
||||||
{/* <Icon icon='material-symbols:edit-outline' width={16} height={16} />*/}
|
|
||||||
{/* Edit*/}
|
|
||||||
{/*</Button>*/}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={deleteClickHandler}
|
|
||||||
variant='ghost'
|
|
||||||
color='error'
|
|
||||||
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</RowOptionsMenuWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PurchaseTable = () => {
|
|
||||||
// ===== STATE MANAGEMENT =====
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
|
||||||
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
|
|
||||||
// ===== TABLE FILTER STATE =====
|
|
||||||
const {
|
|
||||||
state: tableFilterState,
|
|
||||||
updateFilter,
|
|
||||||
setPage,
|
|
||||||
setPageSize,
|
|
||||||
toQueryString: getTableFilterQueryString,
|
|
||||||
} = useTableFilter({
|
|
||||||
initial: {
|
|
||||||
search: '',
|
|
||||||
},
|
|
||||||
paramMap: {
|
|
||||||
page: 'page',
|
|
||||||
pageSize: 'limit',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== MODAL HOOKS =====
|
|
||||||
const deleteModal = useModal();
|
|
||||||
|
|
||||||
// ===== API DATA FETCHING =====
|
|
||||||
const {
|
|
||||||
data: purchaseRequests,
|
|
||||||
isLoading,
|
|
||||||
mutate: refreshPurchaseRequests,
|
|
||||||
} = useSWR(
|
|
||||||
`${PurchaseApi.basePath}${getTableFilterQueryString()}`,
|
|
||||||
PurchaseApi.getAllFetcher
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== TABLE COLUMNS DEFINITION =====
|
|
||||||
const purchaseColumns: ColumnDef<Purchase>[] = [
|
|
||||||
{
|
|
||||||
header: 'No. PR/PO',
|
|
||||||
cell: (props) => {
|
|
||||||
const { pr_number, po_number } = props.row.original;
|
|
||||||
return po_number ? po_number : pr_number;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'supplier',
|
|
||||||
header: 'Vendor',
|
|
||||||
cell: (props) => props.row.original.supplier.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'po_date',
|
|
||||||
header: 'Tgl. PO',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.po_date
|
|
||||||
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
|
|
||||||
: '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'due_date',
|
|
||||||
header: 'Jatuh Tempo',
|
|
||||||
cell: (props) =>
|
|
||||||
props.row.original.due_date
|
|
||||||
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
|
|
||||||
: '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Aging',
|
|
||||||
cell: (props) => {
|
|
||||||
const purchase = props.row.original;
|
|
||||||
if (!purchase.po_date) return '-';
|
|
||||||
const poDate = new Date(purchase.po_date);
|
|
||||||
const today = new Date();
|
|
||||||
const diffTime = Math.abs(today.getTime() - poDate.getTime());
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
return `${diffDays} hari`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'grand_total',
|
|
||||||
header: 'Total (Rp.)',
|
|
||||||
cell: (props) => formatCurrency(props.row.original.grand_total),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Aksi',
|
|
||||||
cell: (props) => {
|
|
||||||
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
|
||||||
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
|
||||||
const currentRowRelativeIndex =
|
|
||||||
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
|
||||||
|
|
||||||
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
|
||||||
|
|
||||||
const deleteClickHandler = () => {
|
|
||||||
setSelectedPurchase(props.row.original);
|
|
||||||
deleteModal.openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{currentPageSize > 2 && (
|
|
||||||
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
|
||||||
<RowOptionsMenu
|
|
||||||
type='dropdown'
|
|
||||||
props={props}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
|
||||||
/>
|
|
||||||
</RowDropdownOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPageSize <= 2 && (
|
|
||||||
<RowCollapseOptions>
|
|
||||||
<RowOptionsMenu
|
|
||||||
type='collapse'
|
|
||||||
props={props}
|
|
||||||
deleteClickHandler={deleteClickHandler}
|
|
||||||
/>
|
|
||||||
</RowCollapseOptions>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ===== EVENT HANDLERS =====
|
|
||||||
const confirmationModalDeleteClickHandler = useCallback(async () => {
|
|
||||||
setIsDeleteLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await PurchaseApi.delete(selectedPurchase?.id as number);
|
|
||||||
refreshPurchaseRequests();
|
|
||||||
deleteModal.closeModal();
|
|
||||||
toast.success('Berhasil menghapus data permintaan pembelian!');
|
|
||||||
} catch {
|
|
||||||
toast.error('Gagal menghapus data permintaan pembelian!');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
|
|
||||||
|
|
||||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
|
|
||||||
(e) => {
|
|
||||||
updateFilter('search', e.target.value);
|
|
||||||
},
|
|
||||||
[updateFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageSizeChangeHandler = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const newVal = val as OptionType;
|
|
||||||
setPageSize(newVal.value as number);
|
|
||||||
},
|
|
||||||
[setPageSize]
|
|
||||||
);
|
|
||||||
|
|
||||||
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 xl:flex-row justify-between items-end xl:items-center gap-2'>
|
|
||||||
<div className='w-full flex flex-row gap-2'>
|
|
||||||
<Button
|
|
||||||
href='/purchase/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 Pembelian'
|
|
||||||
value={tableFilterState.search}
|
|
||||||
onChange={searchChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'sm:max-w-3xs',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-wrap justify-end gap-4'>
|
|
||||||
<SelectInput
|
|
||||||
label='Baris'
|
|
||||||
options={ROWS_OPTIONS}
|
|
||||||
value={{
|
|
||||||
label: String(tableFilterState.pageSize),
|
|
||||||
value: tableFilterState.pageSize,
|
|
||||||
}}
|
|
||||||
onChange={pageSizeChangeHandler}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full sm:w-24',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table<Purchase>
|
|
||||||
data={
|
|
||||||
isResponseSuccess(purchaseRequests) ? purchaseRequests?.data : []
|
|
||||||
}
|
|
||||||
columns={purchaseColumns}
|
|
||||||
pageSize={tableFilterState.pageSize}
|
|
||||||
page={
|
|
||||||
isResponseSuccess(purchaseRequests)
|
|
||||||
? purchaseRequests?.meta?.page
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
totalItems={
|
|
||||||
isResponseSuccess(purchaseRequests)
|
|
||||||
? purchaseRequests?.meta?.total_results
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onPageChange={setPage}
|
|
||||||
isLoading={isLoading}
|
|
||||||
sorting={sorting}
|
|
||||||
setSorting={setSorting}
|
|
||||||
className={{
|
|
||||||
containerClassName: cn({
|
|
||||||
'mb-20':
|
|
||||||
isResponseSuccess(purchaseRequests) &&
|
|
||||||
purchaseRequests?.data?.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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ===== MODAL COMPONENTS ===== */}
|
|
||||||
<ConfirmationModal
|
|
||||||
ref={deleteModal.ref}
|
|
||||||
type='error'
|
|
||||||
text={`Apakah anda yakin ingin menghapus data permintaan pembelian ini?`}
|
|
||||||
secondaryButton={{
|
|
||||||
text: 'Tidak',
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: 'Ya',
|
|
||||||
color: 'error',
|
|
||||||
isLoading: isDeleteLoading,
|
|
||||||
onClick: confirmationModalDeleteClickHandler,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PurchaseTable;
|
|
||||||
@@ -1,758 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useFormik } from 'formik';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import TextInput from '@/components/input/TextInput';
|
|
||||||
import NumberInput from '@/components/input/NumberInput';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import {
|
|
||||||
PurchaseRequestAcceptApprovalFormDefaultValues,
|
|
||||||
PurchaseRequestAcceptApprovalFormInitialValues,
|
|
||||||
PurchaseRequestAcceptApprovalFormSchema,
|
|
||||||
} from './PurchaseOrderForm.schema';
|
|
||||||
import { isResponseError } from '@/lib/api-helper';
|
|
||||||
import { PurchaseApi } from '@/services/api/purchase';
|
|
||||||
import {
|
|
||||||
CreateAcceptApprovalRequestPayload,
|
|
||||||
Purchase,
|
|
||||||
} from '@/types/api/purchase/purchase';
|
|
||||||
import DateInput from '@/components/input/DateInput';
|
|
||||||
import { formatNumber } from '@/lib/helper';
|
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
|
||||||
import { SupplierApi } from '@/services/api/master-data';
|
|
||||||
|
|
||||||
interface PurchaseOrderAcceptApprovalFormProps {
|
|
||||||
type?: 'add' | 'edit';
|
|
||||||
initialValues?: Purchase;
|
|
||||||
onCancel?: () => void;
|
|
||||||
refreshApprovals?: () => void;
|
|
||||||
onModalClose?: () => void;
|
|
||||||
onRefetchData?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PurchaseOrderAcceptApprovalForm = ({
|
|
||||||
type = 'add',
|
|
||||||
initialValues,
|
|
||||||
onCancel,
|
|
||||||
refreshApprovals,
|
|
||||||
onModalClose,
|
|
||||||
onRefetchData,
|
|
||||||
}: PurchaseOrderAcceptApprovalFormProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
|
|
||||||
useState('');
|
|
||||||
|
|
||||||
// ===== UTILITY FUNCTIONS =====
|
|
||||||
const isRepeaterInputError = (
|
|
||||||
idx: number,
|
|
||||||
field:
|
|
||||||
| 'purchase_item_id'
|
|
||||||
| 'received_date'
|
|
||||||
| 'travel_number'
|
|
||||||
| 'travel_document_path'
|
|
||||||
| 'vehicle_number'
|
|
||||||
| 'expedition_vendor_id'
|
|
||||||
| 'received_qty'
|
|
||||||
| 'transport_per_item'
|
|
||||||
| 'transport_total'
|
|
||||||
): { isError: boolean; errorMessage: string } => {
|
|
||||||
const touchedItem = formik.touched.items?.[idx];
|
|
||||||
const errorItem = formik.errors.items?.[idx] as
|
|
||||||
| Record<string, string>
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!touchedItem) {
|
|
||||||
return { isError: false, errorMessage: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTouched = (touchedItem as Record<string, boolean>)?.[field];
|
|
||||||
const errorMessage = errorItem?.[field] || '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
isError: Boolean(isTouched && errorMessage),
|
|
||||||
errorMessage: isTouched && errorMessage ? errorMessage : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== SUBMISSION HANDLERS =====
|
|
||||||
const createAcceptApprovalHandler = useCallback(
|
|
||||||
async (payload: CreateAcceptApprovalRequestPayload) => {
|
|
||||||
const purchaseRequestId = searchParams.get('purchaseId')
|
|
||||||
? parseInt(searchParams.get('purchaseId')!)
|
|
||||||
: initialValues?.id || 1;
|
|
||||||
|
|
||||||
if (!purchaseRequestId) {
|
|
||||||
setPurchaseOrderFormErrorMessage('Purchase Request ID is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await PurchaseApi.acceptApproval.create(
|
|
||||||
purchaseRequestId,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isResponseError(res)) {
|
|
||||||
setPurchaseOrderFormErrorMessage(res.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(res?.message as string);
|
|
||||||
refreshApprovals?.();
|
|
||||||
onRefetchData?.();
|
|
||||||
formik.resetForm();
|
|
||||||
onCancel?.();
|
|
||||||
onModalClose?.();
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[
|
|
||||||
initialValues?.id,
|
|
||||||
searchParams,
|
|
||||||
refreshApprovals,
|
|
||||||
onModalClose,
|
|
||||||
onRefetchData,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateAcceptApprovalHandler = useCallback(
|
|
||||||
async (purchaseId: number, payload: CreateAcceptApprovalRequestPayload) => {
|
|
||||||
const res = await PurchaseApi.acceptApproval.create(purchaseId, payload);
|
|
||||||
if (isResponseError(res)) {
|
|
||||||
setPurchaseOrderFormErrorMessage(res.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(res?.message as string);
|
|
||||||
refreshApprovals?.();
|
|
||||||
onRefetchData?.();
|
|
||||||
formik.resetForm();
|
|
||||||
onCancel?.();
|
|
||||||
onModalClose?.();
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
[refreshApprovals, onModalClose, onRefetchData]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== SELECT INPUT DATA =====
|
|
||||||
const {
|
|
||||||
setInputValue: setExpeditionsSelectInputValue,
|
|
||||||
options: expeditionVendors,
|
|
||||||
isLoadingOptions: isLoadingExpeditions,
|
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
|
|
||||||
category: 'BOP',
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== FORM CONFIGURATION =====
|
|
||||||
const formikInitialValues = useMemo(() => {
|
|
||||||
return initialValues
|
|
||||||
? PurchaseRequestAcceptApprovalFormDefaultValues(initialValues)
|
|
||||||
: PurchaseRequestAcceptApprovalFormInitialValues;
|
|
||||||
}, [initialValues]);
|
|
||||||
|
|
||||||
const formik = useFormik({
|
|
||||||
initialValues: formikInitialValues,
|
|
||||||
validationSchema: PurchaseRequestAcceptApprovalFormSchema,
|
|
||||||
validateOnChange: true,
|
|
||||||
validateOnBlur: true,
|
|
||||||
onSubmit: async (values) => {
|
|
||||||
const payload: CreateAcceptApprovalRequestPayload = {
|
|
||||||
notes: values.notes || '',
|
|
||||||
items:
|
|
||||||
values.items?.map((formItem) => {
|
|
||||||
return {
|
|
||||||
purchase_item_id: formItem.purchase_item_id || 0,
|
|
||||||
received_date: formItem.received_date || '',
|
|
||||||
travel_number: formItem.travel_number || '',
|
|
||||||
travel_document_path: formItem.travel_document_path || '',
|
|
||||||
vehicle_number: formItem.vehicle_number || '',
|
|
||||||
expedition_vendor_id: formItem.expedition_vendor_id || 0,
|
|
||||||
received_qty:
|
|
||||||
typeof formItem.received_qty === 'string'
|
|
||||||
? parseFloat(formItem.received_qty) || 0
|
|
||||||
: formItem.received_qty || 0,
|
|
||||||
transport_per_item:
|
|
||||||
typeof formItem.transport_per_item === 'string'
|
|
||||||
? parseFloat(formItem.transport_per_item) || 0
|
|
||||||
: formItem.transport_per_item || 0,
|
|
||||||
transport_total:
|
|
||||||
typeof formItem.transport_total === 'string'
|
|
||||||
? parseFloat(formItem.transport_total) || 0
|
|
||||||
: formItem.transport_total || 0,
|
|
||||||
};
|
|
||||||
}) || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'add':
|
|
||||||
await createAcceptApprovalHandler(payload);
|
|
||||||
break;
|
|
||||||
case 'edit':
|
|
||||||
await updateAcceptApprovalHandler(
|
|
||||||
initialValues?.id as number,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== API DATA FETCHING =====
|
|
||||||
const purchaseItems = useMemo(() => {
|
|
||||||
if (initialValues?.items) {
|
|
||||||
return initialValues.items.map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.product.name,
|
|
||||||
id: item.id,
|
|
||||||
quantity: item.sub_qty,
|
|
||||||
product: {
|
|
||||||
name: item.product.name,
|
|
||||||
product_category: item.product.product_category || '',
|
|
||||||
uom: item.product.uom || { name: '' },
|
|
||||||
},
|
|
||||||
warehouse: {
|
|
||||||
name: item.warehouse?.name || '',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}, [initialValues?.items]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (purchaseItems.length > 0 && initialValues?.items) {
|
|
||||||
const updatedItems = initialValues.items.map((item) => {
|
|
||||||
return {
|
|
||||||
purchase_item: null,
|
|
||||||
purchase_item_id: item.id,
|
|
||||||
received_date: item.received_date
|
|
||||||
? new Date(item.received_date).toISOString().split('T')[0]
|
|
||||||
: '',
|
|
||||||
travel_number: item.travel_number || '',
|
|
||||||
travel_document_path: item.travel_document_path || '',
|
|
||||||
vehicle_number: item.vehicle_number || '',
|
|
||||||
expedition_vendor: null,
|
|
||||||
expedition_vendor_id: 0,
|
|
||||||
received_qty: '',
|
|
||||||
transport_per_item: '',
|
|
||||||
transport_total: '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
formik.setFieldValue('items', updatedItems);
|
|
||||||
}
|
|
||||||
}, [purchaseItems, initialValues]);
|
|
||||||
|
|
||||||
// ===== HELPER FUNCTIONS =====
|
|
||||||
const getQuantityExceededError = useCallback(
|
|
||||||
(idx: number, receivedQty: number) => {
|
|
||||||
if (!receivedQty) return null;
|
|
||||||
|
|
||||||
const originalQty = purchaseItems[idx]?.quantity || 0;
|
|
||||||
if (receivedQty > originalQty) {
|
|
||||||
return `Tidak boleh melebihi ${formatNumber(originalQty)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[purchaseItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasQuantityExceededErrors = useMemo(() => {
|
|
||||||
if (!formik.values.items || purchaseItems.length === 0) return false;
|
|
||||||
|
|
||||||
return formik.values.items.some((item, idx) => {
|
|
||||||
if (!item.received_qty) return false;
|
|
||||||
|
|
||||||
const receivedQty =
|
|
||||||
typeof item.received_qty === 'string'
|
|
||||||
? parseFloat(item.received_qty) || 0
|
|
||||||
: item.received_qty;
|
|
||||||
|
|
||||||
const originalQty = purchaseItems[idx]?.quantity || 0;
|
|
||||||
return receivedQty > originalQty;
|
|
||||||
});
|
|
||||||
}, [formik.values.items, purchaseItems]);
|
|
||||||
|
|
||||||
const getExpeditionVendorOptions = useCallback(() => {
|
|
||||||
return expeditionVendors;
|
|
||||||
}, [expeditionVendors]);
|
|
||||||
|
|
||||||
// ===== FIELD CHANGE HANDLERS =====
|
|
||||||
const expeditionVendorChangeHandler = (
|
|
||||||
idx: number,
|
|
||||||
val: OptionType | OptionType[] | null
|
|
||||||
) => {
|
|
||||||
const expeditionVendor = val as OptionType | null;
|
|
||||||
formik.setFieldTouched(`items.${idx}.expedition_vendor`, true);
|
|
||||||
formik.setFieldValue(`items.${idx}.expedition_vendor`, expeditionVendor);
|
|
||||||
formik.setFieldTouched(`items.${idx}.expedition_vendor_id`, true);
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.expedition_vendor_id`,
|
|
||||||
expeditionVendor?.value || 0
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== PURCHASE ITEM OPERATIONS =====
|
|
||||||
const handlePurchaseItemChange = (
|
|
||||||
idx: number,
|
|
||||||
field: 'received_qty' | 'transport_per_item' | 'transport_total',
|
|
||||||
value: string | number
|
|
||||||
) => {
|
|
||||||
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
|
|
||||||
formik.setFieldValue(`items.${idx}.${field}`, numValue);
|
|
||||||
|
|
||||||
if (field === 'received_qty' || field === 'transport_per_item') {
|
|
||||||
const receivedQty =
|
|
||||||
field === 'received_qty'
|
|
||||||
? numValue
|
|
||||||
: parseFloat(formik.values.items?.[idx]?.received_qty as string) || 0;
|
|
||||||
const transportPerItem =
|
|
||||||
field === 'transport_per_item'
|
|
||||||
? numValue
|
|
||||||
: parseFloat(
|
|
||||||
formik.values.items?.[idx]?.transport_per_item as string
|
|
||||||
) || 0;
|
|
||||||
|
|
||||||
if (receivedQty > 0 && transportPerItem >= 0) {
|
|
||||||
const calculatedTransportTotal = receivedQty * transportPerItem;
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.transport_total`,
|
|
||||||
calculatedTransportTotal
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field === 'transport_total') {
|
|
||||||
const receivedQty =
|
|
||||||
parseFloat(formik.values.items?.[idx]?.received_qty as string) || 0;
|
|
||||||
if (receivedQty > 0 && numValue >= 0) {
|
|
||||||
const calculatedTransportPerItem = numValue / receivedQty;
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.transport_per_item`,
|
|
||||||
calculatedTransportPerItem
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={formik.handleSubmit} className='w-full flex flex-col gap-6'>
|
|
||||||
<div className='w-full'>
|
|
||||||
<h2 className='text-lg font-semibold mb-4'>
|
|
||||||
{type === 'add'
|
|
||||||
? 'Konfirmasi Penerimaan Produk'
|
|
||||||
: 'Edit Penerimaan Produk'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className='overflow-x-auto'>
|
|
||||||
<table className='table'>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Produk</th>
|
|
||||||
<th>Gudang</th>
|
|
||||||
<th>Jumlah</th>
|
|
||||||
<th>Satuan</th>
|
|
||||||
<th>
|
|
||||||
Tanggal Diterima
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
No. Surat Jalan
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Dokumen Surat Jalan
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Nomor Kendaraan
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Vendor Ekspedisi
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Jumlah Diterima
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Transport/Item
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Total Transport
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{purchaseItems?.map((purchaseItem, idx) => {
|
|
||||||
const formItem = formik.values.items?.[idx];
|
|
||||||
return (
|
|
||||||
<tr key={`purchase-item-${idx}`}>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
name={`items.${idx}.product_name`}
|
|
||||||
type='text'
|
|
||||||
value={purchaseItem?.product?.name || ''}
|
|
||||||
readOnly={true}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-52 md:min-w-72 lg:min-w-80',
|
|
||||||
}}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type='hidden'
|
|
||||||
name={`items.${idx}.purchase_item_id`}
|
|
||||||
value={purchaseItem?.value || 0}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
name={`items.${idx}.warehouse`}
|
|
||||||
type='text'
|
|
||||||
value={purchaseItem?.warehouse?.name || ''}
|
|
||||||
readOnly={true}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
|
|
||||||
}}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
name={`items.${idx}.quantity`}
|
|
||||||
type='text'
|
|
||||||
value={
|
|
||||||
purchaseItem?.quantity
|
|
||||||
? formatNumber(purchaseItem.quantity)
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
readOnly={true}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-32',
|
|
||||||
}}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
name={`items.${idx}.uom`}
|
|
||||||
type='text'
|
|
||||||
value={purchaseItem?.product?.uom?.name || ''}
|
|
||||||
readOnly={true}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-24',
|
|
||||||
}}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<DateInput
|
|
||||||
required
|
|
||||||
isNestedModal={true}
|
|
||||||
name={`items.${idx}.received_date`}
|
|
||||||
value={formItem?.received_date || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.received_date`,
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'received_date').isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'received_date')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.travel_number`}
|
|
||||||
type='text'
|
|
||||||
value={formItem?.travel_number || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.travel_number`,
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'travel_number').isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'travel_number')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
placeholder='Masukkan no. surat jalan'
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.travel_document_path`}
|
|
||||||
type='text'
|
|
||||||
value={formItem?.travel_document_path || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.travel_document_path`,
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'travel_document_path')
|
|
||||||
.isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'travel_document_path')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
placeholder='Masukkan path dokumen'
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-52 md:min-w-72 lg:min-w-80',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.vehicle_number`}
|
|
||||||
type='text'
|
|
||||||
value={formItem?.vehicle_number || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.vehicle_number`,
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'vehicle_number').isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'vehicle_number')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
placeholder='Masukkan nomor kendaraan'
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<SelectInput
|
|
||||||
required
|
|
||||||
isClearable={true}
|
|
||||||
value={formItem?.expedition_vendor}
|
|
||||||
key={`expedition-vendor-${idx}`}
|
|
||||||
onChange={(val) =>
|
|
||||||
expeditionVendorChangeHandler(idx, val)
|
|
||||||
}
|
|
||||||
options={getExpeditionVendorOptions()}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'expedition_vendor_id')
|
|
||||||
.isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'expedition_vendor_id')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
placeholder='Pilih Vendor...'
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-48 md:min-w-64 lg:min-w-72',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.received_qty`}
|
|
||||||
value={formItem?.received_qty || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
handlePurchaseItemChange(
|
|
||||||
idx,
|
|
||||||
'received_qty',
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
placeholder='Masukkan jumlah diterima'
|
|
||||||
allowNegative={false}
|
|
||||||
decimalScale={0}
|
|
||||||
thousandSeparator=','
|
|
||||||
decimalSeparator='.'
|
|
||||||
bottomLabel={`Total: ${purchaseItems[idx]?.quantity ? formatNumber(purchaseItems[idx].quantity) : 0}`}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'received_qty').isError ||
|
|
||||||
(formItem?.received_qty
|
|
||||||
? getQuantityExceededError(
|
|
||||||
idx,
|
|
||||||
Number(formItem.received_qty)
|
|
||||||
) !== null
|
|
||||||
: false)
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'received_qty')
|
|
||||||
.errorMessage ||
|
|
||||||
(formItem?.received_qty
|
|
||||||
? getQuantityExceededError(
|
|
||||||
idx,
|
|
||||||
Number(formItem.received_qty)
|
|
||||||
) || undefined
|
|
||||||
: undefined)
|
|
||||||
}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.transport_per_item`}
|
|
||||||
value={formItem?.transport_per_item || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
handlePurchaseItemChange(
|
|
||||||
idx,
|
|
||||||
'transport_per_item',
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
placeholder='Masukkan transport/item'
|
|
||||||
allowNegative={false}
|
|
||||||
decimalScale={2}
|
|
||||||
thousandSeparator=','
|
|
||||||
decimalSeparator='.'
|
|
||||||
inputPrefix={'Rp'}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'transport_per_item')
|
|
||||||
.isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'transport_per_item')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.transport_total`}
|
|
||||||
value={formItem?.transport_total || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
handlePurchaseItemChange(
|
|
||||||
idx,
|
|
||||||
'transport_total',
|
|
||||||
e.target.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
placeholder='Masukkan total transport'
|
|
||||||
allowNegative={false}
|
|
||||||
decimalScale={2}
|
|
||||||
thousandSeparator=','
|
|
||||||
decimalSeparator='.'
|
|
||||||
inputPrefix={'Rp'}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'transport_total').isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'transport_total')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
className={{
|
|
||||||
wrapper: 'min-w-40 md:min-w-52 lg:min-w-64',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className={'col-span-2'}>
|
|
||||||
<TextInput
|
|
||||||
label='Notes'
|
|
||||||
name='notes'
|
|
||||||
value={formik.values.notes || ''}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={formik.touched.notes && Boolean(formik.errors.notes)}
|
|
||||||
errorMessage={formik.errors.notes as string}
|
|
||||||
placeholder='Masukkan catatan'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className='flex flex-row justify-between gap-2 flex-wrap mt-5'>
|
|
||||||
<div className='flex flex-row justify-end gap-2 w-full'>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='warning'
|
|
||||||
className='px-4'
|
|
||||||
onClick={() => {
|
|
||||||
formik.resetForm();
|
|
||||||
setPurchaseOrderFormErrorMessage('');
|
|
||||||
onCancel?.();
|
|
||||||
onModalClose?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='submit'
|
|
||||||
color='primary'
|
|
||||||
className='px-4'
|
|
||||||
isLoading={formik.isSubmitting}
|
|
||||||
disabled={
|
|
||||||
!formik.isValid ||
|
|
||||||
formik.isSubmitting ||
|
|
||||||
hasQuantityExceededErrors
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{purchaseOrderFormErrorMessage && (
|
|
||||||
<div role='alert' className='alert alert-error'>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:error-outline'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
<span>{purchaseOrderFormErrorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PurchaseOrderAcceptApprovalForm;
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import * as Yup from 'yup';
|
|
||||||
import { Purchase } from '@/types/api/purchase/purchase';
|
|
||||||
|
|
||||||
type PurchaseRequestStaffApprovalFormSchemaType = {
|
|
||||||
action: 'APPROVED' | 'REJECTED';
|
|
||||||
notes: string | null;
|
|
||||||
items: {
|
|
||||||
purchase_item_id?: number;
|
|
||||||
product_id?: number | null;
|
|
||||||
warehouse_id?: number | null;
|
|
||||||
product?: {
|
|
||||||
value?: number;
|
|
||||||
label?: string;
|
|
||||||
} | null;
|
|
||||||
warehouse?: {
|
|
||||||
value?: number;
|
|
||||||
label?: string;
|
|
||||||
} | null;
|
|
||||||
qty: number;
|
|
||||||
price: number | string;
|
|
||||||
total_price: number | string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type PurchaseRequestManagerApprovalFormSchemaType = {
|
|
||||||
notes: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PurchaseRequestAcceptApprovalFormSchemaType = {
|
|
||||||
notes: string | null;
|
|
||||||
items: {
|
|
||||||
purchase_item?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
purchase_item_id: number;
|
|
||||||
received_date: string;
|
|
||||||
travel_number: string;
|
|
||||||
travel_document_path: string;
|
|
||||||
vehicle_number: string;
|
|
||||||
expedition_vendor?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
expedition_vendor_id: number;
|
|
||||||
received_qty: number | string;
|
|
||||||
transport_per_item: number | string;
|
|
||||||
transport_total: number | string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseStaffApprovalItemSchema = {
|
|
||||||
purchase_item_id?: number;
|
|
||||||
product_id?: number | null;
|
|
||||||
warehouse_id?: number | null;
|
|
||||||
product?: {
|
|
||||||
value?: number;
|
|
||||||
label?: string;
|
|
||||||
} | null;
|
|
||||||
warehouse?: {
|
|
||||||
value?: number;
|
|
||||||
label?: string;
|
|
||||||
} | null;
|
|
||||||
qty: number;
|
|
||||||
price: number | string;
|
|
||||||
total_price: number | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseAcceptApprovalItemSchema = {
|
|
||||||
purchase_item?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
purchase_item_id: number;
|
|
||||||
received_date: string;
|
|
||||||
travel_number: string;
|
|
||||||
travel_document_path: string;
|
|
||||||
vehicle_number: string;
|
|
||||||
expedition_vendor?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
expedition_vendor_id: number;
|
|
||||||
received_qty: number | string;
|
|
||||||
transport_per_item: number | string;
|
|
||||||
transport_total: number | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseDeleteItemsSchema = {
|
|
||||||
item_ids: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const PurchaseStaffApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseStaffApprovalItemSchema> =
|
|
||||||
Yup.object({
|
|
||||||
purchase_item_id: Yup.number()
|
|
||||||
.optional()
|
|
||||||
.min(0, 'Purchase item ID tidak valid!')
|
|
||||||
.typeError('Purchase item ID harus berupa angka!'),
|
|
||||||
product: Yup.object({
|
|
||||||
value: Yup.number(),
|
|
||||||
label: Yup.string(),
|
|
||||||
})
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
product_id: Yup.number()
|
|
||||||
.optional()
|
|
||||||
.nullable()
|
|
||||||
.typeError('Product ID harus berupa angka!'),
|
|
||||||
warehouse: Yup.object({
|
|
||||||
value: Yup.number(),
|
|
||||||
label: Yup.string(),
|
|
||||||
})
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
warehouse_id: Yup.number()
|
|
||||||
.optional()
|
|
||||||
.nullable()
|
|
||||||
.typeError('Warehouse ID harus berupa angka!'),
|
|
||||||
qty: Yup.number()
|
|
||||||
.required('Jumlah wajib diisi!')
|
|
||||||
.min(1, 'Jumlah harus berupa angka lebih dari 0!')
|
|
||||||
.typeError('Jumlah harus berupa angka lebih dari 0!'),
|
|
||||||
price: Yup.mixed<number | string>()
|
|
||||||
.required('Harga wajib diisi!')
|
|
||||||
.test(
|
|
||||||
'is-valid-price',
|
|
||||||
'Harga harus berupa angka lebih dari atau sama dengan 0!',
|
|
||||||
function (value) {
|
|
||||||
if (value === '' || value === null || value === undefined)
|
|
||||||
return false;
|
|
||||||
const numValue =
|
|
||||||
typeof value === 'string' ? parseFloat(value) : value;
|
|
||||||
return !isNaN(numValue) && numValue >= 0;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.typeError('Harga harus berupa angka!'),
|
|
||||||
total_price: Yup.mixed<number | string>()
|
|
||||||
.required('Total harga wajib diisi!')
|
|
||||||
.test(
|
|
||||||
'is-valid-total-price',
|
|
||||||
'Total harga harus berupa angka lebih dari atau sama dengan 0!',
|
|
||||||
function (value) {
|
|
||||||
if (value === '' || value === null || value === undefined)
|
|
||||||
return false;
|
|
||||||
const numValue =
|
|
||||||
typeof value === 'string' ? parseFloat(value) : value;
|
|
||||||
return !isNaN(numValue) && numValue >= 0;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.typeError('Total harga harus berupa angka!'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> =
|
|
||||||
Yup.object({
|
|
||||||
notes: Yup.string().nullable().default(null),
|
|
||||||
});
|
|
||||||
|
|
||||||
const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApprovalItemSchema> =
|
|
||||||
Yup.object({
|
|
||||||
purchase_item: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
})
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
purchase_item_id: Yup.number()
|
|
||||||
.min(1, 'Purchase item is required!')
|
|
||||||
.required('Purchase item is required!')
|
|
||||||
.typeError('Purchase item is required!')
|
|
||||||
.test(
|
|
||||||
'is-valid-purchase-item',
|
|
||||||
'Purchase item must be selected!',
|
|
||||||
function (value) {
|
|
||||||
return Boolean(value && value > 0);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
received_date: Yup.string()
|
|
||||||
.required('Tanggal penerimaan wajib diisi!')
|
|
||||||
.typeError('Tanggal penerimaan wajib diisi!'),
|
|
||||||
travel_number: Yup.string()
|
|
||||||
.required('No. Surat jalan wajib diisi!')
|
|
||||||
.typeError('No. Surat jalan wajib diisi!'),
|
|
||||||
travel_document_path: Yup.string()
|
|
||||||
.required('Dokumen Surat jalan wajib diisi!')
|
|
||||||
.typeError('Dokumen Surat jalan wajib diisi!'),
|
|
||||||
vehicle_number: Yup.string()
|
|
||||||
.required('Nomor kendaraan wajib diisi!')
|
|
||||||
.typeError('Nomor kendaraan wajib diisi!'),
|
|
||||||
expedition_vendor: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).nullable(),
|
|
||||||
expedition_vendor_id: Yup.number()
|
|
||||||
.min(1, 'Vendor ekspedisi wajib diisi!')
|
|
||||||
.required('Vendor ekspedisi wajib diisi!')
|
|
||||||
.test(
|
|
||||||
'is-valid-expedition-vendor',
|
|
||||||
'Vendor ekspedisi harus dipilih!',
|
|
||||||
function (value) {
|
|
||||||
if (!this.parent.expedition_vendor) return true;
|
|
||||||
return Boolean(value && value > 0);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.typeError('Vendor ekspedisi harus dipilih!'),
|
|
||||||
received_qty: Yup.mixed<string | number>()
|
|
||||||
.required('Jumlah diterima wajib diisi!')
|
|
||||||
.test(
|
|
||||||
'is-valid-received-qty',
|
|
||||||
'Harus berupa angka lebih dari 0!',
|
|
||||||
function (value) {
|
|
||||||
if (value === '' || value === null || value === undefined)
|
|
||||||
return false;
|
|
||||||
const numValue =
|
|
||||||
typeof value === 'string' ? parseFloat(value) : value;
|
|
||||||
return !isNaN(numValue) && numValue > 0;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.typeError('Jumlah diterima harus berupa angka!'),
|
|
||||||
transport_per_item: Yup.mixed<string | number>()
|
|
||||||
.required('Biaya transport per item wajib diisi!')
|
|
||||||
.test(
|
|
||||||
'is-valid-transport-per-item',
|
|
||||||
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
|
|
||||||
function (value) {
|
|
||||||
if (value === '' || value === null || value === undefined)
|
|
||||||
return false;
|
|
||||||
const numValue =
|
|
||||||
typeof value === 'string' ? parseFloat(value) : value;
|
|
||||||
return !isNaN(numValue) && numValue >= 0;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.typeError('Biaya transport per item harus berupa angka!'),
|
|
||||||
transport_total: Yup.mixed<string | number>()
|
|
||||||
.required('Total biaya transport wajib diisi!')
|
|
||||||
.test(
|
|
||||||
'is-valid-transport-total',
|
|
||||||
'Total biaya transport harus berupa angka lebih dari atau sama dengan 0!',
|
|
||||||
function (value) {
|
|
||||||
if (value === '' || value === null || value === undefined)
|
|
||||||
return false;
|
|
||||||
const numValue =
|
|
||||||
typeof value === 'string' ? parseFloat(value) : value;
|
|
||||||
return !isNaN(numValue) && numValue >= 0;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.typeError('Total biaya transport harus berupa angka!'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PurchaseRequestStaffApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestStaffApprovalFormSchemaType> =
|
|
||||||
Yup.object({
|
|
||||||
action: Yup.mixed<'APPROVED' | 'REJECTED'>()
|
|
||||||
.oneOf(['APPROVED', 'REJECTED'], 'Action harus APPROVED atau REJECTED')
|
|
||||||
.required('Action wajib diisi!')
|
|
||||||
.default('APPROVED'),
|
|
||||||
notes: Yup.string().nullable().default(null),
|
|
||||||
items: Yup.array()
|
|
||||||
.of(PurchaseStaffApprovalItemObjectSchema)
|
|
||||||
.min(1, 'Minimal harus ada 1 item pembelian!')
|
|
||||||
.required('Item pembelian wajib diisi!')
|
|
||||||
.typeError('Item pembelian wajib diisi!')
|
|
||||||
.test(
|
|
||||||
'items-validation',
|
|
||||||
'Setiap item harus valid: existing items hanya butuh purchase_item_id, new items butuh product_id & warehouse_id',
|
|
||||||
function (items) {
|
|
||||||
if (!items || items.length === 0) return false;
|
|
||||||
|
|
||||||
return items.every((item) => {
|
|
||||||
const isExisting =
|
|
||||||
item.purchase_item_id && item.purchase_item_id > 0;
|
|
||||||
|
|
||||||
const isNew = !item.purchase_item_id || item.purchase_item_id === 0;
|
|
||||||
|
|
||||||
if (isExisting) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
return Boolean(
|
|
||||||
item.product_id &&
|
|
||||||
item.product_id > 0 &&
|
|
||||||
item.warehouse_id &&
|
|
||||||
item.warehouse_id > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PurchaseDeleteItemsSchema: Yup.ObjectSchema<PurchaseDeleteItemsSchema> =
|
|
||||||
Yup.object({
|
|
||||||
item_ids: Yup.array()
|
|
||||||
.of(
|
|
||||||
Yup.number()
|
|
||||||
.min(1, 'Item ID tidak valid!')
|
|
||||||
.required('Item ID tidak valid!')
|
|
||||||
.typeError('Item ID tidak valid!')
|
|
||||||
)
|
|
||||||
.min(1, 'Minimal harus ada 1 item yang dihapus!')
|
|
||||||
.required('Item yang dihapus wajib diisi!')
|
|
||||||
.typeError('Item yang dihapus wajib diisi!'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PurchaseRequestStaffApprovalFormInitialValues: PurchaseRequestStaffApprovalFormSchemaType =
|
|
||||||
{
|
|
||||||
action: 'APPROVED',
|
|
||||||
notes: '',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
product_id: 0,
|
|
||||||
warehouse_id: 0,
|
|
||||||
product: null,
|
|
||||||
warehouse: null,
|
|
||||||
qty: 0,
|
|
||||||
price: '',
|
|
||||||
total_price: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PurchaseRequestStaffApprovalFormDefaultValues = (
|
|
||||||
purchase?: Purchase
|
|
||||||
): PurchaseRequestStaffApprovalFormSchemaType => {
|
|
||||||
return {
|
|
||||||
action: 'APPROVED',
|
|
||||||
notes: purchase?.notes ?? null,
|
|
||||||
items: purchase?.items
|
|
||||||
? purchase.items.map((item) => ({
|
|
||||||
purchase_item_id: item.id,
|
|
||||||
product_id: item.product_id || 0,
|
|
||||||
warehouse_id: item.warehouse?.id || 0,
|
|
||||||
product: {
|
|
||||||
value: item.product_id || 0,
|
|
||||||
label: item.product?.name || '',
|
|
||||||
},
|
|
||||||
warehouse: {
|
|
||||||
value: item.warehouse?.id || 0,
|
|
||||||
label: item.warehouse?.name || '',
|
|
||||||
},
|
|
||||||
qty: item.sub_qty || item.qty || 0,
|
|
||||||
price: item.price,
|
|
||||||
total_price: item.total_price,
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
product_id: 0,
|
|
||||||
warehouse_id: 0,
|
|
||||||
product: null,
|
|
||||||
warehouse: null,
|
|
||||||
qty: 0,
|
|
||||||
price: '',
|
|
||||||
total_price: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseRequestStaffApprovalFormValues = Yup.InferType<
|
|
||||||
typeof PurchaseRequestStaffApprovalFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const PurchaseRequestManagerApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> =
|
|
||||||
PurchaseManagerApprovalObjectSchema;
|
|
||||||
|
|
||||||
export const PurchaseRequestManagerApprovalFormDefaultValues = (
|
|
||||||
purchase?: Purchase
|
|
||||||
): PurchaseRequestManagerApprovalFormSchemaType => {
|
|
||||||
return {
|
|
||||||
notes: purchase?.notes ?? null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseRequestManagerApprovalFormValues = Yup.InferType<
|
|
||||||
typeof PurchaseRequestManagerApprovalFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestAcceptApprovalFormSchemaType> =
|
|
||||||
Yup.object({
|
|
||||||
notes: Yup.string().nullable().default(null),
|
|
||||||
items: Yup.array()
|
|
||||||
.of(PurchaseAcceptApprovalItemObjectSchema)
|
|
||||||
.min(1, 'Minimal harus ada 1 item pembelian!')
|
|
||||||
.required('Item pembelian wajib diisi!')
|
|
||||||
.typeError('Item pembelian wajib diisi!'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
|
|
||||||
{
|
|
||||||
notes: '',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
purchase_item_id: 0,
|
|
||||||
received_date: '',
|
|
||||||
travel_number: '',
|
|
||||||
travel_document_path: '',
|
|
||||||
vehicle_number: '',
|
|
||||||
expedition_vendor_id: 0,
|
|
||||||
received_qty: '',
|
|
||||||
transport_per_item: '',
|
|
||||||
transport_total: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PurchaseRequestAcceptApprovalFormDefaultValues = (
|
|
||||||
purchase?: Purchase
|
|
||||||
): PurchaseRequestAcceptApprovalFormSchemaType => {
|
|
||||||
return {
|
|
||||||
notes: purchase?.notes ?? null,
|
|
||||||
items: purchase?.items
|
|
||||||
? purchase.items.map((item) => ({
|
|
||||||
purchase_item_id: item.id,
|
|
||||||
received_date: '',
|
|
||||||
travel_number: '',
|
|
||||||
travel_document_path: '',
|
|
||||||
vehicle_number: '',
|
|
||||||
expedition_vendor_id: 0,
|
|
||||||
received_qty: '',
|
|
||||||
transport_per_item: '',
|
|
||||||
transport_total: '',
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
purchase_item_id: 0,
|
|
||||||
received_date: '',
|
|
||||||
travel_number: '',
|
|
||||||
travel_document_path: '',
|
|
||||||
vehicle_number: '',
|
|
||||||
expedition_vendor_id: 0,
|
|
||||||
received_qty: '',
|
|
||||||
transport_per_item: '',
|
|
||||||
transport_total: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseRequestAcceptApprovalFormValues = Yup.InferType<
|
|
||||||
typeof PurchaseRequestAcceptApprovalFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const PurchaseDeleteItemsInitialValues: PurchaseDeleteItemsSchema = {
|
|
||||||
item_ids: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseDeleteItemsFormValues = Yup.InferType<
|
|
||||||
typeof PurchaseDeleteItemsSchema
|
|
||||||
>;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,170 +0,0 @@
|
|||||||
import * as Yup from 'yup';
|
|
||||||
import { Purchase } from '@/types/api/purchase/purchase';
|
|
||||||
|
|
||||||
type PurchaseRequestFormSchemaType = {
|
|
||||||
supplier?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
supplier_id: number;
|
|
||||||
credit_term: number;
|
|
||||||
area?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
area_id: number | undefined;
|
|
||||||
location?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
location_id: number | undefined;
|
|
||||||
notes: string | null;
|
|
||||||
items: {
|
|
||||||
warehouse?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
warehouse_id: number;
|
|
||||||
product?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
product_id: number;
|
|
||||||
qty: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseItemSchema = {
|
|
||||||
warehouse?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
warehouse_id: number;
|
|
||||||
product?: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
} | null;
|
|
||||||
product_id: number;
|
|
||||||
qty: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PurchaseItemObjectSchema: Yup.ObjectSchema<PurchaseItemSchema> =
|
|
||||||
Yup.object({
|
|
||||||
warehouse: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).nullable(),
|
|
||||||
warehouse_id: Yup.number()
|
|
||||||
.required('Gudang wajib dipilih!')
|
|
||||||
.min(1, 'Gudang wajib dipilih!')
|
|
||||||
.typeError('Gudang wajib dipilih!'),
|
|
||||||
product: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).nullable(),
|
|
||||||
product_id: Yup.number()
|
|
||||||
.required('Produk wajib dipilih!')
|
|
||||||
.min(1, 'Produk wajib dipilih!')
|
|
||||||
.typeError('Produk wajib dipilih!'),
|
|
||||||
qty: Yup.number()
|
|
||||||
.required('Kuantitas wajib diisi!')
|
|
||||||
.min(1, 'Kuantitas tidak boleh kurang dari 1!')
|
|
||||||
.typeError('Kuantitas wajib diisi!'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PurchaseRequestFormSchema: Yup.ObjectSchema<PurchaseRequestFormSchemaType> =
|
|
||||||
Yup.object({
|
|
||||||
supplier: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).nullable(),
|
|
||||||
credit_term: Yup.number()
|
|
||||||
.required('Jangka waktu kredit wajib diisi!')
|
|
||||||
.min(0, 'Jangka waktu kredit tidak boleh kurang dari 0!')
|
|
||||||
.typeError('Jangka waktu kredit wajib diisi!'),
|
|
||||||
supplier_id: Yup.number()
|
|
||||||
.required('Supplier wajib dipilih!')
|
|
||||||
.min(1, 'Supplier wajib dipilih!')
|
|
||||||
.typeError('Supplier wajib dipilih!'),
|
|
||||||
area: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).nullable(),
|
|
||||||
area_id: Yup.number()
|
|
||||||
.min(0, 'Area tidak boleh kurang dari 0!')
|
|
||||||
.typeError('Area harus berupa angka!'),
|
|
||||||
location: Yup.object({
|
|
||||||
value: Yup.number().min(1).required(),
|
|
||||||
label: Yup.string().required(),
|
|
||||||
}).nullable(),
|
|
||||||
location_id: Yup.number()
|
|
||||||
.min(0, 'Lokasi tidak boleh kurang dari 0!')
|
|
||||||
.typeError('Lokasi harus berupa angka!'),
|
|
||||||
notes: Yup.string().nullable().default(null),
|
|
||||||
items: Yup.array()
|
|
||||||
.of(PurchaseItemObjectSchema)
|
|
||||||
.min(1, 'Minimal harus ada 1 item pembelian!')
|
|
||||||
.required('Item pembelian wajib diisi!')
|
|
||||||
.typeError('Item pembelian wajib diisi!'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UpdatePurchaseRequestFormSchema = PurchaseRequestFormSchema;
|
|
||||||
|
|
||||||
export type PurchaseRequestFormValues = Yup.InferType<
|
|
||||||
typeof PurchaseRequestFormSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const getPurchaseRequestFormInitialValues = (
|
|
||||||
initialValues?: Purchase
|
|
||||||
): PurchaseRequestFormValues => ({
|
|
||||||
supplier: initialValues?.supplier
|
|
||||||
? {
|
|
||||||
value: initialValues.supplier.id,
|
|
||||||
label: initialValues.supplier.name,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
supplier_id: initialValues?.supplier?.id ?? 0,
|
|
||||||
credit_term: initialValues?.credit_term ?? 0,
|
|
||||||
area: initialValues?.area
|
|
||||||
? {
|
|
||||||
value: initialValues.area.id,
|
|
||||||
label: initialValues.area.name,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
area_id: initialValues?.area?.id ?? undefined,
|
|
||||||
location: initialValues?.location
|
|
||||||
? {
|
|
||||||
value: initialValues.location.id,
|
|
||||||
label: initialValues.location.name,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
location_id: initialValues?.location?.id ?? undefined,
|
|
||||||
notes: initialValues?.notes ?? null,
|
|
||||||
items: initialValues?.items?.length
|
|
||||||
? initialValues.items.map((item) => ({
|
|
||||||
warehouse: item.warehouse
|
|
||||||
? {
|
|
||||||
value: item.warehouse.id,
|
|
||||||
label: item.warehouse.name,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
warehouse_id: item.warehouse?.id ?? 0,
|
|
||||||
product: item.product
|
|
||||||
? {
|
|
||||||
value: item.product.id,
|
|
||||||
label: item.product.name,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
product_id: item.product?.id ?? 0,
|
|
||||||
qty: item.qty ?? 0,
|
|
||||||
}))
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
warehouse: null,
|
|
||||||
warehouse_id: 0,
|
|
||||||
product: null,
|
|
||||||
product_id: 0,
|
|
||||||
qty: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,973 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useFormik } from 'formik';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import TextInput from '@/components/input/TextInput';
|
|
||||||
import NumberInput from '@/components/input/NumberInput';
|
|
||||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
|
||||||
import SelectInput, {
|
|
||||||
OptionType,
|
|
||||||
useSelect,
|
|
||||||
} from '@/components/input/SelectInput';
|
|
||||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
|
||||||
import { useModal } from '@/components/Modal';
|
|
||||||
|
|
||||||
import {
|
|
||||||
PurchaseRequestFormSchema,
|
|
||||||
PurchaseRequestFormValues,
|
|
||||||
getPurchaseRequestFormInitialValues,
|
|
||||||
UpdatePurchaseRequestFormSchema,
|
|
||||||
} from './PurchaseRequestForm.schema';
|
|
||||||
import {
|
|
||||||
SupplierApi,
|
|
||||||
AreaApi,
|
|
||||||
LocationApi,
|
|
||||||
WarehouseApi,
|
|
||||||
ProductApi,
|
|
||||||
} from '@/services/api/master-data';
|
|
||||||
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
|
|
||||||
import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
|
|
||||||
import { formatNumber } from '@/lib/helper';
|
|
||||||
import { PurchaseApi } from '@/services/api/purchase';
|
|
||||||
|
|
||||||
import Card from '@/components/Card';
|
|
||||||
import {
|
|
||||||
CreatePurchaseRequestPayload,
|
|
||||||
Purchase,
|
|
||||||
} from '@/types/api/purchase/purchase';
|
|
||||||
|
|
||||||
interface PurchaseRequestFormProps {
|
|
||||||
type?: 'add' | 'edit' | 'detail';
|
|
||||||
initialValues?: Purchase;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PurchaseRequestForm = ({
|
|
||||||
type = 'add',
|
|
||||||
initialValues,
|
|
||||||
}: PurchaseRequestFormProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const deleteModal = useModal();
|
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
|
||||||
|
|
||||||
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
|
|
||||||
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [purchaseRequestFormErrorMessage, setPurchaseRequestFormErrorMessage] =
|
|
||||||
useState('');
|
|
||||||
|
|
||||||
// ===== TYPE DEFINITIONS =====
|
|
||||||
interface ProductOptionType {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== UTILITY FUNCTIONS =====
|
|
||||||
const isRepeaterInputError = (
|
|
||||||
idx: number,
|
|
||||||
field: 'warehouse_id' | 'product_id' | 'qty'
|
|
||||||
): { isError: boolean; errorMessage: string } => {
|
|
||||||
if (!formik.touched.items || !Array.isArray(formik.touched.items)) {
|
|
||||||
return {
|
|
||||||
isError: false,
|
|
||||||
errorMessage: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchedField = (
|
|
||||||
formik.touched.items[idx] as Partial<{
|
|
||||||
warehouse_id: boolean;
|
|
||||||
product_id: boolean;
|
|
||||||
qty: boolean;
|
|
||||||
}>
|
|
||||||
)?.[field];
|
|
||||||
const errorItem = formik.errors.items?.[idx] as
|
|
||||||
| Record<string, string>
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
isError: Boolean(touchedField && Boolean(errorItem?.[field])),
|
|
||||||
errorMessage: touchedField && errorItem?.[field] ? errorItem[field] : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== SUBMISSION HANDLERS =====
|
|
||||||
const createPurchaseRequestHandler = useCallback(
|
|
||||||
async (payload: CreatePurchaseRequestPayload) => {
|
|
||||||
const res = await PurchaseApi.create(payload);
|
|
||||||
if (isResponseError(res)) {
|
|
||||||
setPurchaseRequestFormErrorMessage(res.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(res?.message as string);
|
|
||||||
router.push('/purchase');
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePurchaseRequestHandler = useCallback(
|
|
||||||
async (
|
|
||||||
purchaseRequestId: number,
|
|
||||||
payload: CreatePurchaseRequestPayload
|
|
||||||
) => {
|
|
||||||
const res = await PurchaseApi.update(purchaseRequestId, payload);
|
|
||||||
if (isResponseError(res)) {
|
|
||||||
setPurchaseRequestFormErrorMessage(res.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(res?.message as string);
|
|
||||||
router.refresh();
|
|
||||||
router.push('/purchase');
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deletePurchaseRequestClickHandler = useCallback(() => {
|
|
||||||
deleteModal.openModal();
|
|
||||||
}, [deleteModal]);
|
|
||||||
|
|
||||||
const confirmationModalDeleteClickHandler = useCallback(async () => {
|
|
||||||
if (!initialValues?.id) return;
|
|
||||||
|
|
||||||
setIsDeleteLoading(true);
|
|
||||||
await PurchaseApi.delete(initialValues.id);
|
|
||||||
deleteModal.closeModal();
|
|
||||||
toast.success('Successfully delete Purchase Request!');
|
|
||||||
setIsDeleteLoading(false);
|
|
||||||
router.push('/purchase');
|
|
||||||
}, [deleteModal, initialValues?.id, router]);
|
|
||||||
|
|
||||||
// ===== SELECT INPUT DATA =====
|
|
||||||
const {
|
|
||||||
setInputValue: setSupplierSelectInputValue,
|
|
||||||
options: supplierOptions,
|
|
||||||
isLoadingOptions: isLoadingSuppliers,
|
|
||||||
rawData: supplierRawData,
|
|
||||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
|
|
||||||
category: 'SAPRONAK',
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
setInputValue: setAreaSelectInputValue,
|
|
||||||
options: areaOptions,
|
|
||||||
isLoadingOptions: isLoadingAreas,
|
|
||||||
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
|
|
||||||
|
|
||||||
const {
|
|
||||||
inputValue: warehouseSelectInputValue,
|
|
||||||
setInputValue: setWarehouseSelectInputValue,
|
|
||||||
isLoadingOptions: isLoadingWarehouses,
|
|
||||||
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
|
|
||||||
|
|
||||||
// ===== FORM CONFIGURATION =====
|
|
||||||
const formikInitialValues = useMemo<PurchaseRequestFormValues>(
|
|
||||||
() => getPurchaseRequestFormInitialValues(initialValues),
|
|
||||||
[initialValues]
|
|
||||||
);
|
|
||||||
|
|
||||||
const formik = useFormik<PurchaseRequestFormValues>({
|
|
||||||
initialValues: formikInitialValues,
|
|
||||||
validationSchema:
|
|
||||||
type === 'edit'
|
|
||||||
? UpdatePurchaseRequestFormSchema
|
|
||||||
: PurchaseRequestFormSchema,
|
|
||||||
validateOnChange: true,
|
|
||||||
validateOnBlur: true,
|
|
||||||
validateOnMount: false,
|
|
||||||
enableReinitialize: true,
|
|
||||||
onSubmit: async (values) => {
|
|
||||||
const payload: CreatePurchaseRequestPayload = {
|
|
||||||
supplier_id:
|
|
||||||
typeof values.supplier_id === 'string'
|
|
||||||
? parseInt(values.supplier_id) || 0
|
|
||||||
: values.supplier_id || 0,
|
|
||||||
credit_term:
|
|
||||||
typeof values.credit_term === 'string'
|
|
||||||
? parseInt(values.credit_term) || 0
|
|
||||||
: values.credit_term || 0,
|
|
||||||
notes: values.notes || '',
|
|
||||||
items: (values.items || []).map((item) => ({
|
|
||||||
warehouse_id: Number(item.warehouse_id) || 0,
|
|
||||||
product_id: Number(item.product_id) || 0,
|
|
||||||
qty: Number(item.qty) || 0,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'add':
|
|
||||||
await createPurchaseRequestHandler(payload);
|
|
||||||
break;
|
|
||||||
case 'edit':
|
|
||||||
await updatePurchaseRequestHandler(
|
|
||||||
initialValues?.id as number,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== API DATA FETCHING =====
|
|
||||||
const { data: supplierData, isLoading: isLoadingProducts } = useSWR(
|
|
||||||
formik.values.supplier_id && Number(formik.values.supplier_id) > 0
|
|
||||||
? formik.values.supplier_id?.toString()
|
|
||||||
: null,
|
|
||||||
(id: string) => SupplierApi.getSingle(Number(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const supplierProductOptions = useMemo(() => {
|
|
||||||
if (!supplierData || !isResponseSuccess(supplierData)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const supplier = supplierData.data as SupplierProducts;
|
|
||||||
const products = supplier.products || [];
|
|
||||||
return products.map((product) => ({
|
|
||||||
value: product.id,
|
|
||||||
label: product.name,
|
|
||||||
}));
|
|
||||||
}, [supplierData]);
|
|
||||||
|
|
||||||
const supplierProductData = useMemo(() => {
|
|
||||||
if (!supplierData || !isResponseSuccess(supplierData)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const supplier = supplierData.data as SupplierProducts;
|
|
||||||
const products = supplier.products || [];
|
|
||||||
const data: Record<number, NonNullable<typeof supplier.products>[0]> = {};
|
|
||||||
|
|
||||||
products.forEach((product) => {
|
|
||||||
data[product.id] = product;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}, [supplierData]);
|
|
||||||
|
|
||||||
const locationsUrl = useMemo(() => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
search: locationSelectInputValue,
|
|
||||||
...(formik.values.area_id && formik.values.area_id > 0
|
|
||||||
? { area_id: formik.values.area_id.toString() }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
return `${LocationApi.basePath}?${params.toString()}`;
|
|
||||||
}, [locationSelectInputValue, formik.values.area_id]);
|
|
||||||
|
|
||||||
const { data: locations, isLoading: isLoadingLocations } = useSWR(
|
|
||||||
locationsUrl,
|
|
||||||
LocationApi.getAllFetcher
|
|
||||||
);
|
|
||||||
|
|
||||||
const locationOptions = useMemo(() => {
|
|
||||||
if (!isResponseSuccess(locations)) return [];
|
|
||||||
return (
|
|
||||||
locations?.data.map((location) => ({
|
|
||||||
value: location.id,
|
|
||||||
label: location.name,
|
|
||||||
})) || []
|
|
||||||
);
|
|
||||||
}, [locations]);
|
|
||||||
|
|
||||||
const warehousesUrl = useMemo(() => {
|
|
||||||
const params = new URLSearchParams({ search: warehouseSelectInputValue });
|
|
||||||
|
|
||||||
if (formik.values.area_id && formik.values.area_id > 0) {
|
|
||||||
params.append('area_id', formik.values.area_id.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formik.values.location_id && formik.values.location_id > 0) {
|
|
||||||
params.append('location_id', formik.values.location_id.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${WarehouseApi.basePath}?${params.toString()}`;
|
|
||||||
}, [
|
|
||||||
warehouseSelectInputValue,
|
|
||||||
formik.values.area_id,
|
|
||||||
formik.values.location_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { data: warehouses } = useSWR(
|
|
||||||
warehousesUrl,
|
|
||||||
WarehouseApi.getAllFetcher
|
|
||||||
);
|
|
||||||
|
|
||||||
const warehouseOptions = useMemo(() => {
|
|
||||||
if (!isResponseSuccess(warehouses)) return [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
warehouses?.data.map((w) => ({
|
|
||||||
value: w.id,
|
|
||||||
label: w.name,
|
|
||||||
area: w.area?.name,
|
|
||||||
location:
|
|
||||||
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
|
|
||||||
? w.location?.name
|
|
||||||
: undefined,
|
|
||||||
})) || []
|
|
||||||
);
|
|
||||||
}, [warehouses]);
|
|
||||||
|
|
||||||
const addPurchaseItem = () => {
|
|
||||||
const newItems = [
|
|
||||||
...(formik.values.items || []),
|
|
||||||
{
|
|
||||||
warehouse: null,
|
|
||||||
warehouse_id: 0,
|
|
||||||
product: null,
|
|
||||||
product_id: 0,
|
|
||||||
qty: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
formik.setFieldValue('items', newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePurchaseItem = (idx: number) => {
|
|
||||||
const updatedPurchaseItems = formik.values.items?.filter(
|
|
||||||
(_, i) => i !== idx
|
|
||||||
);
|
|
||||||
formik.setFieldValue('items', updatedPurchaseItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeSelectedPurchaseItems = () => {
|
|
||||||
const updatedPurchaseItems = formik.values.items?.filter(
|
|
||||||
(_, idx) => !selectedPurchaseItems.includes(idx)
|
|
||||||
);
|
|
||||||
formik.setFieldValue('items', updatedPurchaseItems);
|
|
||||||
setSelectedPurchaseItems([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== UTILITY FUNCTIONS =====
|
|
||||||
const updateCreditTermBasedOnSupplier = useCallback(
|
|
||||||
(supplierId: number) => {
|
|
||||||
if (supplierId > 0 && isResponseSuccess(supplierRawData)) {
|
|
||||||
const supplierData = supplierRawData.data.find(
|
|
||||||
(s: Supplier) => s.id === supplierId
|
|
||||||
);
|
|
||||||
if (supplierData?.due_date) {
|
|
||||||
formik.setFieldTouched('credit_term', false);
|
|
||||||
formik.setFieldValue('credit_term', supplierData.due_date.toString());
|
|
||||||
} else {
|
|
||||||
formik.setFieldTouched('credit_term', false);
|
|
||||||
formik.setFieldValue('credit_term', '');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
formik.setFieldTouched('credit_term', false);
|
|
||||||
formik.setFieldValue('credit_term', '');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[supplierRawData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetPurchaseItems = useCallback(() => {
|
|
||||||
if (formik.values.items) {
|
|
||||||
formik.values.items.forEach((_, idx) => {
|
|
||||||
formik.setFieldTouched(`items.${idx}.product`, false);
|
|
||||||
formik.setFieldValue(`items.${idx}.product`, null);
|
|
||||||
formik.setFieldTouched(`items.${idx}.product_id`, false);
|
|
||||||
formik.setFieldValue(`items.${idx}.product_id`, 0);
|
|
||||||
formik.setFieldTouched(`items.${idx}.qty`, false);
|
|
||||||
formik.setFieldValue(`items.${idx}.qty`, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ===== SIDE EFFECTS =====
|
|
||||||
useEffect(() => {
|
|
||||||
if (formik.values.supplier_id && Number(formik.values.supplier_id) > 0) {
|
|
||||||
updateCreditTermBasedOnSupplier(Number(formik.values.supplier_id));
|
|
||||||
resetPurchaseItems();
|
|
||||||
} else {
|
|
||||||
formik.setFieldTouched('credit_term', false);
|
|
||||||
formik.setFieldValue('credit_term', '');
|
|
||||||
resetPurchaseItems();
|
|
||||||
}
|
|
||||||
}, [formik.values.supplier_id]);
|
|
||||||
|
|
||||||
// ===== FORM HANDLERS =====
|
|
||||||
const handleSupplierChange = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const supplier = val as OptionType | null;
|
|
||||||
const supplierId = Number(supplier?.value);
|
|
||||||
|
|
||||||
formik.setFieldTouched('supplier', true);
|
|
||||||
formik.setFieldValue('supplier', supplier);
|
|
||||||
formik.setFieldTouched('supplier_id', true);
|
|
||||||
formik.setFieldValue('supplier_id', supplierId);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreditTermChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
|
|
||||||
formik.setFieldTouched('credit_term', true);
|
|
||||||
formik.setFieldValue('credit_term', value);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreditTermBlur = useCallback(
|
|
||||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
|
||||||
formik.handleBlur(e);
|
|
||||||
},
|
|
||||||
[formik]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAreaChange = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const area = val as OptionType | null;
|
|
||||||
formik.setFieldTouched('area_id', true);
|
|
||||||
formik.setFieldValue('area_id', (area as OptionType)?.value || 0);
|
|
||||||
formik.setFieldTouched('area', true);
|
|
||||||
formik.setFieldValue('area', area);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLocationChange = useCallback(
|
|
||||||
(val: OptionType | OptionType[] | null) => {
|
|
||||||
const location = val as OptionType | null;
|
|
||||||
formik.setFieldTouched('location_id', true);
|
|
||||||
formik.setFieldValue('location_id', (location as OptionType)?.value || 0);
|
|
||||||
formik.setFieldTouched('location', true);
|
|
||||||
formik.setFieldValue('location', location);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleWarehouseChange = useCallback(
|
|
||||||
(idx: number, val: OptionType | OptionType[] | null) => {
|
|
||||||
const warehouse = val as OptionType | null;
|
|
||||||
const warehouseId = (warehouse as OptionType)?.value || 0;
|
|
||||||
|
|
||||||
formik.setFieldTouched(`items.${idx}.warehouse`, true);
|
|
||||||
formik.setFieldValue(`items.${idx}.warehouse`, warehouse);
|
|
||||||
formik.setFieldTouched(`items.${idx}.warehouse_id`, true);
|
|
||||||
formik.setFieldValue(`items.${idx}.warehouse_id`, warehouseId);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== PURCHASE ITEM OPERATIONS =====
|
|
||||||
const handlePurchaseItemChange = (
|
|
||||||
idx: number,
|
|
||||||
field: 'qty',
|
|
||||||
value: string | number
|
|
||||||
) => {
|
|
||||||
if (field === 'qty') {
|
|
||||||
const numValue =
|
|
||||||
typeof value === 'string' ? parseFloat(value) || 0 : value;
|
|
||||||
formik.setFieldTouched(`items.${idx}.qty`, true);
|
|
||||||
formik.setFieldValue(`items.${idx}.qty`, numValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className='w-full'>
|
|
||||||
<header className='flex flex-col gap-4'>
|
|
||||||
<Button
|
|
||||||
href='/purchase'
|
|
||||||
variant='link'
|
|
||||||
className='w-fit p-0 text-primary'
|
|
||||||
>
|
|
||||||
<Icon icon='uil:arrow-left' width={24} height={24} />
|
|
||||||
Kembali
|
|
||||||
</Button>
|
|
||||||
<h1 className='text-2xl font-bold text-center'>
|
|
||||||
{type === 'add' && 'Tambah Purchase Request'}
|
|
||||||
{type === 'edit' && 'Edit Purchase Request'}
|
|
||||||
{type === 'detail' && 'Detail Purchase Request'}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<form
|
|
||||||
onSubmit={formik.handleSubmit}
|
|
||||||
onReset={formik.handleReset}
|
|
||||||
className='w-full mt-8 flex flex-col gap-6'
|
|
||||||
>
|
|
||||||
{/* Basic Info Card */}
|
|
||||||
<Card
|
|
||||||
title='Informasi Purchase Request'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full mb-4 shadow',
|
|
||||||
body: 'flex flex-col gap-6',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
|
|
||||||
<SelectInput
|
|
||||||
required
|
|
||||||
label='Vendor'
|
|
||||||
placeholder='Pilih Vendor...'
|
|
||||||
value={formik.values.supplier}
|
|
||||||
onChange={handleSupplierChange}
|
|
||||||
options={supplierOptions}
|
|
||||||
onInputChange={setSupplierSelectInputValue}
|
|
||||||
isLoading={isLoadingSuppliers}
|
|
||||||
isError={
|
|
||||||
formik.touched.supplier_id &&
|
|
||||||
Boolean(formik.errors.supplier_id)
|
|
||||||
}
|
|
||||||
errorMessage={formik.errors.supplier_id as string}
|
|
||||||
isDisabled={type === 'detail'}
|
|
||||||
isClearable
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NumberInput
|
|
||||||
required={!!formik.values.supplier_id}
|
|
||||||
label='Jatuh tempo (hari)'
|
|
||||||
name='credit_term'
|
|
||||||
value={formik.values.credit_term || ''}
|
|
||||||
onChange={handleCreditTermChange}
|
|
||||||
onBlur={handleCreditTermBlur}
|
|
||||||
isError={
|
|
||||||
formik.touched.credit_term &&
|
|
||||||
Boolean(formik.errors.credit_term)
|
|
||||||
}
|
|
||||||
errorMessage={formik.errors.credit_term as string}
|
|
||||||
readOnly={type === 'detail' || !formik.values.supplier_id}
|
|
||||||
disabled={type === 'detail' || !formik.values.supplier_id}
|
|
||||||
allowNegative={false}
|
|
||||||
decimalScale={0}
|
|
||||||
placeholder={
|
|
||||||
!formik.values.supplier_id
|
|
||||||
? 'Pilih Vendor terlebih dahulu'
|
|
||||||
: 'Masukkan jumlah hari jatuh tempo'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Area'
|
|
||||||
placeholder='Pilih Area...'
|
|
||||||
value={formik.values.area}
|
|
||||||
onChange={handleAreaChange}
|
|
||||||
options={areaOptions}
|
|
||||||
onInputChange={setAreaSelectInputValue}
|
|
||||||
isLoading={isLoadingAreas}
|
|
||||||
isDisabled={type === 'detail'}
|
|
||||||
isClearable
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
|
||||||
label='Lokasi'
|
|
||||||
placeholder='Pilih Lokasi...'
|
|
||||||
value={formik.values.location}
|
|
||||||
onChange={handleLocationChange}
|
|
||||||
options={locationOptions}
|
|
||||||
onInputChange={setLocationSelectInputValue}
|
|
||||||
isLoading={isLoadingLocations}
|
|
||||||
isDisabled={type === 'detail'}
|
|
||||||
isClearable={type !== 'detail'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={'col-span-2'}>
|
|
||||||
<TextInput
|
|
||||||
label='Notes'
|
|
||||||
name='notes'
|
|
||||||
value={formik.values.notes || ''}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
isError={formik.touched.notes && Boolean(formik.errors.notes)}
|
|
||||||
errorMessage={formik.errors.notes as string}
|
|
||||||
readOnly={type === 'detail'}
|
|
||||||
disabled={type === 'detail'}
|
|
||||||
placeholder='Masukkan catatan'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Purchase Items Table */}
|
|
||||||
<Card
|
|
||||||
title='Item Pembelian'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full mb-4 shadow',
|
|
||||||
title: 'mb-4',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='overflow-x-auto'>
|
|
||||||
<table className='table'>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<th>
|
|
||||||
<CheckboxInput
|
|
||||||
name='select-all-items'
|
|
||||||
checked={
|
|
||||||
formik.values.items?.length ===
|
|
||||||
selectedPurchaseItems.length &&
|
|
||||||
formik.values.items?.length > 0
|
|
||||||
}
|
|
||||||
onChange={(
|
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedPurchaseItems(
|
|
||||||
formik.values.items?.map((_, idx) => idx) ?? []
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setSelectedPurchaseItems([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
classNames={{
|
|
||||||
wrapper: 'flex justify-center',
|
|
||||||
checkbox: 'checkbox checkbox-sm',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th>
|
|
||||||
Gudang
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Item
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Jumlah
|
|
||||||
<span className='text-error'>*</span>
|
|
||||||
</th>
|
|
||||||
<th>Estimasi Harga</th>
|
|
||||||
<th>Satuan</th>
|
|
||||||
{type !== 'detail' && <th>Action</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{formik.values.items?.map((item, idx) => (
|
|
||||||
<tr key={`purchase-item-${idx}`}>
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<td className='!align-middle'>
|
|
||||||
<CheckboxInput
|
|
||||||
name={`purchase-item-${idx}`}
|
|
||||||
checked={selectedPurchaseItems.includes(idx)}
|
|
||||||
onChange={(
|
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedPurchaseItems([
|
|
||||||
...selectedPurchaseItems,
|
|
||||||
idx,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setSelectedPurchaseItems(
|
|
||||||
selectedPurchaseItems.filter((i) => i !== idx)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
classNames={{
|
|
||||||
wrapper: 'flex justify-center',
|
|
||||||
checkbox: 'checkbox checkbox-sm',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td>
|
|
||||||
<SelectInput
|
|
||||||
placeholder='Pilih Gudang...'
|
|
||||||
value={item.warehouse}
|
|
||||||
onChange={(val) => handleWarehouseChange(idx, val)}
|
|
||||||
options={warehouseOptions}
|
|
||||||
onInputChange={setWarehouseSelectInputValue}
|
|
||||||
isLoading={isLoadingWarehouses}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'warehouse_id').isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'warehouse_id')
|
|
||||||
.errorMessage
|
|
||||||
}
|
|
||||||
isDisabled={type === 'detail'}
|
|
||||||
isClearable={type !== 'detail'}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<SelectInput
|
|
||||||
required
|
|
||||||
value={item.product ?? undefined}
|
|
||||||
onChange={(val) => {
|
|
||||||
const product = val as ProductOptionType | null;
|
|
||||||
const productId =
|
|
||||||
(product as ProductOptionType)?.value || 0;
|
|
||||||
|
|
||||||
formik.setFieldTouched(
|
|
||||||
`items.${idx}.product`,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.product`,
|
|
||||||
product
|
|
||||||
);
|
|
||||||
formik.setFieldTouched(
|
|
||||||
`items.${idx}.product_id`,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
formik.setFieldValue(
|
|
||||||
`items.${idx}.product_id`,
|
|
||||||
productId
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
options={supplierProductOptions}
|
|
||||||
isLoading={isLoadingProducts}
|
|
||||||
isError={
|
|
||||||
isRepeaterInputError(idx, 'product_id').isError
|
|
||||||
}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'product_id').errorMessage
|
|
||||||
}
|
|
||||||
isDisabled={
|
|
||||||
type === 'detail' || !formik.values.supplier_id
|
|
||||||
}
|
|
||||||
isClearable={
|
|
||||||
type !== 'detail' && !!formik.values.supplier_id
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
!formik.values.supplier_id
|
|
||||||
? 'Pilih Vendor terlebih dahulu'
|
|
||||||
: 'Pilih Produk'
|
|
||||||
}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full min-w-52 md:min-w-72 lg:min-w-80',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.qty`}
|
|
||||||
value={item.qty || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
handlePurchaseItemChange(idx, 'qty', e.target.value)
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
placeholder={
|
|
||||||
!formik.values.supplier_id
|
|
||||||
? 'Pilih Vendor terlebih dahulu'
|
|
||||||
: 'Masukkan kuantitas'
|
|
||||||
}
|
|
||||||
readOnly={
|
|
||||||
type === 'detail' || !formik.values.supplier_id
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
type === 'detail' || !formik.values.supplier_id
|
|
||||||
}
|
|
||||||
allowNegative={false}
|
|
||||||
decimalScale={0}
|
|
||||||
isError={isRepeaterInputError(idx, 'qty').isError}
|
|
||||||
errorMessage={
|
|
||||||
isRepeaterInputError(idx, 'qty').errorMessage
|
|
||||||
}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.price`}
|
|
||||||
value={
|
|
||||||
item.product_id &&
|
|
||||||
supplierProductData[item.product_id]
|
|
||||||
? formatNumber(
|
|
||||||
supplierProductData[item.product_id]
|
|
||||||
.ProductPrice *
|
|
||||||
(parseFloat(item.qty?.toString() || '0') ||
|
|
||||||
0)
|
|
||||||
)
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
onChange={() => {}}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
type='text'
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
|
|
||||||
}}
|
|
||||||
disabled={true}
|
|
||||||
readOnly={true}
|
|
||||||
inputPrefix={'Rp'}
|
|
||||||
placeholder={
|
|
||||||
item.product_id
|
|
||||||
? 'Loading...'
|
|
||||||
: 'Pilih produk terlebih dahulu'
|
|
||||||
}
|
|
||||||
bottomLabel={
|
|
||||||
item.product_id &&
|
|
||||||
supplierProductData[item.product_id]
|
|
||||||
? `Harga per unit: Rp ${formatNumber(
|
|
||||||
supplierProductData[item.product_id]
|
|
||||||
.ProductPrice
|
|
||||||
)}`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
name={`items.${idx}.uom`}
|
|
||||||
value={
|
|
||||||
item.product_id &&
|
|
||||||
supplierProductData[item.product_id]
|
|
||||||
? supplierProductData[item.product_id].uom.name
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
type='text'
|
|
||||||
readOnly={true}
|
|
||||||
disabled={true}
|
|
||||||
className={{
|
|
||||||
wrapper: 'w-full min-w-32 md:min-w-48 lg:min-w-52',
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
item.product_id
|
|
||||||
? 'Loading...'
|
|
||||||
: 'Pilih produk terlebih dahulu'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<td>
|
|
||||||
<div className='flex justify-center'>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='error'
|
|
||||||
onClick={() => removePurchaseItem(idx)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='mdi:trash-can'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<div className='flex justify-center items-center mt-4 gap-4'>
|
|
||||||
{selectedPurchaseItems.length > 0 && (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='error'
|
|
||||||
onClick={removeSelectedPurchaseItems}
|
|
||||||
disabled={selectedPurchaseItems.length === 0}
|
|
||||||
className='w-fit'
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:trash-can' width={24} height={24} />
|
|
||||||
Hapus Terpilih ({selectedPurchaseItems.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='success'
|
|
||||||
onClick={addPurchaseItem}
|
|
||||||
className='w-fit'
|
|
||||||
>
|
|
||||||
<Icon icon='ic:round-plus' width={24} height={24} />
|
|
||||||
Tambah Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
|
||||||
{type !== 'detail' && (
|
|
||||||
<div className='flex flex-row justify-end gap-2 w-full'>
|
|
||||||
<Button type='reset' color='warning' className='px-4'>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='submit'
|
|
||||||
color='primary'
|
|
||||||
className='px-4'
|
|
||||||
isLoading={formik.isSubmitting}
|
|
||||||
disabled={!formik.isValid || formik.isSubmitting}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{type === 'detail' && (
|
|
||||||
<div className='flex flex-row justify-start gap-2'>
|
|
||||||
<Button
|
|
||||||
href={`/purchase/detail/edit/?purchaseId=${initialValues?.id}`}
|
|
||||||
color='warning'
|
|
||||||
className='px-4'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:edit-outline'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
color='error'
|
|
||||||
onClick={deletePurchaseRequestClickHandler}
|
|
||||||
className='px-4'
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:delete-outline-rounded'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className='justify-start text-sm'
|
|
||||||
/>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{purchaseRequestFormErrorMessage && (
|
|
||||||
<div role='alert' className='alert alert-error'>
|
|
||||||
<Icon
|
|
||||||
icon='material-symbols:error-outline'
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
<span>{purchaseRequestFormErrorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{type !== 'add' && (
|
|
||||||
<ConfirmationModal
|
|
||||||
ref={deleteModal.ref}
|
|
||||||
type='error'
|
|
||||||
text='Apakah anda yakin ingin menghapus data Purchase Request ini?'
|
|
||||||
secondaryButton={{
|
|
||||||
text: 'Tidak',
|
|
||||||
}}
|
|
||||||
primaryButton={{
|
|
||||||
text: 'Ya',
|
|
||||||
color: 'error',
|
|
||||||
isLoading: isDeleteLoading,
|
|
||||||
onClick: confirmationModalDeleteClickHandler,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PurchaseRequestForm;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,534 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Page,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
Document,
|
|
||||||
Image,
|
|
||||||
StyleSheet,
|
|
||||||
Font,
|
|
||||||
pdf,
|
|
||||||
} from '@react-pdf/renderer';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { Purchase } from '@/types/api/purchase/purchase';
|
|
||||||
import { formatDate, formatNumber } from '@/lib/helper';
|
|
||||||
|
|
||||||
Font.register({
|
|
||||||
family: 'Helvetica',
|
|
||||||
src: 'helvetica',
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface PurchaseOrderInvoiceProps {
|
|
||||||
data?: Purchase;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
|
|
||||||
const [, setIsGeneratingPDF] = useState(false);
|
|
||||||
const purchaseData = data;
|
|
||||||
|
|
||||||
const grandTotal = useMemo(() => {
|
|
||||||
return (
|
|
||||||
purchaseData?.items?.reduce(
|
|
||||||
(sum, item) => sum + (item.total_price || 0),
|
|
||||||
0
|
|
||||||
) || 0
|
|
||||||
);
|
|
||||||
}, [purchaseData?.items]);
|
|
||||||
|
|
||||||
const handleDownloadPDF = async () => {
|
|
||||||
if (!purchaseData) {
|
|
||||||
alert('No purchase order data available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGeneratingPDF(true);
|
|
||||||
try {
|
|
||||||
const PDFDocument = () => (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Purchase Order Title */}
|
|
||||||
<View style={pdfStyles.titleSection}>
|
|
||||||
<Text style={pdfStyles.title}>PURCHASE ORDER</Text>
|
|
||||||
<View style={pdfStyles.poInfo}>
|
|
||||||
<Text>PO Number: {purchaseData?.po_number || '-'}</Text>
|
|
||||||
<Text>
|
|
||||||
Date:{' '}
|
|
||||||
{purchaseData?.po_date
|
|
||||||
? formatDate(purchaseData.po_date, 'DD MMM YYYY')
|
|
||||||
: formatDate(new Date(), 'DD MMM YYYY')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Vendor and Ship To Table */}
|
|
||||||
<View style={pdfStyles.table}>
|
|
||||||
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
|
|
||||||
<View style={pdfStyles.tableCellHeader}>
|
|
||||||
<Text>Vendor</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellHeaderLast}>
|
|
||||||
<Text>Ship To</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableRow}>
|
|
||||||
<View style={pdfStyles.tableCell}>
|
|
||||||
<Text style={{ fontWeight: 'bold' }}>
|
|
||||||
{purchaseData?.supplier?.name || '-'} (
|
|
||||||
{purchaseData?.supplier?.alias || ''})
|
|
||||||
</Text>
|
|
||||||
<Text>{purchaseData?.supplier?.category || '-'}</Text>
|
|
||||||
<Text>
|
|
||||||
Credit Term: {purchaseData?.credit_term || 0} hari
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
Due Date:{' '}
|
|
||||||
{purchaseData?.due_date
|
|
||||||
? formatDate(purchaseData.due_date, 'DD MMM YYYY')
|
|
||||||
: '-'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellLast}>
|
|
||||||
<Text style={{ fontWeight: 'bold' }}>
|
|
||||||
PT LUMBUNG TELUR INDONESIA
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
|
|
||||||
? purchaseData.items[0].warehouse.location.name
|
|
||||||
: '-'}
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
|
|
||||||
? purchaseData.items[0].warehouse.location.address
|
|
||||||
: '-'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Item Description Table */}
|
|
||||||
<View>
|
|
||||||
<View style={pdfStyles.table}>
|
|
||||||
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
|
|
||||||
<View style={pdfStyles.tableCellHeader}>
|
|
||||||
<Text>Item Description</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>
|
|
||||||
{purchaseData?.items?.map((item, index) => {
|
|
||||||
const isLastItem =
|
|
||||||
index === (purchaseData?.items?.length || 0) - 1;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={[
|
|
||||||
pdfStyles.tableRow,
|
|
||||||
isLastItem ? {} : pdfStyles.tableBorderBottom,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={pdfStyles.tableCell}>
|
|
||||||
<Text>{item.product?.name || '-'}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellRight}>
|
|
||||||
<Text>Rp{formatNumber(item.price || 0)}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellRight}>
|
|
||||||
<Text>{formatNumber(item.sub_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.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>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Product Allocation Section */}
|
|
||||||
<View style={pdfStyles.allocationSection}>
|
|
||||||
<Text style={pdfStyles.sectionTitle}>Product Allocation</Text>
|
|
||||||
<View style={pdfStyles.allocationTable}>
|
|
||||||
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
|
|
||||||
<View style={pdfStyles.tableCellHeader}>
|
|
||||||
<Text>Warehouse Name</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellHeader}>
|
|
||||||
<Text>Area</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellHeader}>
|
|
||||||
<Text>Location Address</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellHeaderLast}>
|
|
||||||
<Text>Product Allocation</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{purchaseData?.items?.map((item, itemIndex) => (
|
|
||||||
<View key={itemIndex} style={pdfStyles.tableRow}>
|
|
||||||
<View style={pdfStyles.tableCell}>
|
|
||||||
<Text>{item.warehouse?.name || '-'}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCell}>
|
|
||||||
<Text>{item.warehouse?.area?.name || '-'}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCell}>
|
|
||||||
<Text>
|
|
||||||
{item.warehouse?.type === 'LOKASI'
|
|
||||||
? item.warehouse.location.address
|
|
||||||
: '-'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.tableCellLast}>
|
|
||||||
{/* Inner table for product allocation */}
|
|
||||||
<View style={pdfStyles.innerTable}>
|
|
||||||
{/* Header for inner table */}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
pdfStyles.innerRow,
|
|
||||||
{ backgroundColor: '#F5F5F5' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={pdfStyles.innerCell}>Item</Text>
|
|
||||||
<Text style={pdfStyles.innerCellRightLast}>
|
|
||||||
Quantity
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{/* Data row */}
|
|
||||||
<View style={pdfStyles.innerRow}>
|
|
||||||
<Text style={pdfStyles.innerCell}>
|
|
||||||
{item.product?.name || '-'}
|
|
||||||
</Text>
|
|
||||||
<Text style={pdfStyles.innerCellRightLast}>
|
|
||||||
{formatNumber(item.total_qty || 0)} of{' '}
|
|
||||||
{formatNumber(item.sub_qty || 0)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</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>{purchaseData?.notes || '-'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={pdfStyles.footerCompany}>
|
|
||||||
<Text>PT LUMBUNG TELUR INDONESIA</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Page>
|
|
||||||
</Document>
|
|
||||||
);
|
|
||||||
|
|
||||||
const blob = await pdf(<PDFDocument />).toBlob();
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `${purchaseData?.po_number || 'purchase-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 (!purchaseData) {
|
|
||||||
return (
|
|
||||||
<div className='flex items-center justify-center min-h-screen'>
|
|
||||||
<div className='text-gray-500'>No purchase order data available</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return purchaseData?.po_number &&
|
|
||||||
purchaseData.po_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}
|
|
||||||
>
|
|
||||||
<Icon icon='material-symbols:file-open-outline' width={16} height={16} />
|
|
||||||
{purchaseData.po_number}
|
|
||||||
</Button>
|
|
||||||
) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PurchaseOrderInvoice;
|
|
||||||
@@ -93,29 +93,6 @@ export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const PURCHASE_ORDER_APPROVAL_LINE: ApprovalLine = [
|
|
||||||
{
|
|
||||||
step_number: 1,
|
|
||||||
step_name: 'Pengajuan',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step_number: 2,
|
|
||||||
step_name: 'Staff Purchase',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step_number: 3,
|
|
||||||
step_name: 'Manager Purchase',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step_number: 4,
|
|
||||||
step_name: 'Penerimaan Produk',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step_number: 5,
|
|
||||||
step_name: 'Selesai',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [
|
export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [
|
||||||
{
|
{
|
||||||
step_number: 1,
|
step_number: 1,
|
||||||
@@ -123,7 +100,7 @@ export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
step_number: 2,
|
step_number: 2,
|
||||||
step_name: 'Approval Manager',
|
step_name: 'Approval Manager Area',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step_number: 3,
|
step_number: 3,
|
||||||
|
|||||||
+6
-12
@@ -41,9 +41,9 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Pembelian',
|
title: 'Biaya Operasional',
|
||||||
link: '/purchase',
|
link: '/expense',
|
||||||
icon: 'gg:shopping-cart',
|
icon: 'uil:wallet',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -52,12 +52,6 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
|
|||||||
icon: 'mdi:attach-money',
|
icon: 'mdi:attach-money',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
title: 'Biaya Operasional',
|
|
||||||
link: '/expense',
|
|
||||||
icon: 'uil:wallet',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Persediaan',
|
title: 'Persediaan',
|
||||||
link: '/inventory',
|
link: '/inventory',
|
||||||
@@ -239,9 +233,9 @@ 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 = [
|
export const APPROVAL_WORKFLOWS = [
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { httpClient } from '@/services/http/client';
|
|
||||||
import { BaseApiResponse, LogoutResponse } from '@/types/api/api-general';
|
|
||||||
|
|
||||||
export class AuthApiService {
|
|
||||||
async logout() {
|
|
||||||
try {
|
|
||||||
const logoutRes = await httpClient<LogoutResponse>(`/sso/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
return logoutRes;
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError<BaseApiResponse>(error)) {
|
|
||||||
return error.response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthApi = new AuthApiService();
|
|
||||||
|
|||||||
+1050
-490
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
|||||||
import {
|
|
||||||
CreatePurchaseRequestPayload,
|
|
||||||
Purchase,
|
|
||||||
UpdatePurchaseRequestPayload,
|
|
||||||
CreateStaffApprovalRequestPayload,
|
|
||||||
UpdateStaffApprovalRequestPayload,
|
|
||||||
CreateManagerApprovalRequestPayload,
|
|
||||||
CreateAcceptApprovalRequestPayload,
|
|
||||||
DeletePurchaseRequestItemPayload,
|
|
||||||
} from '@/types/api/purchase/purchase';
|
|
||||||
import { BaseApiService } from '@/services/api/base';
|
|
||||||
import { BaseApiResponse } from '@/types/api/api-general';
|
|
||||||
|
|
||||||
const basePurchaseApi = new BaseApiService<
|
|
||||||
Purchase,
|
|
||||||
CreatePurchaseRequestPayload,
|
|
||||||
UpdatePurchaseRequestPayload
|
|
||||||
>('/purchases');
|
|
||||||
|
|
||||||
export const PurchaseApi = {
|
|
||||||
basePath: basePurchaseApi.basePath,
|
|
||||||
header: basePurchaseApi.header,
|
|
||||||
getAllFetcher: basePurchaseApi.getAllFetcher.bind(basePurchaseApi),
|
|
||||||
getSingle: basePurchaseApi.getSingle.bind(basePurchaseApi),
|
|
||||||
create: basePurchaseApi.create.bind(basePurchaseApi),
|
|
||||||
update: basePurchaseApi.update.bind(basePurchaseApi),
|
|
||||||
delete: basePurchaseApi.delete.bind(basePurchaseApi),
|
|
||||||
customRequest: basePurchaseApi.customRequest.bind(basePurchaseApi),
|
|
||||||
|
|
||||||
staffApproval: {
|
|
||||||
create: async (
|
|
||||||
purchaseRequestId: number,
|
|
||||||
payload: CreateStaffApprovalRequestPayload
|
|
||||||
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
|
|
||||||
return await basePurchaseApi.customRequest<
|
|
||||||
BaseApiResponse<{ message: string }>
|
|
||||||
>(`${purchaseRequestId}/approvals/staff`, {
|
|
||||||
method: 'POST',
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async (
|
|
||||||
purchaseRequestId: number,
|
|
||||||
payload: UpdateStaffApprovalRequestPayload
|
|
||||||
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
|
|
||||||
return await basePurchaseApi.customRequest<
|
|
||||||
BaseApiResponse<{ message: string }>
|
|
||||||
>(`${purchaseRequestId}/approvals/staff`, {
|
|
||||||
method: 'POST',
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
managerApproval: {
|
|
||||||
create: async (
|
|
||||||
purchaseRequestId: number,
|
|
||||||
payload: CreateManagerApprovalRequestPayload
|
|
||||||
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
|
|
||||||
return await basePurchaseApi.customRequest<
|
|
||||||
BaseApiResponse<{ message: string }>
|
|
||||||
>(`${purchaseRequestId}/approvals/manager`, {
|
|
||||||
method: 'POST',
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
acceptApproval: {
|
|
||||||
create: async (
|
|
||||||
purchaseRequestId: number,
|
|
||||||
payload: CreateAcceptApprovalRequestPayload
|
|
||||||
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
|
|
||||||
return await basePurchaseApi.customRequest<
|
|
||||||
BaseApiResponse<{ message: string }>
|
|
||||||
>(`${purchaseRequestId}/receipts`, {
|
|
||||||
method: 'POST',
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
items: {
|
|
||||||
delete: async (
|
|
||||||
purchaseRequestId: number,
|
|
||||||
payload: DeletePurchaseRequestItemPayload
|
|
||||||
): Promise<BaseApiResponse<{ message: string }> | undefined> => {
|
|
||||||
return await basePurchaseApi.customRequest<
|
|
||||||
BaseApiResponse<{ message: string }>
|
|
||||||
>(`${purchaseRequestId}/items`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,22 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
import { RequestOptions } from '@/services/http/base';
|
import { RequestOptions } from '@/services/http/base';
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
|
||||||
const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 10_000 });
|
const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 10_000 });
|
||||||
|
|
||||||
axiosClient.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
(error: AxiosError) => {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
const ssoLoginUrl = `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`;
|
|
||||||
window.location.href = ssoLoginUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function httpClient<T, B = unknown>(
|
export async function httpClient<T, B = unknown>(
|
||||||
path: string,
|
path: string,
|
||||||
opts: RequestOptions<B> = {}
|
opts: RequestOptions<B> = {}
|
||||||
|
|||||||
Vendored
+35
-88
@@ -1,107 +1,54 @@
|
|||||||
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
|
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
|
||||||
import { BaseLocation, Location } from '@/types/api/master-data/location';
|
import { Location } from '@/types/api/master-data/location';
|
||||||
import { BaseKandang, Kandang } from '@/types/api/master-data/kandang';
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
import { BaseSupplier, Supplier } from '@/types/api/master-data/supplier';
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
import { BaseNonstock, Nonstock } from '@/types/api/master-data/nonstock';
|
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||||
import { BaseUser } from '@/types/api/user';
|
|
||||||
|
|
||||||
export type BaseExpense = {
|
export type BaseExpense = {
|
||||||
id: number;
|
id: number;
|
||||||
reference_number: string;
|
reference_number: string;
|
||||||
po_number: string | null;
|
po_number?: string;
|
||||||
category: 'BOP' | 'NON-BOP';
|
location: Location;
|
||||||
documents?: {
|
transaction_date: string;
|
||||||
id: number;
|
|
||||||
path: string;
|
|
||||||
}[];
|
|
||||||
realization_docs?: {
|
|
||||||
id: number;
|
|
||||||
path: string;
|
|
||||||
}[];
|
|
||||||
expense_date: string;
|
|
||||||
realization_date?: string;
|
realization_date?: string;
|
||||||
grand_total: number;
|
kandangs: Kandang[];
|
||||||
location: BaseLocation;
|
vendor: Supplier;
|
||||||
supplier: BaseSupplier;
|
request_documents: {
|
||||||
kandangs: {
|
|
||||||
id: number;
|
|
||||||
kandang_id: number;
|
|
||||||
name: string;
|
name: string;
|
||||||
pengajuans?: {
|
url: string;
|
||||||
id: number;
|
}[];
|
||||||
qty: number;
|
kandang_expenses: {
|
||||||
unit_price: number;
|
kandang: Kandang;
|
||||||
total_price: number;
|
expenses: {
|
||||||
note?: string;
|
nonstock: Nonstock;
|
||||||
nonstock: Pick<BaseNonstock, 'id' | 'name' | 'flags'>;
|
total_quantity: number;
|
||||||
project_flock_kandang: {
|
total_expense: number;
|
||||||
id: number;
|
notes?: string;
|
||||||
kandang_id: number;
|
|
||||||
};
|
|
||||||
}[];
|
|
||||||
realisasi?: {
|
|
||||||
id: number;
|
|
||||||
qty: number;
|
|
||||||
unit_price: number;
|
|
||||||
total_price: number;
|
|
||||||
date: string;
|
|
||||||
note?: string;
|
|
||||||
nonstock: Pick<BaseNonstock, 'id' | 'name' | 'flags'>;
|
|
||||||
project_flock_kandang: {
|
|
||||||
id: number;
|
|
||||||
kandang_id: number;
|
|
||||||
};
|
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
total_pengajuan: number;
|
nominal: number;
|
||||||
total_realisasi: number;
|
paid?: number;
|
||||||
latest_approval: BaseApproval;
|
remaining_cost?: number;
|
||||||
|
approval: BaseApproval;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Expense = BaseMetadata & BaseExpense;
|
export type Expense = BaseMetadata & BaseExpense;
|
||||||
|
|
||||||
export type CreateExpensePayload = {
|
export type CreateExpensePayload = {
|
||||||
category: 'BOP' | 'NON-BOP';
|
locationId: number;
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
supplier_id: number;
|
kandangIds: number[];
|
||||||
documents: File[];
|
vendorId: number;
|
||||||
cost_per_kandangs: {
|
request_documents: File[];
|
||||||
kandang_id: number;
|
kandang_expenses: {
|
||||||
cost_items: {
|
kandangId: number;
|
||||||
nonstock_id: number;
|
expenses: {
|
||||||
quantity: number;
|
nonstockId: number;
|
||||||
total_cost: number;
|
total_quantity: number;
|
||||||
notes: string;
|
total_expense: number;
|
||||||
|
notes?: string;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateExpensePayload = {
|
export type UpdateExpensePayload = CreateExpensePayload;
|
||||||
category: 'BOP' | 'NON-BOP';
|
|
||||||
transaction_date: string;
|
|
||||||
supplier_id: number;
|
|
||||||
documents: File[];
|
|
||||||
cost_per_kandang: {
|
|
||||||
kandang_id: number;
|
|
||||||
cost_items: {
|
|
||||||
nonstock_id: number;
|
|
||||||
quantity: number;
|
|
||||||
total_cost: number;
|
|
||||||
notes: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateExpenseRealizationPayload = {
|
|
||||||
realization_date: string;
|
|
||||||
documents: File[];
|
|
||||||
realizations: {
|
|
||||||
expense_nonstock_id: number;
|
|
||||||
qty: number;
|
|
||||||
unit_price: number;
|
|
||||||
total_price: number;
|
|
||||||
notes: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateExpenseRealizationPayload = CreateExpenseRealizationPayload;
|
|
||||||
|
|||||||
-12
@@ -1,5 +1,4 @@
|
|||||||
import { BaseMetadata } from '@/types/api/api-general';
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
import { Uom } from '@/types/api/master-data/uom';
|
|
||||||
|
|
||||||
export type BaseSupplier = {
|
export type BaseSupplier = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,17 +19,6 @@ export type BaseSupplier = {
|
|||||||
|
|
||||||
export type Supplier = BaseMetadata & BaseSupplier;
|
export type Supplier = BaseMetadata & BaseSupplier;
|
||||||
|
|
||||||
export type SupplierProducts = Supplier & {
|
|
||||||
products?: Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ProductPrice: number;
|
|
||||||
SellingPrice?: number;
|
|
||||||
uom: Uom;
|
|
||||||
flags: string[];
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateSupplierPayload = {
|
export type CreateSupplierPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
|
|||||||
Vendored
-128
@@ -1,128 +0,0 @@
|
|||||||
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
|
|
||||||
import { Supplier } from '@/types/api/master-data/supplier';
|
|
||||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
|
||||||
import { Product } from '@/types/api/master-data/product';
|
|
||||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
|
||||||
import { Area } from '@/types/api/master-data/area';
|
|
||||||
import { Location } from '@/types/api/master-data/location';
|
|
||||||
|
|
||||||
export type PurchaseItemProduct = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
flags?: string[];
|
|
||||||
uom?: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
product_category?:
|
|
||||||
| {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
| string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PurchaseItem = {
|
|
||||||
id: number;
|
|
||||||
product_id: number;
|
|
||||||
warehouse: Warehouse;
|
|
||||||
product: PurchaseItemProduct | Product;
|
|
||||||
product_warehouse: ProductWarehouse;
|
|
||||||
quantity: number;
|
|
||||||
qty: number;
|
|
||||||
sub_qty: number;
|
|
||||||
total_qty: number;
|
|
||||||
total_used: number;
|
|
||||||
price: number;
|
|
||||||
total_price: number;
|
|
||||||
received_date?: string | null;
|
|
||||||
travel_number?: string | null;
|
|
||||||
travel_number_docs?: string | null;
|
|
||||||
travel_document_path?: string | null;
|
|
||||||
vehicle_number?: string | null;
|
|
||||||
expedition_vendor_id?: number | null;
|
|
||||||
expedition_vendor_name?: string | null;
|
|
||||||
received_qty?: number | null;
|
|
||||||
transport_per_item?: number | null;
|
|
||||||
transport_total?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BasePurchase = {
|
|
||||||
id: number;
|
|
||||||
pr_number: string;
|
|
||||||
po_number: string;
|
|
||||||
po_document_path?: string | null;
|
|
||||||
po_date: string;
|
|
||||||
supplier: Supplier;
|
|
||||||
credit_term: number;
|
|
||||||
due_date: string;
|
|
||||||
grand_total: number;
|
|
||||||
notes?: string | null;
|
|
||||||
deleted_at?: string | null;
|
|
||||||
created_by: number;
|
|
||||||
area?: Area;
|
|
||||||
location?: Location;
|
|
||||||
warehouse?: Warehouse;
|
|
||||||
items?: PurchaseItem[];
|
|
||||||
approval?: BaseApproval;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Purchase = BaseMetadata & BasePurchase;
|
|
||||||
|
|
||||||
export type CreatePurchaseRequestPayload = {
|
|
||||||
supplier_id: number;
|
|
||||||
credit_term: number;
|
|
||||||
notes?: string | null;
|
|
||||||
items: {
|
|
||||||
warehouse_id: number;
|
|
||||||
product_id: number;
|
|
||||||
qty: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateStaffApprovalRequestPayload = {
|
|
||||||
action: 'APPROVED' | 'REJECTED';
|
|
||||||
notes?: string | null;
|
|
||||||
items: {
|
|
||||||
purchase_item_id: number;
|
|
||||||
qty: number;
|
|
||||||
price: number;
|
|
||||||
total_price: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateStaffApprovalRequestPayload = {
|
|
||||||
action: 'APPROVED' | 'REJECTED';
|
|
||||||
notes?: string | null;
|
|
||||||
items: Array<{
|
|
||||||
purchase_item_id?: number;
|
|
||||||
product_id?: number;
|
|
||||||
warehouse_id?: number;
|
|
||||||
qty: number;
|
|
||||||
price: number;
|
|
||||||
total_price: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateManagerApprovalRequestPayload = {
|
|
||||||
notes?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateAcceptApprovalRequestPayload = {
|
|
||||||
notes?: string;
|
|
||||||
items: {
|
|
||||||
purchase_item_id: number;
|
|
||||||
received_date: string;
|
|
||||||
travel_number: string;
|
|
||||||
travel_document_path: string;
|
|
||||||
vehicle_number: string;
|
|
||||||
expedition_vendor_id: number;
|
|
||||||
received_qty: number;
|
|
||||||
transport_per_item: number;
|
|
||||||
transport_total: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DeletePurchaseRequestItemPayload = {
|
|
||||||
item_ids: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdatePurchaseRequestPayload = CreatePurchaseRequestPayload;
|
|
||||||
Reference in New Issue
Block a user