Compare commits

..

1 Commits

Author SHA1 Message Date
Rivaldi A N S ad6c25d8b6 Merge branch 'dev/randy' into 'fix/FE/US-74/TASK-270-fixing-periode-project-flock'
[FE/FE][US#74/TASK#270] Fixing Project Flock

See merge request mbugroup/lti-web-client!57
2025-11-25 04:14:48 +00:00
57 changed files with 2300 additions and 10282 deletions
+3 -23
View File
@@ -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
View File
@@ -1,3 +1,3 @@
npm run format npm run format
npm run lint npm run lint
npm run build npm run build
+40 -40
View File
@@ -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
View File
@@ -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

+4 -6
View File
@@ -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;
} }
-62
View File
@@ -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;
-11
View File
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
-67
View File
@@ -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;
-11
View File
@@ -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;
-47
View File
@@ -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;
-54
View File
@@ -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;
-11
View File
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
-11
View File
@@ -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
View File
@@ -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>
); );
}; };
+2 -6
View File
@@ -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
); );
+3 -7
View File
@@ -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;
+2 -23
View File
@@ -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>
+166 -33
View File
@@ -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;
+4 -6
View File
@@ -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;
+19 -53
View File
@@ -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,
})) }))
: []; : [];
+465 -34
View File
@@ -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;
+104 -178
View File
@@ -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
@@ -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;
+1 -24
View File
@@ -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
View File
@@ -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 = [
-23
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-97
View File
@@ -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 -13
View File
@@ -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> = {}
+35 -88
View File
@@ -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
View File
@@ -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;
-128
View File
@@ -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;