Merge branch 'development' into feat/FE/US-335/production-data-report

This commit is contained in:
ValdiANS
2025-12-18 18:50:22 +07:00
parent 65b60cc464
commit 096a8d394e
112 changed files with 7158 additions and 3238 deletions
+3
View File
@@ -42,3 +42,6 @@ next-env.d.ts
# idea # idea
.idea .idea
# claude
.claude
+29 -4
View File
@@ -73,8 +73,8 @@ stages:
if [ "$CI_COMMIT_BRANCH" = "development" ]; then if [ "$CI_COMMIT_BRANCH" = "development" ]; then
ENVIRONMENT_NAME="WEB-LTI-DEV" ENVIRONMENT_NAME="WEB-LTI-DEV"
elif [ "$CI_COMMIT_BRANCH" = "master" ]; then elif [ "$CI_COMMIT_BRANCH" = "staging" ]; then
ENVIRONMENT_NAME="WEB-LTI-PROD" ENVIRONMENT_NAME="WEB-LTI-STAGING"
else else
ENVIRONMENT_NAME="UNKNOWN" ENVIRONMENT_NAME="UNKNOWN"
fi fi
@@ -122,11 +122,10 @@ build:dev:
environment: environment:
name: development name: development
variables: variables:
# NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.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_LTI_URL: 'https://dev-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-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' NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
deploy:dev: deploy:dev:
<<: *deploy_template <<: *deploy_template
@@ -140,6 +139,32 @@ deploy:dev:
environment: environment:
name: development name: development
url: https://dev-lti-erp.mbugroup.id url: https://dev-lti-erp.mbugroup.id
# ====== STAGING (Branch staging) ======
build:staging:
<<: *build_template
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
environment:
name: staging
variables:
NEXT_PUBLIC_LTI_URL: 'https://stg-lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
deploy:staging:
<<: *deploy_template
needs: ['build:staging']
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
variables:
S3_BUCKET: 'stg-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E2V6PPO1AUIU7H'
environment:
name: staging
url: https://stg-lti-erp.mbugroup.id
# ====== PRODUCTION ====== # ====== PRODUCTION ======
# build:production: # build:production:
# <<: *build_template # <<: *build_template
+1
View File
@@ -3,6 +3,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'export', output: 'export',
images: { unoptimized: true }, images: { unoptimized: true },
trailingSlash: true,
}; };
export default nextConfig; export default nextConfig;
+1 -1
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.7",
"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",
+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.7",
"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",
+14 -5
View File
@@ -19,6 +19,11 @@ const ClosingDetailPage = () => {
(id: number) => ClosingApi.getGeneralInfo(id) (id: number) => ClosingApi.getGeneralInfo(id)
); );
const { data: salesData, isLoading: isLoadingSales } = useSWR(
closingId ? `sales-${closingId}` : null,
() => ClosingApi.getPenjualan(Number(closingId))
);
if (!closingId) { if (!closingId) {
router.back(); router.back();
@@ -34,14 +39,18 @@ const ClosingDetailPage = () => {
return; return;
} }
const isLoading = isLoadingClosing || isLoadingSales;
return ( return (
<div className='w-full p-4 flex flex-row justify-center'> <div className='w-full p-4 flex flex-row justify-center'>
{isLoadingClosing && ( {isLoading && <span className='loading loading-spinner loading-xl' />}
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingClosing && isResponseSuccess(closing) && ( {!isLoading && isResponseSuccess(closing) && (
<ClosingDetail id={Number(closingId)} initialValue={closing.data} /> <ClosingDetail
id={Number(closingId)}
initialValue={closing.data}
salesData={isResponseSuccess(salesData) ? salesData.data : undefined}
/>
)} )}
</div> </div>
); );
+33 -20
View File
@@ -7,26 +7,39 @@
default: false; default: false;
prefersdark: false; prefersdark: false;
color-scheme: 'light'; color-scheme: 'light';
--color-base-100: oklch(98% 0.001 106.423);
--color-base-200: oklch(97% 0.001 106.424); /* Primary Colors */
--color-base-300: oklch(92% 0.003 48.717); --color-primary: oklch(39.4% 0.177 301.9);
--color-base-content: oklch(22.389% 0.031 278.072); --color-primary-content: oklch(87.5% 0.038 274.5);
--color-primary: oklch(60% 0.126 221.723);
--color-primary-content: oklch(100% 0 0); /* Secondary Colors */
--color-secondary: oklch(52% 0.105 223.128); --color-secondary: oklch(60.1% 0.258 335.7);
--color-secondary-content: oklch(100% 0 0); --color-secondary-content: oklch(99.4% 0.007 337.8);
--color-accent: oklch(45% 0.085 224.283);
--color-accent-content: oklch(100% 0 0); /* Accent Colors */
--color-neutral: oklch(39% 0.07 227.392); --color-accent: oklch(76.2% 0.155 170.8);
--color-neutral-content: oklch(100% 0 0); --color-accent-content: oklch(7.2% 0.007 167.6);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(100% 0 0); /* Neutral Colors */
--color-success: oklch(62% 0.194 149.214); --color-neutral: oklch(22.4% 0.032 258.8);
--color-success-content: oklch(100% 0 0); --color-neutral-content: oklch(87.7% 0.016 257);
--color-warning: oklch(85% 0.199 91.936);
--color-warning-content: oklch(0% 0 0); /* Base Colors */
--color-error: oklch(57% 0.245 27.325); --color-base-100: oklch(100% 0 0); /* #ffffff */
--color-error-content: oklch(100% 0 0); --color-base-200: oklch(97.2% 0 0); /* #f2f2f2 */
--color-base-300: oklch(93.1% 0.002 249.7); /* #e5e6e6 */
--color-base-content: oklch(18.6% 0.024 257.7); /* #1f2937 */
/* Status/Utility Colors */
--color-info: oklch(67.4% 0.176 238.9);
--color-info-content: oklch(0% 0 0); /* #000000 */
--color-success: oklch(62.3% 0.147 149);
--color-success-content: oklch(100% 0 0); /* #ffffff */
--color-warning: oklch(82.2% 0.165 91.9);
--color-warning-content: oklch(0% 0 0); /* #000000 */
--color-error: oklch(61.8% 0.203 27.8);
--color-error-content: oklch(100% 0 0); /* #fffffff */
--radius-selector: 0rem; --radius-selector: 0rem;
--radius-field: 0.25rem; --radius-field: 0.25rem;
--radius-box: 0.25rem; --radius-box: 0.25rem;
@@ -12,8 +12,6 @@ const DetailInventoryAdjustment = () => {
// Ambil data dari router state // Ambil data dari router state
useEffect(() => { useEffect(() => {
console.log('Router State');
console.log(window.history.state);
const state = window.history.state?.usr as const state = window.history.state?.usr as
| { inventoryAdjustment?: InventoryAdjustment } | { inventoryAdjustment?: InventoryAdjustment }
| undefined; | undefined;
@@ -26,9 +24,6 @@ const DetailInventoryAdjustment = () => {
const finalData = inventoryAdjustment; const finalData = inventoryAdjustment;
console.log('Final Data');
console.log(finalData);
if (!finalData) { if (!finalData) {
return ( return (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
+50
View File
@@ -0,0 +1,50 @@
'use client';
import InventoryProductDetail from '@/components/pages/inventory/product/detail/InventoryProductDetail';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const InventoryProductDetailPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const inventoryProductId = searchParams.get('inventoryProductId');
const { data: inventoryProduct, isLoading: isLoadingInventoryProduct } =
useSWR(inventoryProductId, (id: number) =>
InventoryProductApi.getSingle(id)
);
if (!inventoryProductId) {
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 (
!isLoadingInventoryProduct &&
(!inventoryProduct || isResponseError(inventoryProduct))
) {
router.replace('/404');
return;
}
return (
<div className='size-full'>
{isLoadingInventoryProduct && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingInventoryProduct && isResponseSuccess(inventoryProduct) && (
<InventoryProductDetail inventoryProduct={inventoryProduct.data} />
)}
</div>
);
};
export default InventoryProductDetailPage;
+11
View File
@@ -0,0 +1,11 @@
import InventoryProductTable from '@/components/pages/inventory/product/InventoryProductTable';
const InventoryProductPage = () => {
return (
<div className='size-full'>
<InventoryProductTable />
</div>
);
};
export default InventoryProductPage;
+1
View File
@@ -7,4 +7,5 @@ const Marketing = () => {
</div> </div>
); );
}; };
export default Marketing; export default Marketing;
+21 -3
View File
@@ -1,11 +1,29 @@
import { redirect } from 'next/navigation'; 'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/services/hooks/useAuth';
import { redirectToSSO } from '@/lib/auth-helper';
export default function Home() { export default function Home() {
redirect('/dashboard'); const { user, isLoadingUser } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (pathname === '/') {
router.replace('/dashboard');
}
}, [pathname]);
if (isLoadingUser) {
return ( return (
<main className='w-full h-full min-h-screen flex flex-row justify-center items-center'> <main className='w-full h-full min-h-screen flex flex-row justify-center items-center'>
<h1>LTI ERP</h1> <span className='loading loading-spinner loading-lg'></span>
</main> </main>
); );
} }
return <>Loading...</>;
}
@@ -1,10 +1,18 @@
'use client'; 'use client';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import React, { useImperativeHandle } from 'react';
import toast from 'react-hot-toast';
const AddProjectFlock = () => { const AddProjectFlock = () => {
// useImperativeHandle(ref, () => ({
// validate() {
// toast.success('Validating');
// return false;
// },
// }));
return ( return (
<section className='w-full p-4 flex flex-row justify-center'> <section className='w-full flex flex-row justify-center'>
<ProjectFlockForm formType='add' /> <ProjectFlockForm formType='add' />
</section> </section>
); );
@@ -44,7 +44,7 @@ export default function AddChickinKandang() {
return ( return (
<> <>
<section className='w-full p-4'> <section className='size-full'>
{isLoading && <span className='loading loading-spinner loading-xl' />} {isLoading && <span className='loading loading-spinner loading-xl' />}
{!isLoading && {!isLoading &&
isResponseSuccess(projectFlockKandang) && isResponseSuccess(projectFlockKandang) &&
@@ -10,7 +10,7 @@ const AddChickin = () => {
return ( return (
<> <>
<section className='w-full p-4'> <section className='w-full'>
<ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} /> <ProjectFlockChickinDetail projectFlockId={Number(projectFlockId)} />
</section> </section>
</> </>
@@ -2,7 +2,7 @@ import ChickinTable from '@/components/pages/production/chickin/ChickinTable';
const Chickin = () => { const Chickin = () => {
return ( return (
<section className='w-full p-4'> <section className='w-full'>
<ChickinTable /> <ChickinTable />
</section> </section>
); );
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
@@ -0,0 +1,63 @@
'use client';
import ProjectFlockClosingForm from '@/components/pages/production/project-flock/closing/ProjectFlockClosingForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockKandangApi } from '@/services/api/production';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
const ProjectFlockClosingPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const projectFlockId = searchParams.get('projectFlockId');
const projectFlockKandangId = searchParams.get('projectFlockKandangId');
const { data: projectFlockKandang, isLoading: isLoadingProjectFlockKandang } =
useSWR(`get-flock-kandang-id/${projectFlockKandangId}`, () =>
ProjectFlockKandangApi.getSingle(parseInt(projectFlockKandangId ?? ''))
);
const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR(
`get-flock-id/${projectFlockId}`,
() => ProjectFlockApi.getSingle(parseInt(projectFlockId ?? ''))
);
if (!projectFlockId || !projectFlockKandangId) {
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 (
!isLoadingProjectFlock &&
(!projectFlock || isResponseError(projectFlock)) &&
!isLoadingProjectFlockKandang &&
(!projectFlockKandang || isResponseError(projectFlockKandang))
) {
router.replace('/404');
return;
}
return (
<div className='w-full h-full flex flex-col justify-center'>
{isLoadingProjectFlock ||
(isLoadingProjectFlockKandang && (
<span className='loading loading-spinner loading-xl' />
))}
{isResponseSuccess(projectFlock) &&
isResponseSuccess(projectFlockKandang) && (
<ProjectFlockClosingForm
projectFlock={projectFlock.data}
projectFlockKandang={projectFlockKandang.data}
/>
)}
</div>
);
};
export default ProjectFlockClosingPage;
@@ -37,7 +37,7 @@ const ProjectFlockEdit = () => {
} }
return ( return (
<div className='w-full p-4 flex flex-col justify-center'> <div className='w-full flex flex-col justify-center'>
{isLoadingProjectFlock && ( {isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
@@ -1,12 +1,13 @@
'use client'; 'use client';
import ProjectFlockDetail from '@/components/pages/production/project-flock/detail/ProjectFlockDetail';
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
const ProjectFlockDetail = () => { const ProjectFlockDetailPage = () => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -37,19 +38,17 @@ const ProjectFlockDetail = () => {
} }
return ( return (
<div className='w-full p-4 flex flex-col justify-center'> <div className='w-full h-full flex flex-col justify-center'>
{isLoadingProjectFlock && ( {isLoadingProjectFlock && (
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
)} )}
{isResponseSuccess(projectFlock) && ( {isResponseSuccess(projectFlock) && (
<ProjectFlockForm <ProjectFlockDetail projectFlock={projectFlock.data} />
formType='detail'
initialValues={projectFlock.data}
refreshProjectFlocks={refreshProjectFlock}
/>
)} )}
</div> </div>
); );
}; };
export default ProjectFlockDetail; export default ProjectFlockDetailPage;
ProjectFlockDetail;
ProjectFlockDetail;
@@ -0,0 +1,60 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import Drawer from '@/components/Drawer';
import React, { ReactNode } from 'react';
import ProjectFlockTable from '@/components/pages/production/project-flock/ProjectFlockTable';
import { useUiStore } from '@/stores/ui/ui.store';
export default function ProjectFlockLayout({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const toggleValidate = useUiStore((s) => s.toggleValidate);
const isAdd = pathname.includes('/add');
const isEdit = pathname.includes('/detail/edit');
const isDetail = pathname.includes('/detail');
const isChickin = pathname.includes('/chickin/add/kandang');
const isClosing = pathname.includes('/closing');
const isOpen = isAdd || isEdit || isDetail || isChickin || isClosing;
const handleBackdropClick = () => {
const unsub = useUiStore.getState().subscribeIsValid((isValid) => {
if (isValid) {
unsub(); // berhenti listen
router.push('/production/project-flock');
}
});
toggleValidate();
};
return (
<>
{/* List page always rendered */}
<div className='min-h-sceen w-full relative'>
<ProjectFlockTable
refresh={() => !isOpen && router.push('/production/project-flock')}
/>
</div>
{/* Render Drawer only on /add */}
<Drawer
open={isOpen}
setOpen={(v) => {
if (!v) router.push('/production/project-flock');
}}
closeOnBackdropClick={isDetail ? true : false}
onBackdropClick={handleBackdropClick}
variant='right'
zIndex='99999'
sidebarContent={isOpen && <div className=''>{children}</div>}
/>
</>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import ProjectFlockTable from '@/components/pages/production/project-flock/Proje
const ProjectFlock = () => { const ProjectFlock = () => {
return ( return (
<section className='w-full p-4'> <section className='size-full p-4'>
<ProjectFlockTable /> <ProjectFlockTable />
</section> </section>
); );
@@ -14,7 +14,7 @@ const RecordingEdit = () => {
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId, recordingId,
(id: number) => RecordingApi.getSingle(id) // Gunakan RecordingApi (id: string) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
+1 -1
View File
@@ -14,7 +14,7 @@ const RecordingDetail = () => {
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId, recordingId,
(id: number) => RecordingApi.getSingle(id) (id: string) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
@@ -1,49 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const AddGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recording_id');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId && recordingId !== 'new' ? [recordingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (
recordingId &&
recordingId !== 'new' &&
!isLoadingRecording &&
(!recording || !isResponseSuccess(recording))
) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{recordingId && recordingId !== 'new' && isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{(!recordingId ||
recordingId === 'new' ||
(!isLoadingRecording && recording && isResponseSuccess(recording))) && (
<GradingForm
type='add'
initialValues={
isResponseSuccess(recording) ? recording.data?.eggs?.[0] : undefined
}
/>
)}
</div>
);
};
export default AddGrading;
@@ -1,53 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const EditGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const gradingId = searchParams.get('gradingId');
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingId ? [recordingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (!recordingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingRecording && (!recording || !isResponseSuccess(recording))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingRecording && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingRecording && recording && isResponseSuccess(recording) && (
<GradingForm
type='edit'
initialValues={recording.data.eggs?.find(
(egg) => egg.id === parseInt(gradingId || '0')
)}
/>
)}
</div>
);
};
export default EditGrading;
@@ -1,52 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import GradingForm from '@/components/pages/production/recording/grading/form/GradingForm';
import { RecordingApi } from '@/services/api/production';
import { isResponseSuccess } from '@/lib/api-helper';
const DetailGrading = () => {
const router = useRouter();
const searchParams = useSearchParams();
const gradingId = searchParams.get('gradingId');
const { data: grading, isLoading: isLoadingGrading } = useSWR(
gradingId ? [gradingId] : null,
([id]) => RecordingApi.getSingle(parseInt(id))
);
if (!gradingId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (!isLoadingGrading && (!grading || !isResponseSuccess(grading))) {
router.replace('/404');
return;
}
return (
<div className='w-full p-4 flex flex-row justify-center'>
{isLoadingGrading && (
<span className='loading loading-spinner loading-xl' />
)}
{!isLoadingGrading && grading && isResponseSuccess(grading) && (
<GradingForm
type='detail'
initialValues={grading.data.eggs?.find(
(egg) => egg.id === parseInt(gradingId)
)}
/>
)}
</div>
);
};
export default DetailGrading;
+1 -1
View File
@@ -4,7 +4,7 @@ import { HTMLAttributes, ReactNode, useState } 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 Collapse from '@/components/Collapse';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
export interface CardProps export interface CardProps
+101 -6
View File
@@ -10,28 +10,102 @@ interface DrawerProps {
open: boolean; open: boolean;
setOpen: (newOpenState: boolean) => void; setOpen: (newOpenState: boolean) => void;
openOnLarge?: boolean; openOnLarge?: boolean;
variant?: 'sidebar' | 'left' | 'right';
zIndex?: string;
className?: DrawerClassName;
onBackdropClick?: () => void;
closeOnBackdropClick?: boolean;
} }
type DrawerClassName = {
drawer?: string;
drawerContent?: string;
drawerSide?: string;
drawerOverlay?: string;
drawerSidebarContent?: string;
};
const Drawer = ({ const Drawer = ({
children, children,
sidebarContent, sidebarContent,
open, open,
setOpen, setOpen,
openOnLarge, openOnLarge,
variant = 'sidebar',
zIndex = '20',
className,
onBackdropClick,
closeOnBackdropClick = true,
}: DrawerProps) => { }: DrawerProps) => {
const getDrawerClassNames = (): DrawerClassName => {
const baseClassNames = {
drawer: 'drawer',
drawerContent: 'drawer-content',
drawerSide: 'drawer-side',
drawerOverlay: 'drawer-overlay',
drawerSidebarContent: 'min-h-full bg-base-100',
};
if (variant === 'sidebar') {
return {
...baseClassNames,
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full max-w-[300px] lg:w-[300px]'
),
};
} else if (variant === 'right') {
return {
...baseClassNames,
drawer: cn(baseClassNames.drawer, 'drawer-end'),
drawerSide: cn(
baseClassNames.drawerSide,
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full min-w-120 sm:w-fit'
),
};
} else if (variant === 'left') {
return {
...baseClassNames,
drawerSide: cn(
baseClassNames.drawerSide,
'border-l border-solid border-gray-200 drawer-side w-screen top-0 right-0 fixed z-21'
),
drawerSidebarContent: cn(
baseClassNames.drawerSidebarContent,
'w-full min-w-120 sm:w-fit'
),
};
}
return baseClassNames; // Fallback for default or unknown variant
};
const varianClassName = getDrawerClassNames();
const toggleDrawer = () => { const toggleDrawer = () => {
setOpen(!open); setOpen(!open);
}; };
const closeDrawer = () => { const closeDrawer = () => {
if (closeOnBackdropClick) {
setOpen(false); setOpen(false);
}
onBackdropClick && onBackdropClick();
}; };
return ( return (
<div <div
className={cn('drawer', { className={cn(
'drawer',
{
'lg:drawer-open': openOnLarge, 'lg:drawer-open': openOnLarge,
})} },
varianClassName?.drawer,
className?.drawer
)}
> >
<input <input
type='checkbox' type='checkbox'
@@ -40,16 +114,37 @@ const Drawer = ({
className='drawer-toggle' className='drawer-toggle'
/> />
<div className='drawer-content'>{children}</div> {/* Drawer Content */}
<div
className={cn(varianClassName?.drawerContent, className?.drawerContent)}
>
{children}
</div>
<div className='drawer-side border-r border-solid border-gray-200 z-20'> {/* Drawer Side */}
<div
className={cn(
varianClassName?.drawerSide,
className?.drawerSide,
zIndex
)}
>
<label <label
aria-label='close sidebar' aria-label='close sidebar'
className='drawer-overlay' className={cn(
varianClassName?.drawerOverlay,
className?.drawerOverlay
)}
onClick={closeDrawer} onClick={closeDrawer}
/> />
<div className='min-h-full w-full max-w-[300px] lg:w-[300px] bg-base-100'> {/* Sidebar Content */}
<div
className={cn(
varianClassName?.drawerSidebarContent,
className?.drawerContent
)}
>
{sidebarContent} {sidebarContent}
</div> </div>
</div> </div>
+141
View File
@@ -0,0 +1,141 @@
'use client';
import Button from '@/components/Button';
import Tooltip from '@/components/Tooltip';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
type FloatingActionsButtonProps = {
actions: {
action: 'DETAIL' | 'EDIT' | 'DELETE';
icon: string;
label?: string;
onClick?: () => void;
hidden?: boolean;
disabled?: boolean;
}[];
approvals: {
action: 'APPROVED' | 'REJECTED';
icon: string;
label?: string;
onClick?: () => void;
disabled?: boolean;
}[];
selectedRowIds: number[];
onClose: () => void;
};
const FloatingActionsButton = ({
actions,
approvals,
selectedRowIds,
onClose,
}: FloatingActionsButtonProps) => {
// Jika tidak ada baris yang dipilih, jangan tampilkan FAB
const positionStyles =
selectedRowIds.length > 0 ? 'bottom-[10%]' : 'bottom-[-100%]';
// Helper untuk menentukan gaya warna tombol approval
const getApprovalColor = (action: 'APPROVED' | 'REJECTED') => {
if (action === 'APPROVED') return 'success';
if (action === 'REJECTED') return 'error';
return 'primary';
};
const getActionColor = (action: 'DETAIL' | 'EDIT' | 'DELETE') => {
if (action === 'DETAIL') return 'white';
if (action === 'EDIT') return 'warning';
if (action === 'DELETE') return 'error';
return 'primary';
};
return (
// Container utama FAB
<div
className={cn(
`absolute ${positionStyles} inset-x-1/2 -translate-x-1/2 z-50`,
'mx-auto w-full max-w-sm sm:mx-0 bg-base-300 p-4 rounded-xl shadow-md transition-all duration-300 transform',
'bg-slate-950 backdrop-blur-md'
)}
>
<div className='flex flex-col gap-3'>
{/* === BARIS ATAS: Status Seleksi dan Actions (Termasuk Close) === */}
<div className='flex justify-between items-center text-white'>
<h4 className='text-base font-semibold'>
{selectedRowIds.length} Selected
</h4>
<div className='flex flex-row gap-1 items-stretch'>
<div className='flex gap-4 items-center'>
{/* Render Aksi dari props.actions */}
{actions
.filter((action) => !action.hidden)
.map((action, index) => {
return (
<Button
key={index}
onClick={action.onClick}
className='text-white hover:text-gray-400 tooltip tooltip-bottom p-0'
variant='link'
disabled={action.disabled}
>
<Tooltip content={action.label || action.action}>
<Icon
icon={action.icon}
width={20}
height={20}
className={`text-${getActionColor(action.action)} font-thin`}
/>
</Tooltip>
</Button>
);
})}
<div className='border-[0.5px] border-white/30 h-full'></div>
{/* Tombol Close */}
<Button
onClick={onClose}
className='text-white hover:text-gray-400 p-0'
variant='link'
>
<Tooltip content='Close'>
<Icon icon='mdi:close' width={20} height={20} />
</Tooltip>
</Button>
</div>
</div>
</div>
{/* === BARIS BAWAH: Approval Buttons (Approve/Reject) === */}
<div className={`grid grid-cols-${approvals.length} gap-3`}>
{approvals.map((approval, index) => (
<Button
key={index}
onClick={approval.onClick}
className={cn(
'btn btn-lg w-full',
'bg-white/20 border-white/30',
'text-white/50 font-semibold flex items-center gap-2 rounded-lg transition-all duration-200',
approval.disabled
? 'cursor-not-allowed'
: 'hover:text-white/100 hover:bg-white/40 hover:border-white/50'
)}
disabled={approval.disabled}
>
<Icon
icon={approval.icon}
width={20}
height={20}
className={`text-${getApprovalColor(approval.action)}`}
/>
{approval.label || approval.action}
</Button>
))}
</div>
</div>
</div>
);
};
export default FloatingActionsButton;
+11 -10
View File
@@ -7,6 +7,7 @@ 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 Dropdown from '@/components/dropdown/Dropdown';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { AuthApi } from '@/services/api/auth'; import { AuthApi } from '@/services/api/auth';
@@ -52,21 +53,21 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<div className='dropdown dropdown-end'> <Dropdown
<div position='bottom-end'
tabIndex={0} trigger={
role='button' <div className='btn btn-ghost btn-circle avatar'>
className='btn btn-ghost btn-circle avatar' <div className='w-10 rounded-full border flex justify-center items-center'>
>
<div className='w-10 rounded-full border grid place-items-center'>
<Icon icon='uil:user' width={40} height={40} /> <Icon icon='uil:user' width={40} height={40} />
</div> </div>
</div> </div>
}
<Menu className='dropdown-content w-52 mt-3 p-2 bg-base-100 shadow rounded-box menu-sm'> contentClassName='w-52 mt-3'
>
<Menu className='p-2 bg-base-100 shadow rounded-box menu-sm'>
<MenuItem title='Logout' onClick={logoutClickHandler} /> <MenuItem title='Logout' onClick={logoutClickHandler} />
</Menu> </Menu>
</div> </Dropdown>
</div> </div>
</div> </div>
); );
+62 -6
View File
@@ -14,6 +14,7 @@ import {
SortingState, SortingState,
OnChangeFn, OnChangeFn,
Row, Row,
HeaderContext,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { rankItem } from '@tanstack/match-sorter-utils'; import { rankItem } from '@tanstack/match-sorter-utils';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -31,6 +32,9 @@ interface TableClassNames {
tableBodyClassName?: string; tableBodyClassName?: string;
bodyRowClassName?: string; bodyRowClassName?: string;
bodyColumnClassName?: string; bodyColumnClassName?: string;
tableFooterClassName?: string;
footerRowClassName?: string;
footerColumnClassName?: string;
paginationClassName?: string; paginationClassName?: string;
} }
@@ -53,6 +57,7 @@ export interface TableProps<TData extends object> {
rowSelection?: Record<string, boolean>; rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>; setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean); enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
renderFooter?: boolean;
withCheckbox?: boolean; withCheckbox?: boolean;
rowOptions?: number[]; rowOptions?: number[];
} }
@@ -67,18 +72,22 @@ const emptyContentDefaultValue = (
</div> </div>
); );
const TABLE_DEFAULT_STYLING = { export const TABLE_DEFAULT_STYLING = {
containerClassName: 'w-full mb-20', containerClassName: 'w-full mb-20',
tableWrapperClassName: tableWrapperClassName:
'overflow-x-auto border border-solid border-base-content/10 rounded-lg', 'overflow-x-auto border border-solid border-base-content/10 rounded-lg',
tableClassName: 'font-inter w-full table-auto text-sm font-medium', tableClassName: 'font-inter w-full table-auto text-sm font-medium',
tableHeaderClassName: '', tableHeaderClassName: '',
headerRowClassName: '', headerRowClassName: '',
headerColumnClassName: 'px-4 py-3 text-base-content/50', headerColumnClassName:
'px-4 py-3 border-base-content/10 text-base-content/50',
tableBodyClassName: '', tableBodyClassName: '',
bodyRowClassName: 'border-t border-t-base-content/10', bodyRowClassName: 'border-t border-base-content/10',
bodyColumnClassName: 'px-4 py-3 text-base-content', bodyColumnClassName: 'px-4 py-3 text-base-content',
paginationClassName: '', paginationClassName: '',
tableFooterClassName: 'font-semibold border-base-content/10',
footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10',
footerColumnClassName: 'p-4 text-base-content whitespace-nowrap',
}; };
const Table = <TData extends object>({ const Table = <TData extends object>({
@@ -100,6 +109,7 @@ const Table = <TData extends object>({
rowSelection, rowSelection,
setRowSelection, setRowSelection,
enableRowSelection, enableRowSelection,
renderFooter = false,
withCheckbox = false, withCheckbox = false,
rowOptions = [10, 20, 50, 100], rowOptions = [10, 20, 50, 100],
}: TableProps<TData>) => { }: TableProps<TData>) => {
@@ -214,10 +224,26 @@ const Table = <TData extends object>({
key={headerGroup.id} key={headerGroup.id}
className={tableClassNames.headerRowClassName} className={tableClassNames.headerRowClassName}
> >
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => {
const columnRelativeDepth =
header.depth - header.column.depth;
if (
!header.isPlaceholder &&
columnRelativeDepth > 1 &&
header.id === header.column.id
) {
return null;
}
let rowSpan = 1;
if (header.isPlaceholder) {
const leafs = header.getLeafHeaders();
rowSpan = leafs[leafs.length - 1].depth - header.depth;
}
return (
<th <th
key={header.id} key={header.id}
colSpan={header.colSpan} colSpan={header.colSpan}
rowSpan={rowSpan}
onClick={header.column.getToggleSortingHandler()} onClick={header.column.getToggleSortingHandler()}
className={cn( className={cn(
header.column.getCanSort() header.column.getCanSort()
@@ -226,10 +252,17 @@ const Table = <TData extends object>({
{ {
'first:w-9 first:pr-0': withCheckbox, 'first:w-9 first:pr-0': withCheckbox,
}, },
{
'border-b': header.colSpan > 1,
},
tableClassNames.headerColumnClassName tableClassNames.headerColumnClassName
)} )}
> >
<div className='flex items-center gap-1'> <div
className={cn('flex items-center gap-1 min-h-full', {
'justify-center': header.colSpan > 1,
})}
>
{flexRender( {flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext() header.getContext()
@@ -265,7 +298,8 @@ const Table = <TData extends object>({
)} )}
</div> </div>
</th> </th>
))} );
})}
</tr> </tr>
))} ))}
</thead> </thead>
@@ -290,6 +324,28 @@ const Table = <TData extends object>({
</tr> </tr>
))} ))}
</tbody> </tbody>
<tfoot className={cn(tableClassNames.tableFooterClassName)}>
{renderFooter && (
<tr className={cn(tableClassNames.footerRowClassName)}>
{table.getAllLeafColumns().map((column) => (
<td
key={column.id}
className={cn(
{ 'first:w-9 first:pr-0': withCheckbox },
tableClassNames.footerColumnClassName
)}
>
{column.columnDef.footer &&
flexRender(column.columnDef.footer, {
column,
header: column.columnDef,
table,
} as HeaderContext<TData, unknown>)}
</td>
))}
</tr>
)}
</tfoot>
</table> </table>
</div> </div>
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { ReactNode, useRef, useEffect, useState } from 'react';
import { cn } from '@/lib/helper';
interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
position?:
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-start'
| 'top-end'
| 'bottom-start'
| 'bottom-end'
| 'left-start'
| 'left-end'
| 'right-start'
| 'right-end';
align?: 'start' | 'center' | 'end';
hover?: boolean;
className?: string;
contentClassName?: string;
}
const Dropdown = ({
trigger,
children,
position = 'bottom',
align = 'start',
hover = false,
className,
contentClassName,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Build position classes
const getPositionClasses = () => {
const classes: string[] = [];
// Handle combined positions like 'top-start'
if (position.includes('-')) {
const [pos, al] = position.split('-');
classes.push(`dropdown-${pos}`);
classes.push(`dropdown-${al}`);
} else {
classes.push(`dropdown-${position}`);
if (align !== 'start') {
classes.push(`dropdown-${align}`);
}
}
return classes.join(' ');
};
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// alert('clicked');
setIsOpen(!isOpen);
};
return (
<div
ref={dropdownRef}
className={cn(
'dropdown',
getPositionClasses(),
hover && 'dropdown-hover',
isOpen && 'dropdown-open',
className
)}
>
{/* Trigger Button */}
<div onClick={handleToggle} className='cursor-pointer'>
{trigger}
</div>
{/* Dropdown Content - Only render when open */}
{isOpen && (
<div
tabIndex={-1}
className={cn('dropdown-content z-[10]', contentClassName)}
onClick={() => setIsOpen(false)} // Close on item click
>
{children}
</div>
)}
</div>
);
};
export default Dropdown;
+45 -22
View File
@@ -1,56 +1,61 @@
'use client'; 'use client';
import { ReactNode, useEffect } from 'react'; import { ReactNode, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import useSWR from 'swr';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general'; import { BaseApiResponse, GetMeResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { redirectToSSO } from '@/lib/auth-helper';
interface RequireAuthProps { interface RequireAuthProps {
children?: ReactNode; children?: ReactNode;
} }
const RequireAuth = ({ children }: RequireAuthProps) => { const RequireAuth = ({ children }: RequireAuthProps) => {
const router = useRouter(); const { user, setUser, setIsLoadingUser } = useAuth();
const { setUser, setIsLoadingUser } = useAuth();
const { const {
data: userResponse, data: userResponse,
isLoading: isLoadingUserResponse, isLoading: isLoadingUserResponse,
error: userErrorResponse, error: userErrorResponse,
} = useSWRImmutable< } = useSWR<
GetMeResponse & { ok?: boolean }, GetMeResponse & { ok?: boolean },
AxiosError<BaseApiResponse>, AxiosError<BaseApiResponse>,
SWRHttpKey SWRHttpKey
>('/sso/userinfo', httpClientFetcher, { >('/sso/userinfo', httpClientFetcher, {
shouldRetryOnError: false, shouldRetryOnError: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 0,
}); });
useEffect(() => {
setIsLoadingUser(isLoadingUserResponse);
}, [isLoadingUserResponse, setIsLoadingUser]);
useEffect(() => { useEffect(() => {
if (isResponseSuccess(userResponse)) { if (isResponseSuccess(userResponse)) {
setUser(userResponse.data); setUser(userResponse.data);
} else if (
isResponseError(userErrorResponse?.response?.data) &&
typeof window !== 'undefined'
) {
router.replace(
`${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`
);
} }
}, [userResponse, userErrorResponse, setIsLoadingUser, setUser]); }, [userResponse, setUser]);
if (isLoadingUserResponse && !userResponse && !userErrorResponse) { // Explicitly handle 401 redirect from the component level
useEffect(() => {
if (
isResponseError(userResponse) &&
userErrorResponse?.response?.status === 401
) {
// Clear cache to prevent stale data from rendering children
// mutate('/sso/userinfo', undefined, { revalidate: false }); // Optional: if using global mutate
setUser(undefined);
redirectToSSO();
}
}, [userErrorResponse, setUser, userResponse]);
useEffect(() => {
setIsLoadingUser(isLoadingUserResponse);
}, [isLoadingUserResponse]);
if (
(isLoadingUserResponse && !userResponse && !userErrorResponse) ||
(!userResponse && !userErrorResponse)
) {
return ( return (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' /> <span className='loading loading-spinner loading-xl' />
@@ -58,7 +63,25 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
); );
} }
return <>{isResponseSuccess(userResponse) && children}</>; if (userErrorResponse) {
return (
<div className='w-full h-screen flex flex-col justify-center items-center gap-4'>
<h2 className='text-2xl font-bold text-error'>Authentication Failed</h2>
<p className='text-gray-600'>
Please try refreshing the page or contact support if the problem
persists.
</p>
<button
className='btn btn-primary'
onClick={() => window.location.reload()}
>
Retry
</button>
</div>
);
}
return <>{isResponseSuccess(userResponse) && user && children}</>;
}; };
export default RequireAuth; export default RequireAuth;
@@ -0,0 +1,104 @@
'use client';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { ReactNode } from 'react';
import { cn } from '@/lib/helper';
export interface DrawerHeaderProps {
// Left side props
leftIcon?: string;
leftIconSize?: number;
leftIconHref?: string;
leftIconOnClick?: () => void;
leftIconClassName?: string;
// Subtitle/label props
subtitle?: string | ReactNode;
subtitleClassName?: string;
// Right side actions (children)
children?: ReactNode;
// Container props
className?: string;
showDivider?: boolean;
}
const DrawerHeader = ({
leftIcon = 'mdi:close',
leftIconSize = 24,
leftIconHref,
leftIconOnClick,
leftIconClassName,
subtitle,
subtitleClassName,
children,
className,
showDivider = true,
}: DrawerHeaderProps) => {
const renderLeftIcon = () => {
const iconElement = (
<Icon
icon={leftIcon}
width={leftIconSize}
height={leftIconSize}
className={cn('cursor-pointer', leftIconClassName)}
/>
);
if (leftIconHref) {
return (
<Link href={leftIconHref} className='hover:text-gray-400'>
{iconElement}
</Link>
);
}
if (leftIconOnClick) {
return (
<button
onClick={leftIconOnClick}
className='hover:text-gray-400 bg-transparent border-none p-0'
>
{iconElement}
</button>
);
}
return iconElement;
};
return (
<div
className={cn(
'flex flex-row justify-between items-center px-4 pt-4',
className
)}
>
{/* Left Side */}
<div className='flex flex-row h-full gap-2 items-center'>
{renderLeftIcon()}
{showDivider && subtitle && (
<div className='divider divider-horizontal p-0 m-0'></div>
)}
{subtitle && (
<div className={cn('text-sm text-neutral', subtitleClassName)}>
{subtitle}
</div>
)}
</div>
{/* Right Side Actions */}
{children && (
<div className='flex flex-row gap-3 justify-end items-center'>
{children}
</div>
)}
</div>
);
};
export default DrawerHeader;
+2 -2
View File
@@ -7,11 +7,11 @@ 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 { 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 { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
export interface DateInputProps { export interface DateInputProps {
label?: string; label?: string;
+138 -39
View File
@@ -1,6 +1,11 @@
'use client'; 'use client';
import { ChangeEventHandler, ReactNode } from 'react'; import {
ChangeEventHandler,
ReactNode,
createContext,
useContext,
} from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
export interface RadioOption { export interface RadioOption {
@@ -8,37 +13,74 @@ export interface RadioOption {
value: string; value: string;
} }
export interface RadioInputProps { // DaisyUI Radio Colors
label?: string; export type RadioColor =
bottomLabel?: string; | 'neutral'
| 'primary'
| 'secondary'
| 'accent'
| 'success'
| 'warning'
| 'info'
| 'error';
// DaisyUI Radio Sizes
export type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
// Context untuk RadioGroup
interface RadioGroupContextValue {
name: string; name: string;
value?: string; value?: string;
options: RadioOption[]; color?: RadioColor;
variant?: string; size?: RadioSize;
className?: {
wrapper?: string;
label?: string;
radioWrapper?: string;
radio?: string;
};
isError?: boolean;
isValid?: boolean;
errorMessage?: string;
required?: boolean;
disabled?: boolean; disabled?: boolean;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void; onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
} }
const RadioInput = ({ const RadioGroupContext = createContext<RadioGroupContextValue | undefined>(
undefined
);
const useRadioGroup = () => {
const context = useContext(RadioGroupContext);
if (!context) {
throw new Error('RadioGroupItem must be used within RadioGroup');
}
return context;
};
// RadioGroup Component
export interface RadioGroupProps {
label?: string;
bottomLabel?: string;
name: string;
value?: string;
options?: RadioOption[];
color?: RadioColor;
size?: RadioSize;
className?: {
wrapper?: string;
label?: string;
radioWrapper?: string;
};
isError?: boolean;
errorMessage?: string;
required?: boolean;
disabled?: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
children?: ReactNode;
}
export const RadioGroup = ({
label, label,
bottomLabel, bottomLabel,
name, name,
value, value,
options, options,
variant = 'radio-primary', color = 'primary',
size = 'md',
className, className,
isError, isError,
errorMessage, errorMessage,
@@ -46,8 +88,20 @@ const RadioInput = ({
disabled = false, disabled = false,
onChange, onChange,
onBlur, onBlur,
}: RadioInputProps) => { children,
}: RadioGroupProps) => {
const contextValue: RadioGroupContextValue = {
name,
value,
color,
size,
disabled,
onChange,
onBlur,
};
return ( return (
<RadioGroupContext.Provider value={contextValue}>
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}> <div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
{/* Label atas */} {/* Label atas */}
{label && ( {label && (
@@ -74,27 +128,18 @@ const RadioInput = ({
className?.radioWrapper className?.radioWrapper
)} )}
> >
{options.map((option) => ( {/* Jika options diberikan, render otomatis */}
<label {options &&
options.map((option) => (
<RadioGroupItem
key={option.value} key={option.value}
className={cn(
'flex flex-row items-center gap-2 cursor-pointer',
disabled && 'opacity-60 cursor-not-allowed'
)}
>
<input
type='radio'
name={name}
value={option.value} value={option.value}
checked={value === option.value} label={option.label}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
className={cn('radio', variant, className?.radio)}
/> />
<span className='text-sm'>{option.label}</span>
</label>
))} ))}
{/* Atau gunakan children untuk custom rendering */}
{children}
</div> </div>
{/* Label bawah */} {/* Label bawah */}
@@ -107,7 +152,61 @@ const RadioInput = ({
<p className='text-sm text-error'>{errorMessage}</p> <p className='text-sm text-error'>{errorMessage}</p>
)} )}
</div> </div>
</RadioGroupContext.Provider>
); );
}; };
export default RadioInput; // RadioGroupItem Component
export interface RadioGroupItemProps {
value: string;
label?: string;
className?: string;
disabled?: boolean;
color?: RadioColor;
size?: RadioSize;
}
export const RadioGroupItem = ({
value,
label,
className,
disabled: itemDisabled,
color: itemColor,
size: itemSize,
}: RadioGroupItemProps) => {
const {
name,
value: groupValue,
color: groupColor,
size: groupSize,
disabled: groupDisabled,
onChange,
onBlur,
} = useRadioGroup();
const isDisabled = itemDisabled ?? groupDisabled;
const radioColor = itemColor ?? groupColor;
const radioSize = itemSize ?? groupSize;
return (
<label
className={cn(
'flex flex-row items-center gap-2 cursor-pointer',
isDisabled && 'opacity-60 cursor-not-allowed',
className
)}
>
<input
type='radio'
name={name}
value={value}
checked={groupValue === value}
onChange={onChange}
onBlur={onBlur}
disabled={isDisabled}
className={cn('radio', `radio-${radioColor}`, `radio-${radioSize}`)}
/>
{label && <span className='text-sm'>{label}</span>}
</label>
);
};
+51 -15
View File
@@ -144,33 +144,45 @@ const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => {
export const formatGroupedApprovalsToApprovalSteps = ( export const formatGroupedApprovalsToApprovalSteps = (
approvalLine: ApprovalLine, approvalLine: ApprovalLine,
groupedApprovals: BaseGroupedApproval[], groupedApprovals: BaseGroupedApproval[] | undefined,
latestApproval: BaseApproval latestApproval: BaseApproval | undefined
): ApprovalStepsProps['approvals'] => { ): ApprovalStepsProps['approvals'] => {
const formattedApprovalSteps: ApprovalStepsProps['approvals'] = const formattedApprovalSteps: ApprovalStepsProps['approvals'] =
approvalLine.map((approvalLineItem) => { approvalLine.map((approvalLineItem) => {
const approvalGroup = groupedApprovals.find( const approvalGroup = groupedApprovals?.find(
(approvalGroupItem) => (approvalGroupItem) =>
approvalGroupItem.step_number === approvalLineItem.step_number approvalGroupItem.step_number === approvalLineItem.step_number
); );
const currentStepNumber = approvalLineItem.step_number; const currentStepNumber = approvalLineItem.step_number;
const lastStepNumber = const lastStepNumber =
groupedApprovals[groupedApprovals.length - 1]?.step_number; groupedApprovals?.[groupedApprovals.length - 1]?.step_number;
const isLatestApprovalRejected = latestApproval.action === 'REJECTED'; const isLatestApprovalRejected = latestApproval?.action === 'REJECTED';
if (!approvalGroup && currentStepNumber <= lastStepNumber) { // Only throw error if we have a valid lastStepNumber to compare against
throw new Error( if (
`Approval dengan ${approvalLineItem.step_name} tidak ditemukan!` !approvalGroup &&
); lastStepNumber !== undefined &&
currentStepNumber <= lastStepNumber
) {
// throw new Error(
// `Approval dengan ${approvalLineItem.step_name} tidak ditemukan!`
// );
} }
if (!approvalGroup) { if (!approvalGroup) {
const isWaiting = currentStepNumber === latestApproval.step_number + 1; // Check if this step is waiting (only if we have latestApproval)
const isWaiting =
latestApproval?.step_number !== undefined &&
currentStepNumber === latestApproval.step_number + 1;
// Check if previous approval was rejected
const isPreviousApprovalRejected = const isPreviousApprovalRejected =
groupedApprovals[groupedApprovals.length - 1].approvals[0].action === groupedApprovals &&
'REJECTED'; groupedApprovals.length > 0 &&
groupedApprovals[groupedApprovals.length - 1]?.approvals?.[0]
?.action === 'REJECTED';
return { return {
name: approvalLineItem.step_name, name: approvalLineItem.step_name,
@@ -184,7 +196,11 @@ export const formatGroupedApprovalsToApprovalSteps = (
let approvalStatus: ApprovalStepStatus = 'IDLE'; let approvalStatus: ApprovalStepStatus = 'IDLE';
if (approvalGroup.step_number <= latestApproval.step_number) { // Only compare if latestApproval and its step_number exist
if (
latestApproval?.step_number !== undefined &&
approvalGroup.step_number <= latestApproval.step_number
) {
if (approvalGroup.approvals) { if (approvalGroup.approvals) {
switch (approvalGroup?.approvals[0]?.action) { switch (approvalGroup?.approvals[0]?.action) {
case 'CREATED': case 'CREATED':
@@ -203,6 +219,7 @@ export const formatGroupedApprovalsToApprovalSteps = (
} }
} }
} else if ( } else if (
latestApproval?.step_number !== undefined &&
approvalGroup.step_number === latestApproval.step_number + 1 && approvalGroup.step_number === latestApproval.step_number + 1 &&
!isLatestApprovalRejected !isLatestApprovalRejected
) { ) {
@@ -353,14 +370,33 @@ const useApprovalSteps = ({
// Formatting Akhir // Formatting Akhir
const approvals = useMemo(() => { const approvals = useMemo(() => {
if (isLoading || !approvalLines.length || !latestApproval) { if (isLoading || !approvalLines.length) {
return []; return [];
} }
// Try to derive latestApproval from groupedApprovals if not provided
let effectiveLatestApproval = latestApproval;
if (!effectiveLatestApproval && groupedApprovals.length > 0) {
// Get all approvals from grouped data
const allApprovals = groupedApprovals.flatMap((group) => group.approvals);
if (allApprovals.length > 0) {
// Use the most recent approval (last in array)
effectiveLatestApproval = allApprovals[allApprovals.length - 1];
}
}
// If still no latestApproval, return empty
if (!effectiveLatestApproval) {
return [];
}
try { try {
return formatGroupedApprovalsToApprovalSteps( return formatGroupedApprovalsToApprovalSteps(
approvalLines, approvalLines,
groupedApprovals, groupedApprovals,
latestApproval effectiveLatestApproval
); );
} catch (error) { } catch (error) {
console.warn('Gagal memformat approval steps:', error); console.warn('Gagal memformat approval steps:', error);
+16 -5
View File
@@ -9,14 +9,25 @@ import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGe
import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent'; import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent';
import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent'; import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent';
import { ClosingGeneralInformation } from '@/types/api/closing'; import {
ClosingGeneralInformation,
BaseClosingSales,
} from '@/types/api/closing';
import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent';
import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent';
import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable';
interface ClosingDetailProps { interface ClosingDetailProps {
id: number; id: number;
initialValue?: ClosingGeneralInformation; initialValue?: ClosingGeneralInformation;
salesData?: BaseClosingSales;
} }
const ClosingDetail: React.FC<ClosingDetailProps> = ({ id, initialValue }) => { const ClosingDetail: React.FC<ClosingDetailProps> = ({
id,
initialValue,
salesData,
}) => {
const [activeTab, setActiveTab] = useState<string>('sapronak'); const [activeTab, setActiveTab] = useState<string>('sapronak');
const closingDetailTabs = useMemo(() => { const closingDetailTabs = useMemo(() => {
@@ -29,17 +40,17 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({ id, initialValue }) => {
{ {
id: 'perhitunganSapronak', id: 'perhitunganSapronak',
label: 'Perhitungan Sapronak', label: 'Perhitungan Sapronak',
content: 'Perhitungan Sapronak', content: <ClosingSapronakCalculationTabContent projectFlockId={id} />,
}, },
{ {
id: 'penjualan', id: 'penjualan',
label: 'Penjualan', label: 'Penjualan',
content: 'Penjualan', content: <SalesReportTable initialValues={salesData} />,
}, },
{ {
id: 'overhead', id: 'overhead',
label: 'Overhead', label: 'Overhead',
content: 'Overhead', content: <ClosingOverheadTabContent projectFlockId={id} />,
}, },
{ {
id: 'hppEkspedisi', id: 'hppEkspedisi',
@@ -0,0 +1,19 @@
import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable';
interface ClosingOverheadTabContentProps {
projectFlockId: number;
}
const ClosingOverheadTabContent = ({
projectFlockId,
}: ClosingOverheadTabContentProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<ClosingOverheadTable projectFlockId={projectFlockId} />
)}
</div>
);
};
export default ClosingOverheadTabContent;
@@ -0,0 +1,162 @@
import Card from '@/components/Card';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing';
import { Overhead, OverheadTotal } from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table';
import { useMemo } from 'react';
import useSWR from 'swr';
interface ClosingOverheadTableProps {
type?: 'detail';
projectFlockId: number;
}
const ClosingOverheadTable = ({
type,
projectFlockId,
}: ClosingOverheadTableProps) => {
const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/overhead`,
() => ClosingApi.getOverhead(projectFlockId),
{
keepPreviousData: true,
}
);
// Helper function to create columns with footer support
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [
// Group untuk kolom tanpa footer
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
{
header: 'Budget Pengajuan',
footer: '',
columns: [
{
id: 'budget_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
footer: total ? () => formatNumber(total.budget_quantity) : '',
},
{
id: 'budget_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.budget_unit_price
? formatCurrency(props.budget_unit_price)
: '-',
footer: '',
},
{
id: 'budget_total_amount',
header: 'Total',
accessorFn: (props) =>
props.budget_total_amount
? formatCurrency(props.budget_total_amount)
: '-',
footer: total ? () => formatCurrency(total.budget_total_amount) : '',
},
],
},
{
header: 'Realisasi',
footer: '',
columns: [
{
id: 'actual_date',
header: 'Tanggal',
accessorFn: (props) =>
props.actual_date
? formatDate(props.actual_date, 'DD MMM, YYYY')
: '-',
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.actual_unit_price
? formatCurrency(props.actual_unit_price)
: '-',
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) =>
props.actual_total_amount
? formatCurrency(props.actual_total_amount)
: '-',
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
},
],
},
{
id: 'cost_per_bird',
header: 'Rp/Ekor',
accessorFn: (props) =>
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
},
];
const columns = useMemo(
() =>
isResponseSuccess(overhead)
? createColumns(overhead.data?.total)
: createColumns(),
[overhead]
);
return (
<>
<Card
title='Pengeluaran Overhead'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<Overhead>
data={
isResponseSuccess(overhead) ? (overhead.data?.overheads ?? []) : []
}
columns={columns}
className={{
containerClassName: 'my-4',
headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'whitespace-nowrap'
),
}}
renderFooter={
isResponseSuccess(overhead)
? overhead.data?.overheads.length > 0
: false
}
/>
</Card>
</>
);
};
export default ClosingOverheadTable;
@@ -0,0 +1,25 @@
'use client';
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable';
interface ClosingSapronakCalculationTabContentProps {
projectFlockId?: number;
}
const ClosingSapronakCalculationTabContent = ({
projectFlockId,
}: ClosingSapronakCalculationTabContentProps) => {
return (
<div className='flex flex-col gap-4'>
{projectFlockId && (
<>
<ClosingSapronakCalculationTable projectFlockId={projectFlockId} />
</>
)}
</div>
);
};
export default ClosingSapronakCalculationTabContent;
@@ -0,0 +1,221 @@
'use client';
import Card from '@/components/Card';
import Table from '@/components/Table';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import {
RowSapronakCalculation,
TotalSapronakCalculation,
} from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table';
import { useMemo } from 'react';
import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper';
interface ClosingSapronakCalculationTableProps {
type?: 'detail';
projectFlockId: number;
}
const ClosingSapronakCalculationTable = ({
type,
projectFlockId,
}: ClosingSapronakCalculationTableProps) => {
const { data: sapronakCalculation, isLoading } = useSWR(
`/closing/sapronak-calculation/${projectFlockId}`,
() => ClosingApi.getPerhitunganSapronak(projectFlockId),
{
keepPreviousData: true,
}
);
// Helper function to create columns with footer support
const createColumns = (
total?: TotalSapronakCalculation
): ColumnDef<RowSapronakCalculation>[] => [
{
header: 'Tanggal',
accessorKey: 'tanggal',
cell: (props) => (props.getValue() as string) || '-',
footer: 'Total',
},
{
header: 'No. Referensi',
accessorKey: 'no_referensi',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'QTY Masuk',
accessorKey: 'qty_masuk',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_masuk)}
</div>
)
: '',
},
{
header: 'QTY Keluar',
accessorKey: 'qty_keluar',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_keluar)}
</div>
)
: '',
},
{
header: 'QTY Pakai',
accessorKey: 'qty_pakai',
cell: (props) => formatNumber(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatNumber(total.qty_pakai)}
</div>
)
: '',
},
{
header: 'Uraian',
accessorKey: 'uraian',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'Kategori Produk',
accessorKey: 'kategori_produk',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
{
header: 'Harga Beli/Qty (Rp)',
accessorKey: 'harga_beli_per_qty',
cell: (props) => formatCurrency(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.harga_beli_per_qty)}
</div>
)
: '',
},
{
header: 'Total Harga (Rp)',
accessorKey: 'total_harga',
cell: (props) => formatCurrency(props.getValue() as number),
footer: total
? () => (
<div className='font-semibold text-gray-900'>
{formatCurrency(total.total_harga)}
</div>
)
: '',
},
{
header: 'Keterangan',
accessorKey: 'keterangan',
cell: (props) => (props.getValue() as string) || '-',
footer: '',
},
];
// Memoize columns untuk setiap kategori
const docBroilerColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.doc_broiler.total)
: createColumns(),
[sapronakCalculation]
);
const ovkColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.ovk.total)
: createColumns(),
[sapronakCalculation]
);
const pakanColumns = useMemo(
() =>
isResponseSuccess(sapronakCalculation)
? createColumns(sapronakCalculation.data?.pakan.total)
: createColumns(),
[sapronakCalculation]
);
return (
<div className='flex flex-col gap-4'>
{isResponseSuccess(sapronakCalculation) && (
<>
<Card
title='DOC Broiler'
collapsible
defaultCollapsed={false}
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.doc_broiler.rows ?? []}
columns={docBroilerColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='OVK'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.ovk.rows ?? []}
columns={ovkColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
<Card
title='Pakan'
variant='bordered'
collapsible
defaultCollapsed={true}
className={{
wrapper: 'w-full',
}}
>
<Table<RowSapronakCalculation>
data={sapronakCalculation.data?.pakan.rows ?? []}
columns={pakanColumns}
className={{
containerClassName: 'my-4',
}}
renderFooter
/>
</Card>
</>
)}
</div>
);
};
export default ClosingSapronakCalculationTable;
@@ -0,0 +1,285 @@
'use client';
import React, { useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import { formatCurrency, formatNumber, formatDate } from '@/lib/helper';
import { BaseClosingSales, BaseSales } from '@/types/api/closing';
import { Product } from '@/types/api/master-data/product';
import { Customer } from '@/types/api/master-data/customer';
import { Kandang } from '@/types/api/master-data/kandang';
interface SalesReportTableProps {
type?: 'detail';
initialValues?: BaseClosingSales;
}
const SalesReportTable = ({
type = 'detail',
initialValues,
}: SalesReportTableProps) => {
const salesData: BaseSales[] = useMemo(() => {
return initialValues?.sales || [];
}, [initialValues]);
const totals = useMemo(() => {
if (salesData.length === 0) {
return {
totalQuantity: 0,
totalWeight: 0,
avgWeight: 0,
avgPricePartner: 0,
totalPartner: 0,
};
}
const totalQuantity = salesData.reduce(
(sum, item) => sum + (item.qty || 0),
0
);
const totalWeight = salesData.reduce(
(sum, item) => sum + (item.weight || 0),
0
);
const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0;
const validPriceItems = salesData.filter(
(item) => item.price != null && item.price > 0
);
const avgPricePartner =
validPriceItems.length > 0
? validPriceItems.reduce((sum, item) => sum + item.price, 0) /
validPriceItems.length
: 0;
const totalPartner = salesData.reduce(
(sum, item) => sum + (item.total_price || 0),
0
);
return {
totalQuantity,
totalWeight,
avgWeight,
avgPricePartner,
totalPartner,
};
}, [salesData]);
const salesColumns: ColumnDef<BaseSales>[] = useMemo(
() => [
{
id: 'realization_date',
accessorKey: 'realization_date',
header: 'Tanggal Realisasi',
cell: (props) => {
const date = props.row.original.realization_date;
return date ? formatDate(date, 'DD MMM YYYY') : '-';
},
footer: () => (
<div className='font-semibold text-gray-900'>Total Penjualan</div>
),
},
{
id: 'age',
accessorKey: 'age',
header: 'Umur',
cell: (props) => props.getValue() || '-',
},
{
id: 'do_number',
accessorKey: 'do_number',
header: 'No. DO',
cell: (props) => props.getValue() || '-',
},
{
id: 'product',
accessorKey: 'product',
header: 'Produk',
cell: (props) => {
const product = props.getValue() as Product;
return product?.name || '-';
},
},
{
id: 'customer',
accessorKey: 'customer',
header: 'Customer',
cell: (props) => {
const customer = props.getValue() as Customer;
return customer?.name || '-';
},
},
{
id: 'jumlah',
header: 'Jumlah',
columns: [
{
id: 'qty',
accessorKey: 'qty',
header: 'Kuantitas',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.totalQuantity)}
</div>
),
},
{
id: 'weight',
accessorKey: 'weight',
header: 'Kg',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.totalWeight)}
</div>
),
},
],
},
{
id: 'avg_weight',
accessorKey: 'avg_weight',
header: 'AVG (Kg)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-left'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-left font-semibold text-gray-900'>
{formatNumber(totals.avgWeight)}
</div>
),
},
{
id: 'price_partner',
accessorKey: 'price',
header: 'Harga Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.avgPricePartner)}
</div>
),
},
{
id: 'total_mitra',
accessorKey: 'total_price',
header: 'Total Mitra (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalPartner)}
</div>
),
},
{
id: 'price_act',
accessorKey: 'price',
header: 'Harga Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'total_act',
accessorKey: 'total_price',
header: 'Total Act (Rp)',
cell: (props) => {
const value = props.getValue() as number;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'kandang',
accessorKey: 'kandang',
header: 'Kandang',
cell: (props) => {
const kandang = props.getValue() as Kandang;
return kandang?.name || '-';
},
},
{
id: 'payment_status',
accessorKey: 'payment_status',
header: 'Status Pembayaran',
cell: (props) => {
const status = props.getValue() as string;
const getStatusColor = (status: string) => {
if (!status) return 'neutral';
switch (status.toLowerCase()) {
case 'paid':
return 'success';
case 'tempo':
return 'warning';
default:
return 'neutral';
}
};
return (
<Badge variant='soft' size='sm' color={getStatusColor(status)}>
{status || '-'}
</Badge>
);
},
},
],
[]
);
return (
<>
<section className='w-full'>
<div className='p-4'>
<h2 className='text-xl font-semibold mb-4'>Penjualan</h2>
<Card
className={{
wrapper: 'w-full bg-base-100',
body: 'p-0',
}}
>
<Table
data={salesData}
columns={salesColumns}
renderFooter={salesData.length > 0}
className={{
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</Card>
</div>
</section>
</>
);
};
export default SalesReportTable;
@@ -207,7 +207,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -238,7 +238,7 @@ const ExpenseRealizationContent = ({
<tr key={pengajuanIdx}> <tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td> <td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td> <td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.total_price)}</td> <td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td> <td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
</tr> </tr>
) )
@@ -269,7 +269,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.total_price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -300,7 +300,7 @@ const ExpenseRealizationContent = ({
<tr key={realisasiIdx}> <tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td> <td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td> <td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.total_price)}</td> <td>{formatCurrency(realisasiItem.price)}</td>
<td className='w-xs'>{realisasiItem.note ?? '-'}</td> <td className='w-xs'>{realisasiItem.note ?? '-'}</td>
</tr> </tr>
) )
@@ -402,7 +402,10 @@ const ExpenseRequestContent = ({
<th>Tanggal Transaksi</th> <th>Tanggal Transaksi</th>
<th>:</th> <th>:</th>
<td> <td>
{formatDate(initialValues?.expense_date, 'DD MMMM YYYY')} {formatDate(
initialValues?.transaction_date,
'DD MMMM YYYY'
)}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -529,7 +532,7 @@ const ExpenseRequestContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.total_price) (item) => (expenseGrandTotal += item.price)
); );
return ( return (
@@ -550,7 +553,7 @@ const ExpenseRequestContent = ({
<tr> <tr>
<th>Nonstock</th> <th>Nonstock</th>
<th>Total Kuantitas</th> <th>Total Kuantitas</th>
<th>Total Biaya</th> <th>Harga Satuan</th>
<th>Catatan</th> <th>Catatan</th>
</tr> </tr>
</thead> </thead>
@@ -560,9 +563,7 @@ const ExpenseRequestContent = ({
<tr key={pengajuanIdx}> <tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td> <td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td> <td>{pengajuanItem.qty}</td>
<td> <td>{formatCurrency(pengajuanItem.price)}</td>
{formatCurrency(pengajuanItem.total_price)}
</td>
<td className='w-xs'> <td className='w-xs'>
{pengajuanItem.note ?? '-'} {pengajuanItem.note ?? '-'}
</td> </td>
@@ -263,11 +263,11 @@ const ExpensesTable = () => {
}, },
}, },
{ {
accessorKey: 'expense_date', accessorKey: 'transaction_date',
header: 'Tanggal Pengajuan', header: 'Tanggal Pengajuan',
cell: (props) => cell: (props) =>
props.row.original.expense_date props.row.original.transaction_date
? formatDate(props.row.original.expense_date, 'DD MMM YYYY') ? formatDate(props.row.original.transaction_date, 'DD MMM YYYY')
: '-', : '-',
}, },
{ {
@@ -27,7 +27,7 @@ type ExpenseRealizationFormSchemaType = {
label: string; label: string;
}; };
quantity?: number; quantity?: number;
total_cost?: number; price?: number;
notes?: string; notes?: string;
}[]; }[];
}[]; }[];
@@ -82,7 +82,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
label: Yup.string().required(), label: Yup.string().required(),
}).required('Nonstock wajib diisi!'), }).required('Nonstock wajib diisi!'),
quantity: Yup.number().required('Total kuantitas wajib diisi!'), quantity: Yup.number().required('Total kuantitas wajib diisi!'),
total_cost: Yup.number().required('Total biaya wajib diisi!'), price: Yup.number().required('Harga satuan wajib diisi!'),
notes: Yup.string(), notes: Yup.string(),
}) })
) )
@@ -155,7 +155,7 @@ export const getExpenseRealizationFormInitialValues = (
label: realisasiItem.nonstock.name, label: realisasiItem.nonstock.name,
}, },
quantity: realisasiItem.qty, quantity: realisasiItem.qty,
total_cost: realisasiItem.total_price, price: realisasiItem.price,
notes: realisasiItem.note, notes: realisasiItem.note,
}; };
}) })
@@ -166,7 +166,7 @@ export const getExpenseRealizationFormInitialValues = (
label: expenseItem.nonstock.name, label: expenseItem.nonstock.name,
}, },
quantity: expenseItem.qty, quantity: expenseItem.qty,
total_cost: expenseItem.total_price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.note,
})) }))
: []; : [];
@@ -98,15 +98,10 @@ const ExpenseRealizationForm = ({
values.realizations.forEach((realization) => { values.realizations.forEach((realization) => {
realization.cost_items.forEach((costItem) => { realization.cost_items.forEach((costItem) => {
const unitPrice =
parseFloat(String(costItem.total_cost)) /
parseFloat(String(costItem.quantity));
const realizationItem = { const realizationItem = {
expense_nonstock_id: costItem.nonstock?.value as number, expense_nonstock_id: costItem.nonstock?.value as number,
qty: parseFloat(String(costItem.quantity)) as number, qty: parseFloat(String(costItem.quantity)) as number,
unit_price: unitPrice, price: parseFloat(String(costItem.price)) as number,
total_price: parseFloat(String(costItem.total_cost)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
}; };
@@ -177,7 +172,7 @@ const ExpenseRealizationForm = ({
{ {
nonstock: undefined, nonstock: undefined,
quantity: undefined, quantity: undefined,
total_cost: undefined, price: undefined,
notes: '', notes: '',
}, },
], ],
@@ -48,7 +48,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
}; };
const isExpenseRepeaterInputError = ( const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'total_cost' | 'notes', column: 'nonstock' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
@@ -112,7 +112,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
<tr> <tr>
<th>Nonstock</th> <th>Nonstock</th>
<th>Total Kuantitas</th> <th>Total Kuantitas</th>
<th>Total Biaya</th> <th>Harga Satuan</th>
<th>Catatan</th> <th>Catatan</th>
</tr> </tr>
</thead> </thead>
@@ -163,17 +163,17 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`} name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Total Biaya' placeholder='Masukkan Harga Satuan'
value={ value={
formik.values.realizations[ formik.values.realizations[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].total_cost ?? '' ].cost_items[expenseIdx].price ?? ''
} }
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError( isError={isExpenseRepeaterInputError(
'total_cost', 'price',
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
@@ -20,7 +20,7 @@ type ExpenseFormSchemaType = {
existing_documents?: { id: number; name: string; url: string }[]; existing_documents?: { id: number; name: string; url: string }[];
deleted_documents?: number[]; deleted_documents?: number[];
documents?: File[]; documents?: File[];
cost_per_kandangs: { expense_nonstocks: {
kandang_id: number; kandang_id: number;
cost_items: { cost_items: {
nonstock?: { nonstock?: {
@@ -28,7 +28,7 @@ type ExpenseFormSchemaType = {
label: string; label: string;
}; };
quantity?: number; quantity?: number;
total_cost?: number; price?: number;
notes?: string; notes?: string;
}[]; }[];
}[]; }[];
@@ -74,7 +74,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
documents: Yup.array().of(Yup.mixed<File>().required()).optional(), documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
cost_per_kandangs: Yup.array() expense_nonstocks: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(), kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(),
@@ -86,7 +86,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
label: Yup.string().required(), label: Yup.string().required(),
}).required('Nonstock wajib diisi!'), }).required('Nonstock wajib diisi!'),
quantity: Yup.number().required('Total kuantitas wajib diisi!'), quantity: Yup.number().required('Total kuantitas wajib diisi!'),
total_cost: Yup.number().required('Total biaya wajib diisi!'), price: Yup.number().required('Harga satuan wajib diisi!'),
notes: Yup.string(), notes: Yup.string(),
}) })
) )
@@ -128,8 +128,8 @@ export const getExpenseFormInitialValues = (
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.kandang_id,
@@ -148,7 +148,7 @@ export const getExpenseFormInitialValues = (
})), })),
deleted_documents: [], deleted_documents: [],
documents: [], documents: [],
cost_per_kandangs: initialValues?.kandangs expense_nonstocks: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => ({ ? initialValues.kandangs.map((kandangExpense) => ({
kandang_id: kandangExpense.kandang_id, kandang_id: kandangExpense.kandang_id,
cost_items: kandangExpense.pengajuans cost_items: kandangExpense.pengajuans
@@ -158,7 +158,7 @@ export const getExpenseFormInitialValues = (
label: expenseItem.nonstock.name, label: expenseItem.nonstock.name,
}, },
quantity: expenseItem.qty, quantity: expenseItem.qty,
total_cost: expenseItem.total_price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.note,
})) }))
: [], : [],
@@ -110,12 +110,12 @@ const ExpenseRequestForm = ({
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
cost_per_kandangs: values.cost_per_kandangs.map((costPerKandang) => ({ expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({
kandang_id: costPerKandang.kandang_id, kandang_id: expenseNonstock.kandang_id,
cost_items: costPerKandang.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
total_cost: parseFloat(String(costItem.total_cost)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
})), })),
@@ -132,13 +132,13 @@ const ExpenseRequestForm = ({
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
cost_per_kandang: values.cost_per_kandangs.map( expense_nonstocks: values.expense_nonstocks.map(
(costPerKandang) => ({ (expenseNonstock) => ({
kandang_id: costPerKandang.kandang_id, kandang_id: expenseNonstock.kandang_id,
cost_items: costPerKandang.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
total_cost: parseFloat(String(costItem.total_cost)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
}) })
@@ -179,53 +179,54 @@ const ExpenseRequestForm = ({
formik.setFieldValue('location', val); formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []); formik.setFieldValue('kandangs', []);
formik.setFieldValue('cost_per_kandangs', []); formik.setFieldValue('expense_nonstocks', []);
}; };
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 newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])];
// add new cost_per_kandangs // add new expense_nonstocks
kandangs.forEach((kandangItem) => { kandangs.forEach((kandangItem) => {
const isKandangExistInCostPerKandangs = newCostPerKandangs.find( const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find(
(costPerKandangItem) => costPerKandangItem.kandang_id === kandangItem.id (expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id
); );
if (isKandangExistInCostPerKandangs) return; if (isKandangExistInExpenseNonstocks) return;
newCostPerKandangs.push({ newExpenseNonstocks.push({
kandang_id: kandangItem.id, kandang_id: kandangItem.id,
cost_items: [ cost_items: [
{ {
nonstock: undefined, nonstock: undefined,
quantity: undefined, quantity: undefined,
total_cost: undefined, price: undefined,
notes: '', notes: '',
}, },
], ],
}); });
}); });
// prune cost_per_kandangs // prune expense_nonstocks
const kandangIds = new Set(kandangs.map((kandang) => kandang.id)); const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedCostPerKandangsIdx: number[] = []; const deletedExpenseNonstocksIdx: number[] = [];
newCostPerKandangs.forEach((costPerKandang, idx) => { newExpenseNonstocks.forEach((expenseNonstock, idx) => {
const isCostPerKandangValid = kandangIds.has(costPerKandang.kandang_id); const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id);
if (!isCostPerKandangValid) { if (!isExpenseNonstockValid) {
deletedCostPerKandangsIdx.push(idx); deletedExpenseNonstocksIdx.push(idx);
} }
}); });
deletedCostPerKandangsIdx.forEach((deletedCostPerKandangIdx) => { deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => {
newCostPerKandangs.splice(deletedCostPerKandangIdx, 1); newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1);
}); });
formik.setFieldValue('cost_per_kandangs', newCostPerKandangs); formik.setFieldValue('expense_nonstocks', newExpenseNonstocks);
}; };
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -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`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true true
); );
formik.setFieldValue( formik.setFieldValue(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${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.expense_nonstocks[kandangExpenseIdx].cost_items,
{ {
nonstock: undefined, nonstock: undefined,
total_cost: undefined, price: undefined,
quantity: undefined, quantity: undefined,
notes: '', notes: '',
}, },
]; ];
formik.setFieldValue( formik.setFieldValue(
`cost_per_kandangs[${kandangExpenseIdx}].cost_items`, `expense_nonstocks[${kandangExpenseIdx}].cost_items`,
newExpensesValue newExpensesValue
); );
}; };
@@ -71,28 +71,28 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
const path = `cost_per_kandangs[${kandangExpenseIdx}].cost_items`; const path = `expense_nonstocks[${kandangExpenseIdx}].cost_items`;
// 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' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
return ( return (
formik.touched.cost_per_kandangs?.[kandangExpenseIdx]?.cost_items?.[ formik.touched.expense_nonstocks?.[kandangExpenseIdx]?.cost_items?.[
expenseIdx expenseIdx
]?.[column] && ]?.[column] &&
Boolean( Boolean(
formik.errors.cost_per_kandangs?.[kandangExpenseIdx] instanceof formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof
Object && Object &&
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
] instanceof Object && ] instanceof Object &&
formik.errors.cost_per_kandangs?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
]?.[column] ]?.[column]
) )
@@ -113,7 +113,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.expense_nonstocks.length === 0 ||
!formik.values.supplier?.value) && ( !formik.values.supplier?.value) && (
<div> <div>
<p className='text-sm text-gray-400 text-center'> <p className='text-sm text-gray-400 text-center'>
@@ -122,9 +122,9 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div> </div>
)} )}
{formik.values.cost_per_kandangs.length > 0 && {formik.values.expense_nonstocks.length > 0 &&
formik.values.supplier?.value && formik.values.supplier?.value &&
formik.values.cost_per_kandangs.map( formik.values.expense_nonstocks.map(
(kandangExpense, kandangExpenseIdx) => { (kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find( const kandangName = formik.values.kandangs?.find(
(kandang) => kandang.id === kandangExpense.kandang_id (kandang) => kandang.id === kandangExpense.kandang_id
@@ -147,7 +147,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<tr> <tr>
<th>Nonstock</th> <th>Nonstock</th>
<th>Total Kuantitas</th> <th>Total Kuantitas</th>
<th>Total Biaya</th> <th>Harga Satuan</th>
<th>Catatan</th> <th>Catatan</th>
{type !== 'detail' && <th>Aksi</th>} {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
@@ -178,10 +178,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
required required
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`} name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas' placeholder='Masukkan Total Kuantitas'
value={ value={
formik.values.cost_per_kandangs[ formik.values.expense_nonstocks[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? '' ].cost_items[expenseIdx].quantity ?? ''
} }
@@ -198,18 +198,17 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<NumberInput <NumberInput
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].total_cost`} name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Total Biaya' placeholder='Masukkan Harga Satuan'
value={ value={
formik.values.cost_per_kandangs[ formik.values.expense_nonstocks[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].total_cost ?? ].cost_items[expenseIdx].price ?? ''
''
} }
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError( isError={isExpenseRepeaterInputError(
'total_cost', 'price',
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
@@ -224,10 +223,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
<td className='p-2'> <td className='p-2'>
<TextInput <TextInput
name={`cost_per_kandangs[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`} name={`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan' placeholder='Tuliskan catatan'
value={ value={
formik.values.cost_per_kandangs[ formik.values.expense_nonstocks[
kandangExpenseIdx kandangExpenseIdx
].cost_items[expenseIdx].notes ?? '' ].cost_items[expenseIdx].notes ?? ''
} }
@@ -224,7 +224,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Vendor', value: expense?.supplier.name }, { label: 'Vendor', value: expense?.supplier.name },
{ {
label: 'Tanggal Transaksi', label: 'Tanggal Transaksi',
value: formatDate(expense?.expense_date, 'DD MMMM YYYY'), value: formatDate(expense?.transaction_date, 'DD MMMM YYYY'),
}, },
{ {
label: 'Tanggal Realisasi', label: 'Tanggal Realisasi',
@@ -326,7 +326,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRequestTotal = 0; let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.total_price) (item) => (expenseRequestTotal += item.price)
); );
return ( return (
@@ -374,7 +374,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
<Text <Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText} style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
> >
Total Biaya Harga Satuan
</Text> </Text>
</View> </View>
<View <View
@@ -424,7 +424,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(pengajuan.total_price)} {formatCurrency(pengajuan.price)}
</Text> </Text>
</View> </View>
<View <View
@@ -484,7 +484,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRealizationTotal = 0; let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.total_price) (item) => (expenseRealizationTotal += item.price)
); );
return ( return (
@@ -532,7 +532,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
<Text <Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText} style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
> >
Total Biaya Harga Satuan
</Text> </Text>
</View> </View>
<View <View
@@ -582,7 +582,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(realisasi.total_price)} {formatCurrency(realisasi.price)}
</Text> </Text>
</View> </View>
<View <View
@@ -6,7 +6,7 @@ import Table from '@/components/Table';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { inventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -41,8 +41,8 @@ const InventoryAdjustmentTable = () => {
// Fetch Data // Fetch Data
const { data: inventoryAdjustments, isLoading } = useSWR( const { data: inventoryAdjustments, isLoading } = useSWR(
`${inventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
inventoryAdjustmentApi.getAllFetcher InventoryAdjustmentApi.getAllFetcher
); );
// State // State
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { inventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
InventoryAdjustment, InventoryAdjustment,
@@ -24,7 +24,7 @@ import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import RadioInput from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
@@ -52,7 +52,7 @@ const InventoryAdjustmentForm = ({
const createInventoryAdjustmentHandler = useCallback( const createInventoryAdjustmentHandler = useCallback(
async (payload: CreateInventoryAdjustmentPayload) => { async (payload: CreateInventoryAdjustmentPayload) => {
const createInventoryAdjustmentRes = const createInventoryAdjustmentRes =
await inventoryAdjustmentApi.create(payload); await InventoryAdjustmentApi.create(payload);
if (isResponseError(createInventoryAdjustmentRes)) { if (isResponseError(createInventoryAdjustmentRes)) {
setInventoryAdjustmentFormErrorMessage( setInventoryAdjustmentFormErrorMessage(
@@ -347,7 +347,7 @@ const InventoryAdjustmentForm = ({
/> />
{/* Radio Button Flag Stock */} {/* Radio Button Flag Stock */}
<RadioInput <RadioGroup
name='transaction_type' name='transaction_type'
label='Tipe Transaksi' label='Tipe Transaksi'
options={[ options={[
@@ -367,7 +367,7 @@ const InventoryAdjustmentForm = ({
Boolean(formik.errors.transaction_type) Boolean(formik.errors.transaction_type)
} }
errorMessage={formik.errors.transaction_type as string} errorMessage={formik.errors.transaction_type as string}
variant='radio-primary' color='primary'
required required
bottomLabel={ bottomLabel={
formik.values.transaction_type == undefined formik.values.transaction_type == undefined
@@ -0,0 +1,233 @@
'use client';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product';
import { Icon } from '@iconify/react';
import {
CellContext,
ColumnDef,
Row,
SortingState,
} from '@tanstack/react-table';
import { ChangeEventHandler, useMemo, useState } from 'react';
import useSWR from 'swr';
const RowOptionsMenu = ({
type = 'dropdown',
props,
}: {
type: 'dropdown' | 'collapse';
props: CellContext<InventoryProduct, unknown>;
}) => (
<RowOptionsMenuWrapper type={type}>
<Button
href={`/inventory/product/detail?inventoryProductId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
</RowOptionsMenuWrapper>
);
const InventoryProductTable = () => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR(
`${InventoryProductApi.basePath}${getTableFilterQueryString()}`,
InventoryProductApi.getAllFetcher
);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
setPageSize(newVal.value as number);
setPage(1);
};
const columns: ColumnDef<InventoryProduct>[] = useMemo(
() => [
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama',
},
{
accessorKey: 'product_price',
header: 'Harga Beli',
cell: (props) => {
return props.row.original.product_price
? formatCurrency(props.row.original.product_price)
: '-';
},
},
{
accessorKey: 'selling_price',
header: 'Harga Jual',
cell: (props) => {
return props.row.original.selling_price
? formatCurrency(props.row.original.selling_price)
: '-';
},
},
{
accessorFn: (row) => row.product_category.name,
header: 'Kategori',
},
{
accessorFn: (row) => row.total_stock,
header: 'Stok',
cell: (props) => {
return props.row.original.total_stock
? formatNumber(props.row.original.total_stock)
: '0';
},
},
{
accessorFn: (row) => row.uom.name,
header: 'Satuan',
},
{
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;
return (
<>
{currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu type='dropdown' props={props} />
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
<RowCollapseOptions>
<RowOptionsMenu type='collapse' props={props} />
</RowCollapseOptions>
)}
</>
);
},
},
],
[]
);
return (
<>
<div className='w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-2'>
<div className='w-full flex flex-row gap-2'></div>
</div>
<div className='flex justify-between items-end gap-4'>
<DebouncedTextInput
name='search'
placeholder='Cari Produk'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper:
'col-span-6 sm:col-span-4 max-w-28 sm:justify-self-end',
}}
/>
</div>
</div>
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts) ? inventoryProducts?.data : []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(inventoryProducts) &&
inventoryProducts?.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>
</>
);
};
export default InventoryProductTable;
@@ -0,0 +1,118 @@
import Card from '@/components/Card';
import { FormHeader } from '@/components/helper/form/FormHeader';
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react';
const InventoryProductDetail = ({
inventoryProduct,
}: {
inventoryProduct?: InventoryProduct;
}) => {
const stockLogs = useMemo(() => {
return (
inventoryProduct?.product_warehouses?.flatMap(
(warehouse) => warehouse.stock_logs || []
) || []
);
}, [inventoryProduct]);
return (
<div className='flex flex-col gap-4 p-4'>
<FormHeader
title='Detail Persediaan Produk'
backUrl='/inventory/product'
/>
<Card
title='Informasi Produk'
variant='bordered'
className={{
wrapper: 'w-full mt-4',
}}
>
<div className='grid grid-cols-2 gap-4'>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td className='font-semibold'>SKU</td>
<td>:</td>
<td>{inventoryProduct?.sku}</td>
</tr>
<tr>
<td className='font-semibold'>Nama Produk</td>
<td>:</td>
<td>{inventoryProduct?.name}</td>
</tr>
<tr>
<td className='font-semibold'>Kategory</td>
<td>:</td>
<td>{inventoryProduct?.product_category.name}</td>
</tr>
<tr>
<td className='font-semibold'>Satuan</td>
<td>:</td>
<td>{inventoryProduct?.uom.name}</td>
</tr>
</tbody>
</table>
</div>
<div className='overflow-x-auto rounded-box border border-base-content/5 bg-base-100 mt-3'>
<table className='table'>
<tbody>
<tr>
<td className='font-semibold'>Harga Jual</td>
<td>:</td>
<td>
{inventoryProduct?.selling_price
? formatCurrency(inventoryProduct.selling_price)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Harga Beli</td>
<td>:</td>
<td>
{inventoryProduct?.product_price
? formatCurrency(inventoryProduct?.product_price)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Pajak</td>
<td>:</td>
<td>
{inventoryProduct?.tax
? formatCurrency(inventoryProduct?.tax)
: '-'}
</td>
</tr>
<tr>
<td className='font-semibold'>Total Stok</td>
<td>:</td>
<td>
{inventoryProduct?.total_stock
? formatNumber(inventoryProduct?.total_stock)
: '0'}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</Card>
<StockProductWarehouseTable
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
/>
<StockLogTable stockLogs={stockLogs} />
</div>
);
};
export default InventoryProductDetail;
@@ -0,0 +1,81 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLog } from '@/types/api/inventory/product';
const StockLogTable = ({ stockLogs }: { stockLogs: StockLog[] }) => {
return (
<Card
title='Informasi Stock Produk'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<StockLog>
data={stockLogs}
columns={[
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
]}
className={{
containerClassName: 'mt-6',
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',
}}
/>
</Card>
);
};
export default StockLogTable;
@@ -0,0 +1,65 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper';
import {
InventoryProduct,
ProductWarehouseStock,
} from '@/types/api/inventory/product';
const StockProductWarehouseTable = ({
productWarehouseStock,
}: {
productWarehouseStock?: ProductWarehouseStock[];
}) => {
return (
<Card
title='Informasi Gudang'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<ProductWarehouseStock>
data={productWarehouseStock ?? []}
columns={[
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
]}
className={{
containerClassName: 'mt-6',
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',
}}
/>
</Card>
);
};
export default StockProductWarehouseTable;
@@ -6,7 +6,7 @@ import {
import { import {
DeliveryOrderProductFormValues, DeliveryOrderProductFormValues,
DeliveryOrderProductSchema, DeliveryOrderProductSchema,
} from './repeater/delivery-order/DeliverOrderProduct.schema'; } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
type MarketingSchemaType = { type MarketingSchemaType = {
customer_id: number | undefined; customer_id: number | undefined;
@@ -8,7 +8,6 @@ import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import { formatCurrency, formatDate } from '@/lib/helper'; import { formatCurrency, formatDate } from '@/lib/helper';
import { import {
@@ -31,23 +30,23 @@ import {
DeliveryOrderSchema, DeliveryOrderSchema,
SalesOrderFormValues, SalesOrderFormValues,
SalesOrderSchema, SalesOrderSchema,
} from './MarketingForm.schema'; } from '@/components/pages/marketing/form/MarketingForm.schema';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { import {
DeliveryOrderApi, DeliveryOrderApi,
MarketingApi, MarketingApi,
SalesOrderApi, SalesOrderApi,
} from '@/services/api/marketing/marketing'; } from '@/services/api/marketing/marketing';
import { SalesOrderProductFormValues } from './repeater/sales-order/SalesOrderProduct.schema';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import SalesOrderProductTable from './table-view/SalesOrderProductTable';
import SalesOrderProductForm from './repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from './table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from './repeater/delivery-order/DeliverOrderProduct';
import { DeliveryOrderProductFormValues } from './repeater/delivery-order/DeliverOrderProduct.schema';
import DebouncedTextArea from '@/components/input/DebouncedTextArea'; import DebouncedTextArea from '@/components/input/DebouncedTextArea';
import SalesOrderProductTable from '@/components/pages/marketing/form/table-view/SalesOrderProductTable';
import SalesOrderProductForm from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProductForm';
import DeliveryOrderProductTable from '@/components/pages/marketing/form/table-view/DeliveryOrderProductTable';
import DeliveryOrderProductForm from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct';
import { SalesOrderProductFormValues } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -156,8 +155,6 @@ export const recalculate = (
field: string, field: string,
values: ProductCalculationFields values: ProductCalculationFields
) => { ) => {
console.log('Values');
console.log(values);
const { qty, unit_price, total_price, avg_weight, total_weight } = values; const { qty, unit_price, total_price, avg_weight, total_weight } = values;
const result: Partial<ProductCalculationFields> = {}; const result: Partial<ProductCalculationFields> = {};
if (field == 'unit_price' || field == 'total_price' || field == 'qty') { if (field == 'unit_price' || field == 'total_price' || field == 'qty') {
@@ -174,8 +171,6 @@ export const recalculate = (
result.avg_weight = Number(total_weight) / Number(qty); result.avg_weight = Number(total_weight) / Number(qty);
} }
} }
console.log('Result');
console.log(result);
return result; return result;
}; };
export const getSubmitField = (values: ProductCalculationFields) => { export const getSubmitField = (values: ProductCalculationFields) => {
@@ -327,8 +322,6 @@ const MarketingForm = ({
}) })
.filter((item) => Boolean(item)), .filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload); } as UpdateDeliveryOrderPayload);
console.log('PAYLOAD');
console.log(payload);
switch (formType) { switch (formType) {
case 'add': case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload); await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -352,7 +345,6 @@ const MarketingForm = ({
// ================== FORM REPEATER HANDLER ================== // ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => { const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(values);
const createMarketingRes = await SalesOrderApi.create(values); const createMarketingRes = await SalesOrderApi.create(values);
if (isResponseSuccess(createMarketingRes)) { if (isResponseSuccess(createMarketingRes)) {
toast.success(createMarketingRes?.message as string); toast.success(createMarketingRes?.message as string);
@@ -365,7 +357,6 @@ const MarketingForm = ({
}; };
const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => { const updateMarketingHandler = async (values: UpdateSalesOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(values);
const updateMarketingRes = await SalesOrderApi.update( const updateMarketingRes = await SalesOrderApi.update(
initialValues?.id as number, initialValues?.id as number,
values values
@@ -381,10 +372,8 @@ const MarketingForm = ({
}; };
const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => { const createDeliveryHandler = async (values: CreateDeliveryOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(initialValues?.id);
const createDeliveryRes = await DeliveryOrderApi.create(values); const createDeliveryRes = await DeliveryOrderApi.create(values);
if (isResponseSuccess(createDeliveryRes)) { if (isResponseSuccess(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.success(createDeliveryRes?.message as string); toast.success(createDeliveryRes?.message as string);
setDeliveryOrderValues( setDeliveryOrderValues(
createDeliveryRes.data?.delivery_order?.flatMap((delivery) => createDeliveryRes.data?.delivery_order?.flatMap((delivery) =>
@@ -397,20 +386,17 @@ const MarketingForm = ({
router.push(`/marketing/detail?marketingId=${initialValues?.id}`); router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
} }
if (isResponseError(createDeliveryRes)) { if (isResponseError(createDeliveryRes)) {
console.log(createDeliveryRes);
toast.error(createDeliveryRes?.message as string); toast.error(createDeliveryRes?.message as string);
} }
setIsLoading(false); setIsLoading(false);
}; };
const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => { const updateDeliveryHandler = async (values: UpdateDeliveryOrderPayload) => {
setIsLoading(true); setIsLoading(true);
console.log(initialValues?.id);
const updateDeliveryRes = await DeliveryOrderApi.update( const updateDeliveryRes = await DeliveryOrderApi.update(
initialValues?.id as number, initialValues?.id as number,
values values
); );
if (isResponseSuccess(updateDeliveryRes)) { if (isResponseSuccess(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.success(updateDeliveryRes?.message as string); toast.success(updateDeliveryRes?.message as string);
setDeliveryOrderValues( setDeliveryOrderValues(
mergeSOwithDO( mergeSOwithDO(
@@ -426,7 +412,6 @@ const MarketingForm = ({
router.push(`/marketing/detail?marketingId=${initialValues?.id}`); router.push(`/marketing/detail?marketingId=${initialValues?.id}`);
} }
if (isResponseError(updateDeliveryRes)) { if (isResponseError(updateDeliveryRes)) {
console.log(updateDeliveryRes);
toast.error(updateDeliveryRes?.message as string); toast.error(updateDeliveryRes?.message as string);
} }
setIsLoading(false); setIsLoading(false);
@@ -435,16 +420,13 @@ const MarketingForm = ({
// ================== MARKETING HANDLER ================== // ================== MARKETING HANDLER ==================
const deleteMarketingHandler = async () => { const deleteMarketingHandler = async () => {
setIsLoading(true); setIsLoading(true);
console.log(initialValues?.id);
const deleteMarketingRes = await MarketingApi.delete( const deleteMarketingRes = await MarketingApi.delete(
initialValues?.id as number initialValues?.id as number
); );
if (isResponseSuccess(deleteMarketingRes)) { if (isResponseSuccess(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.success(deleteMarketingRes?.message as string); toast.success(deleteMarketingRes?.message as string);
} }
if (isResponseError(deleteMarketingRes)) { if (isResponseError(deleteMarketingRes)) {
console.log(deleteMarketingRes);
toast.error(deleteMarketingRes?.message as string); toast.error(deleteMarketingRes?.message as string);
} }
setIsLoading(false); setIsLoading(false);
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { import {
DeliveryOrderProductFormValues, DeliveryOrderProductFormValues,
DeliveryOrderProductSchema, DeliveryOrderProductSchema,
} from './DeliverOrderProduct.schema'; } from '@/components/pages/marketing/form/repeater/delivery-order/DeliverOrderProduct.schema';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import Button from '@/components/Button'; import Button from '@/components/Button';
@@ -3,10 +3,10 @@ import { BaseDeliveryOrder, Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer'; import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper'; import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path'; import { format } from 'path';
import { date } from 'yup'; import { date } from 'yup';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
interface DeliveryOrderExportProps { interface DeliveryOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -3,8 +3,8 @@ import { Marketing } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer'; import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import pdfStyles from './styles/MarketingPDFStyles';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
interface SalesOrderExportProps { interface SalesOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -306,7 +306,6 @@ const SupplierForm = ({
label='Hatchery' label='Hatchery'
value={hatcheryOptionsValues} value={hatcheryOptionsValues}
onChange={(val) => { onChange={(val) => {
console.log(val); // pastikan val = array of { value, label }
setHatcheryOptionValues(val as OptionType[]); setHatcheryOptionValues(val as OptionType[]);
}} }}
isError={ isError={
@@ -7,13 +7,16 @@ import { formatNumber } from '@/lib/helper';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import ChickinFormView from './tabs/ChickinFormView';
import ChickinLogsView from './tabs/ChickLogsView';
import { useState } from 'react'; import { useState } from 'react';
import ApprovalSteps, { import ApprovalSteps, {
useApprovalSteps, useApprovalSteps,
} from '@/components/pages/ApprovalSteps'; } from '@/components/pages/ApprovalSteps';
import { PROJECT_FLOCK_KANDANG_APPROVAL_LINE } from '@/config/approval-line'; import ChickinFormView from '@/components/pages/production/chickin/form/tabs/ChickinFormView';
import ChickinLogsView from '@/components/pages/production/chickin/form/tabs/ChickLogsView';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import { Icon } from '@iconify/react';
import Badge from '@/components/Badge';
import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line';
const ChickinFormKandang = ({ const ChickinFormKandang = ({
formType = 'add', formType = 'add',
initialValues, initialValues,
@@ -23,7 +26,7 @@ const ChickinFormKandang = ({
initialValues: ProjectFlockKandang; initialValues: ProjectFlockKandang;
afterSubmit?: () => void; afterSubmit?: () => void;
}) => { }) => {
const [activeTabId, setActiveTabId] = useState<string>('formChickIn'); const [openChickin, setOpenChickin] = useState<boolean>(false);
const { const {
approvals, approvals,
@@ -31,114 +34,154 @@ const ChickinFormKandang = ({
refresh: refreshApprovals, refresh: refreshApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.approval, latestApproval: initialValues?.approval,
approvalLines: PROJECT_FLOCK_KANDANG_APPROVAL_LINE, approvalLines: CHICKINS_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCK_KANDANGS', moduleName: 'CHICKINS',
moduleId: initialValues?.id.toString() ?? '', moduleId: initialValues?.id.toString() ?? '',
}); });
const afterSubmitFormChickin = () => { const afterSubmitFormChickin = () => {
setActiveTabId('logsChickIn'); setOpenChickin(true);
afterSubmit && afterSubmit(); afterSubmit && afterSubmit();
refreshApprovals(); refreshApprovals();
}; };
return ( return (
<div className='flex flex-col gap-4'> <>
<FormHeader <DrawerHeader
title={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`} subtitle={`Chick In ${initialValues.kandang?.name ?? 'Kandang'}`}
backUrl={`/production/project-flock/chickin/add?projectFlockId=${initialValues?.project_flock?.id}`} leftIcon='mdi:arrow-left'
leftIconHref={`/production/project-flock/detail?projectFlockId=${initialValues?.project_flock?.id}`}
/> />
{/* Informasi Kandang */}
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Kandang</h2>
{approvals && !approvalsLoading && ( {approvals && !approvalsLoading && (
<div className='mb-3 text-sm'>
<ApprovalSteps approvals={approvals} /> <ApprovalSteps approvals={approvals} />
</div>
)} )}
<Card {/* Badge Row */}
title='Informasi Kandang' <div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='success'
className={{ className={{
wrapper: 'w-full bg-white mt-4', badge: 'rounded-lg px-2',
}} }}
> >
<Table<Kandang> <Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
emptyContent={ Aktif
<div className='w-full p-5 text-center'> </Badge>
<span className='text-lg opacity-50'> <div className='divider divider-horizontal p-0 m-0'></div>
Informasi Kandang belum tersedia... <Badge
</span> color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:home' width={12} height={12} />
{` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`}
</Badge>
</div> </div>
}
data={[initialValues?.kandang]} {/* Information Grid */}
columns={[ <div className='grid grid-cols-3 gap-4'>
{ {/* Area */}
header: 'Area', <div
accessorFn: () => initialValues?.project_flock?.area.name || '-', className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
}, relative
{ before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
header: 'Lokasi', >
accessorFn: () => <Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
initialValues?.project_flock?.location.name || '-', </div>
}, <div className='col-span-2'>
{ {initialValues.project_flock.area.name}
header: 'Flock', </div>
accessorFn: () => initialValues?.project_flock?.flock_name || '-',
}, {/* Lokasi */}
{ <div
header: 'Kandang', className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
accessorFn: (row) => row?.name || '-', relative
}, before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
{ >
header: 'Kapasitas', <Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
accessorFn: (row) => </div>
(row?.capacity && formatNumber(row?.capacity)) || '-', <div className='col-span-2'>
}, {initialValues.project_flock?.location.name}
{ </div>
header: 'Penanggung Jawab',
accessorFn: (row) => row?.pic?.name || '-', {/* Kandang */}
}, <div
]} className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
</div>
<div className='col-span-2'>{initialValues.kandang.name}</div>
{/* Jumlah DOC */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah DOC
</div>
<div className='col-span-2'>
{formatNumber(
initialValues.chickins?.reduce(
(total, chickin) => total + chickin.usage_qty,
0
) ?? 0
)}{' '}
Ekor
</div>
</div>
</div>
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-xl font-semibold'>Informasi Chick In</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{ className={{
tableWrapperClassName: 'overflow-x-auto min-h-full!', badge: 'rounded-lg px-2',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}} }}
>
<Icon icon='mdi:circle' width={12} height={12} color={'success'} />{' '}
Perlu Chick In ({initialValues.available_qtys?.length ?? 0})
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => setOpenChickin(!openChickin)}
>
{`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`}
<Icon
icon={`mdi:${openChickin ? 'eye' : 'eye-off'}`}
width={12}
height={12}
/> />
</Card> </Badge>
<Tabs </div>
className='bg-white p-2' </div>
onTabChange={setActiveTabId} {openChickin && (
activeTabId={activeTabId} <ChickinLogsView
tabs={[ initialValues={initialValues}
{ afterSubmit={afterSubmitFormChickin}
id: 'formChickIn', />
label: 'Tambah Chick In', )}
content: (
<ChickinFormView <ChickinFormView
initialValues={initialValues} initialValues={initialValues}
formType={formType} formType={formType}
afterSubmit={afterSubmitFormChickin} afterSubmit={afterSubmitFormChickin}
/> />
), </>
},
{
content: (
<ChickinLogsView
initialValues={initialValues}
afterSubmit={afterSubmitFormChickin}
/>
),
id: 'logsChickIn',
label: 'Riwayat Chick In',
},
]}
variant='lifted'
/>
</div>
); );
}; };
@@ -2,17 +2,12 @@ import Alert from '@/components/Alert';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin'; import { ChickinApi } from '@/services/api/production/chickin';
import { import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
Chickin,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useState } from 'react'; import { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -54,105 +49,120 @@ const ChickinLogsView = ({
return ( return (
<> <>
<div className='px-4 pb-4 flex flex-col gap-4'>
{/* Card List Chickin Logs */}
{(initialValues?.chickins || []).length === 0 ? (
<div className='w-full p-8 text-center'>
<span className='text-lg opacity-50'>
Belum ada riwayat Chick In...
</span>
</div>
) : (
(initialValues?.chickins || []).map((chickin, index) => {
const isApproved = chickin.usage_qty !== 0;
const isPending = chickin.pending_usage_qty !== 0;
const quantity = isApproved
? chickin.usage_qty
: isPending
? chickin.pending_usage_qty
: 0;
return (
<Card <Card
title='Riwayat Chick In' key={chickin.id || index}
variant='bordered'
className={{ className={{
wrapper: 'w-full bg-white', wrapper: 'w-full',
body: 'p-3',
}} }}
> >
<div className='flex flex-row justify-start gap-3 mt-3'> <div className='flex flex-col gap-4'>
{initialValues?.approval?.step_number == 1 && ( {/* Header with Status Badge */}
<Button <div className='flex flex-row justify-between items-center'>
color='success' <div className='text-lg font-semibold'>
variant='outline' Chick In #{index + 1}
onClick={handleClickApprove}
>
<Icon width={24} height={24} icon='material-symbols:check' />
Approve
</Button>
)}
</div> </div>
<Table<Chickin>
data={initialValues?.chickins || []}
columns={[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorFn: (row) => row.chick_in_date,
header: 'Tanggal Chick In',
cell: (props) => {
return formatDate(props.getValue() as string, 'DD MMM YYYY');
},
},
{
accessorFn: (row) => row.product_warehouse?.warehouse?.name,
header: 'Kandang',
},
{
accessorFn: (row) => row.product_warehouse?.product?.name,
header: 'Produk',
},
{
accessorFn: (row) => row.usage_qty ?? row.pending_usage_qty,
header: 'Jumlah Chick In',
cell: (props) => {
if (props.row.original.usage_qty != 0) {
return formatNumber(props.row.original.usage_qty);
} else if (props.row.original.pending_usage_qty != 0) {
return formatNumber(props.row.original.pending_usage_qty);
} else {
return '-';
}
},
},
{
accessorFn: (row) => row.pending_usage_qty,
header: 'Status',
cell: (props) => {
return (
<PillBadge <PillBadge
content={ content={
props.row.original.usage_qty !== 0 isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
? 'Disetujui'
: props.row.original.pending_usage_qty !== 0
? 'Pending'
: '-'
} }
color={ color={
props.row.original.usage_qty !== 0 isApproved ? 'green' : isPending ? 'yellow' : 'gray'
? 'green'
: props.row.original.pending_usage_qty !== 0
? 'yellow'
: 'gray'
} }
/> />
</div>
{/* Tanggal Chick In */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:calendar'} width={14} height={14} />{' '}
<span>Tanggal Chick In</span>
</div>
<div className='text-end text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</div>
</div>
{/* Kandang */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:home'} width={14} height={14} />{' '}
<span>Kandang</span>
</div>
<div className='text-end text-gray-500'>
{chickin.product_warehouse?.warehouse?.name || '-'}
</div>
</div>
{/* Produk */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon
icon={'mdi:package-variant'}
width={14}
height={14}
/>{' '}
<span>Produk</span>
</div>
<div className='text-end text-gray-500'>
{chickin.product_warehouse?.product?.name || '-'}
</div>
</div>
{/* Jumlah Chick In */}
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:counter'} width={14} height={14} />{' '}
<span>Jumlah Chick In</span>
</div>
<div className='text-end text-gray-500 font-semibold'>
{quantity > 0 ? `${formatNumber(quantity)} Ekor` : '-'}
</div>
</div>
</div>
</Card>
); );
}, })
}, )}
]}
className={{ {initialValues?.approval?.step_number <= 2 && (
containerClassName: cn({ <Button
'mb-20': initialValues?.chickins?.length === 0, color='success'
}), onClick={handleClickApprove}
tableWrapperClassName: 'overflow-x-auto min-h-full!', className='w-full'
tableClassName: 'font-inter w-full table-auto min-h-full!', >
headerRowClassName: 'border-b border-b-gray-200', <Icon width={24} height={24} icon='material-symbols:check' />
headerColumnClassName: Approve Semua Chick In
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', </Button>
bodyRowClassName: 'border-b border-b-gray-200', )}
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{chickinErrorMessage && ( {chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}> <div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert> <Alert color='error'>{chickinErrorMessage}</Alert>
</div> </div>
)} )}
</Card> </div>
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={confirmModal.ref} ref={confirmModal.ref}
type='success' type='success'
@@ -20,6 +20,7 @@ import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandan
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { Icon } from '@iconify/react';
const ChickinFormView = ({ const ChickinFormView = ({
initialValues, initialValues,
@@ -118,19 +119,58 @@ const ChickinFormView = ({
return ( return (
<form <form
className='flex flex-col gap-4' className='flex flex-col gap-4 p-4'
onReset={() => { onReset={() => {
handleReset(); handleReset();
}} }}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
> >
{(formik.values.chickin_requests || []).map((chickinRequest, index) => {
const availableQty = initialValues?.available_qtys?.find(
(availableQty) =>
availableQty.product_warehouse.id ===
chickinRequest.product_warehouse_id
);
return (
<Card <Card
title='Informasi Chick In DOC' key={index}
// title={`${formatNumber(availableQty?.available_qty ?? 0)} Ekor - ${availableQty?.product_warehouse?.product?.name}`}
variant='bordered'
size='sm'
className={{ className={{
wrapper: 'w-full bg-white', wrapper: 'w-full',
body: 'p-3',
}} }}
> >
<Table<ChickinRequestFormValues> <div className='flex flex-row justify-between items-center'>
<div className='text-lg font-semibold'>
{formatNumber(availableQty?.available_qty ?? 0)} Ekor -{' '}
{availableQty?.product_warehouse?.product?.name}
</div>
{chickinRequest.chick_in_date && (
<Icon
icon='mdi:check-circle-outline'
color='success'
className='text-success'
width={20}
height={20}
/>
)}
</div>
<DateInput
className={{
wrapper: 'w-full',
inputWrapper: 'bg-white',
}}
label='Tanggal Chick In'
name={`chickin_requests[${index}].chick_in_date`}
value={chickinRequest.chick_in_date}
onChange={formik.handleChange}
/>
</Card>
);
})}
{/* <Table<ChickinRequestFormValues>
data={formik.values.chickin_requests || []} data={formik.values.chickin_requests || []}
columns={[ columns={[
{ {
@@ -204,20 +244,17 @@ const ChickinFormView = ({
</span> </span>
</div> </div>
} }
/> /> */}
</Card> {formik.values.chickin_requests?.length > 0 && (
<div className='flex flex-row justify-center gap-3'>
<Button type='reset' color='warning' disabled={formik.isSubmitting}>
Reset
</Button>
<Button <Button
type='submit' type='submit'
color='primary' color='primary'
disabled={!formik.isValid || formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting}
> >
Submit <Icon icon='mdi:checkbox-marked-outline' width={24} height={24} />
Chick In
</Button> </Button>
</div> )}
{chickinErrorMessage && ( {chickinErrorMessage && (
<div className='w-full' onClick={() => setChickinErrorMessage('')}> <div className='w-full' onClick={() => setChickinErrorMessage('')}>
<Alert color='error'>{chickinErrorMessage}</Alert> <Alert color='error'>{chickinErrorMessage}</Alert>
@@ -1,6 +1,8 @@
'use client'; 'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button'; import Button from '@/components/Button';
import FloatingActionsButton from '@/components/FloatingActionsButton';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
@@ -8,23 +10,18 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data'; import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { import { ProjectFlock } from '@/types/api/production/project-flock';
ProjectFlockApprovalPayload,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, SortingState } from '@tanstack/react-table'; import { CellContext, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useState } from 'react'; import { useRouter } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -98,7 +95,7 @@ const RowOptionsMenu = ({
); );
}; };
const ProjectFlockTable = () => { const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -123,8 +120,9 @@ const ProjectFlockTable = () => {
periodFilter: 'period', periodFilter: 'period',
}, },
}); });
const router = useRouter();
// State // ===== State =====
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection) const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id]) .filter((id) => rowSelection[id])
@@ -151,14 +149,15 @@ const ProjectFlockTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
// Fetch Data // ===== Fetch Data =====
const { const {
data: projectFlocks, data: projectFlocks,
isLoading, isLoading,
mutate: refreshProjectFlocks, mutate: refreshProjectFlocks,
} = useSWR( } = useSWR(
`${ProjectFlockApi.basePath}${getTableFilterQueryString()}`, `${ProjectFlockApi.basePath}${getTableFilterQueryString()}`,
ProjectFlockApi.getAllFetcher ProjectFlockApi.getAllFetcher,
{ revalidateOnMount: true }
); );
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({
@@ -191,7 +190,7 @@ const ProjectFlockTable = () => {
KandangApi.getAllFetcher KandangApi.getAllFetcher
); );
// Data to Options Mapping // ===== Data to Options Mapping ======
const optionsArea = isResponseSuccess(areas) const optionsArea = isResponseSuccess(areas)
? areas?.data.map((area) => ({ ? areas?.data.map((area) => ({
value: area.id, value: area.id,
@@ -211,7 +210,7 @@ const ProjectFlockTable = () => {
})) }))
: []; : [];
// Handler // ====== HANDLER ======
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType; const newVal = val as OptionType;
setPageSize(newVal.value as number); setPageSize(newVal.value as number);
@@ -219,17 +218,17 @@ const ProjectFlockTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await ProjectFlockApi.delete(selectedProjectFlock?.id as number); await ProjectFlockApi.delete(selectedSingleRow?.id as number);
refreshProjectFlocks(); refreshProjectFlocks();
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Project Flock!'); toast.success('Successfully delete Project Flock!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
setRowSelection({});
}; };
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
const confirmApprovalHandler = async ( const confirmApprovalHandler = async (
notes: string, notes: string,
approvalAction: 'APPROVED' | 'REJECTED' approvalAction: 'APPROVED' | 'REJECTED'
@@ -259,22 +258,44 @@ const ProjectFlockTable = () => {
setIsApproveLoading(false); setIsApproveLoading(false);
}; };
// ====== EFFECT ======
useEffect(() => {
refreshProjectFlocks();
}, [refresh]);
// ====== MEMO ======
const selectedSingleRow: ProjectFlock | null | undefined = useMemo(() => {
return selectedRowIds.length === 1
? isResponseSuccess(projectFlocks)
? projectFlocks?.data.find((row) => row.id === selectedRowIds[0])
: null
: null;
}, [rowSelection]);
const canApprove = useMemo(() => {
if (!selectedSingleRow || isApproveLoading) return false;
const isPengajuan = selectedSingleRow.approval.step_number == 1;
const isNotRejected = selectedSingleRow.approval.action != 'REJECTED';
return isPengajuan && isNotRejected;
}, [selectedSingleRow, isApproveLoading]);
return ( return (
<> <>
<div className='w-full p-0 sm:p-4'> <div className='min-h-screen w-full p-0 sm:p-4'>
<div className='flex flex-col gap-2 mb-4'> <div className='flex flex-col gap-2 mb-4'>
<div className='w-full flex flex-col justify-between items-end gap-2'> <div className='w-full flex flex-col justify-between items-end gap-2'>
<div className='flex flex-col sm:flex-row gap-3 w-full'> <div className='flex flex-col sm:flex-row gap-3 w-full'>
<Button <Button
href='/production/project-flock/add'
variant='outline'
color='primary' color='primary'
className='w-full sm:w-fit' className='w-full sm:w-fit'
href='/production/project-flock/add'
> >
<Icon icon='ic:round-plus' width={24} height={24} /> <Icon icon='ic:round-plus' width={24} height={24} />
Tambah Tambah
</Button> </Button>
<Button {/* <Button
variant='outline' variant='outline'
color='success' color='success'
onClick={() => { onClick={() => {
@@ -299,7 +320,7 @@ const ProjectFlockTable = () => {
> >
<Icon icon='mdi:times' width={24} height={24} /> <Icon icon='mdi:times' width={24} height={24} />
Reject Reject
</Button> </Button> */}
<div className='ms-auto w-full sm:w-auto'> <div className='ms-auto w-full sm:w-auto'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
@@ -391,9 +412,7 @@ const ProjectFlockTable = () => {
id: 'select', id: 'select',
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter( const selectableRows = allRows;
(row) => row.original?.approval?.step_number == 1
);
const allSelected = const allSelected =
selectableRows.every((row) => row.getIsSelected()) && selectableRows.every((row) => row.getIsSelected()) &&
@@ -417,12 +436,6 @@ const ProjectFlockTable = () => {
checked={allSelected} checked={allSelected}
indeterminate={someSelected} indeterminate={someSelected}
onChange={toggleSelectableRows} onChange={toggleSelectableRows}
disabled={
isResponseSuccess(projectFlocks) &&
projectFlocks?.data?.filter(
(flock) => flock.approval.step_number == 1
).length == 0
}
/> />
</div> </div>
); );
@@ -431,14 +444,8 @@ const ProjectFlockTable = () => {
return ( return (
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={ checked={row.getIsSelected()}
row.getIsSelected() && disabled={!row.getCanSelect()}
row.original.approval.step_number == 1
}
disabled={
!row.getCanSelect() ||
row.original.approval.step_number != 1
}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
/> />
@@ -469,6 +476,40 @@ const ProjectFlockTable = () => {
{ {
accessorKey: 'approval.step_name', accessorKey: 'approval.step_name',
header: 'Status', header: 'Status',
cell: (props) => {
const approval = props.row.original.approval;
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start',
}}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval.step_number == 1
? 'neutral'
: approval.step_number == 2
? 'success'
: 'error'
}
/>
{approval.step_name}
</Badge>
);
},
}, },
{ {
header: 'Kandang', header: 'Kandang',
@@ -496,51 +537,51 @@ const ProjectFlockTable = () => {
accessorKey: 'created_at', accessorKey: 'created_at',
header: 'Dibuat pada', header: 'Dibuat pada',
cell: (props) => cell: (props) =>
new Date(props.row.original.created_at).toLocaleDateString(), formatDate(props.row.original.created_at, 'MMM DD, YYYY'),
}, },
{ // {
header: 'Aksi', // header: 'Aksi',
cell: (props) => { // cell: (props) => {
const currentPageSize = // const currentPageSize =
props.table.getPaginationRowModel().rows.length; // props.table.getPaginationRowModel().rows.length;
const currentPageRows = // const currentPageRows =
props.table.getPaginationRowModel().flatRows; // props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex = // const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1; // currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = // const isLast2Rows =
currentRowRelativeIndex > currentPageSize - 2; // currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => { // const deleteClickHandler = () => {
setSelectedProjectFlock(props.row.original); // setSelectedProjectFlock(props.row.original);
deleteModal.openModal(); // deleteModal.openModal();
}; // };
return ( // return (
<> // <>
{currentPageSize > 2 && ( // {currentPageSize > 2 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}> // <RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu // <RowOptionsMenu
type='dropdown' // type='dropdown'
props={props} // props={props}
deleteClickHandler={deleteClickHandler} // deleteClickHandler={deleteClickHandler}
/> // />
</RowDropdownOptions> // </RowDropdownOptions>
)} // )}
{currentPageSize <= 2 && ( // {currentPageSize <= 2 && (
<RowCollapseOptions> // <RowCollapseOptions>
<RowOptionsMenu // <RowOptionsMenu
type='collapse' // type='collapse'
props={props} // props={props}
deleteClickHandler={deleteClickHandler} // deleteClickHandler={deleteClickHandler}
/> // />
</RowCollapseOptions> // </RowCollapseOptions>
)} // )}
</> // </>
); // );
}, // },
}, // },
]} ]}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={ page={
@@ -576,6 +617,57 @@ const ProjectFlockTable = () => {
</div> </div>
</div> </div>
<FloatingActionsButton
actions={[
{
action: 'DETAIL',
icon: 'mdi:eye-outline',
label: 'Lihat Detail',
hidden: selectedRowIds.length !== 1,
onClick() {
router.push(
`/production/project-flock/detail?projectFlockId=${selectedRowIds[0]}`
);
setRowSelection({});
},
},
{
action: 'DELETE',
icon: 'material-symbols:delete-outline-rounded',
label: `Hapus data`,
hidden: selectedRowIds.length !== 1,
onClick: () => {
deleteModal.openModal();
},
},
]}
approvals={[
{
icon: 'material-symbols:check',
label: 'Approve',
action: 'APPROVED',
onClick: () => {
setApprovalAction('APPROVED');
confirmModal.openModal();
},
disabled: !canApprove,
},
{
icon: 'mdi:times',
label: 'Reject',
action: 'REJECTED',
onClick: () => {
setApprovalAction('REJECTED');
confirmModal.openModal();
},
},
]}
selectedRowIds={selectedRowIds}
onClose={() => {
setRowSelection({});
}}
/>
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
type='error' type='error'
@@ -10,7 +10,7 @@ import SelectInput, {
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper'; import { cn, formatDate, formatTitleCase } from '@/lib/helper';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { ProjectFlockKandangApi } from '@/services/api/production'; import { ProjectFlockKandangApi } from '@/services/api/production';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -21,6 +21,7 @@ import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { FormHeader } from '@/components/helper/form/FormHeader'; import { FormHeader } from '@/components/helper/form/FormHeader';
import Link from 'next/link';
const ProjectFlockChickinDetail = ({ const ProjectFlockChickinDetail = ({
projectFlockId, projectFlockId,
@@ -101,11 +102,26 @@ const ProjectFlockChickinDetail = ({
}, [projectFlockId, listProjectFlock]); }, [projectFlockId, listProjectFlock]);
return ( return (
<> <>
<FormHeader {/* Header */}
<div className='flex flex-row justify-between items-center px-4 py-4'>
<div className='flex flex-row items-center h-full gap-2'>
<Link
href={`/production/project-flock/detail?projectFlockId=${projectFlock?.id}`}
className='hover:text-gray-400'
>
<Icon icon='mdi:arrow-left' width={24} height={24} />
</Link>
<div className='divider divider-horizontal p-0 m-0'></div>
<div className='text-sm text-neutral'>
Chick In {projectFlock?.flock_name}
</div>
</div>
</div>
{/* <FormHeader
title={`Chick In ${projectFlock?.flock_name ?? 'Project Flock'}`} title={`Chick In ${projectFlock?.flock_name ?? 'Project Flock'}`}
backUrl='/production/project-flock' backUrl={`/production/project-flock/detail?projectFlockId=${projectFlock?.id}`}
/> /> */}
<div className='flex flex-col gap-4 w-full my-4'> {/* <div className='flex flex-col gap-4 w-full my-4'>
<div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'> <div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'>
<SelectInput <SelectInput
required required
@@ -145,8 +161,129 @@ const ProjectFlockChickinDetail = ({
} }
/> />
</div> </div>
</div> */}
{/* Informasi Umum */}
{projectFlock && (
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={
projectFlock.approval.step_number == 1
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
? 'error'
: undefined
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
projectFlock.approval.step_number == 1
? 'neutral'
: projectFlock.approval.step_number == 2
? 'success'
: projectFlock.approval.step_number >= 3
? 'error'
: undefined
}
/>{' '}
{projectFlock.approval.step_name}
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:bookmark' width={12} height={12} />
{` ${formatTitleCase(projectFlock.category)}`}
</Badge>
</div> </div>
<Card {/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:account' /> Submitted
</div>
<div className='col-span-2'>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:account-circle' width={14} height={14} />{' '}
{projectFlock.created_user.name}
</Badge>
</div>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon={'mdi:clock'} /> History
</div>
<div className='col-span-2'>
<Button variant='outline' className='py-1 text-sm'>
See History{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div>
{/* BARIS 1 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock.area.name}</div>
{/* BARIS 2 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock.location.name}</div>
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> FCR
</div>
<div className='col-span-2'>{projectFlock.fcr.name}</div>
{/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
Kategori
</div>
<div className='col-span-2'>
{formatTitleCase(projectFlock.category)}
</div>
</div>
</div>
</div>
)}
{/* <Card
title='Informasi Flock' title='Informasi Flock'
className={{ className={{
wrapper: 'w-full bg-white mb-3', wrapper: 'w-full bg-white mb-3',
@@ -231,8 +368,152 @@ const ProjectFlockChickinDetail = ({
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
/> />
</Card> </Card> */}
{/* Card Kandangs */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Daftar Kandang</h2>
{isResponseSuccess(listProjectFlock) ? (
<>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'success'}
/>{' '}
Disetujui (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval?.step_number == 1
).length}
)
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
variant='soft'
color={'neutral'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'neutral'}
/>{' '}
Pengajuan (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval?.step_number == 2
).length}
)
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='error'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon
icon={`mdi:circle`}
width={12}
height={12}
color='error'
/>
Belum Chickin (
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.filter(
(k) => k.approval == null
).length}
)
</Badge>
</div>
{/* Card Kandang */}
<Card <Card
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-col gap-6'>
{isResponseSuccess(listProjectFlockKandang) &&
listProjectFlockKandang.data.map((kandang) => (
<div
key={kandang.id}
className='flex flex-row justify-between items-center'
>
<div className='flex flex-row gap-2 items-center cursor-pointer text-gray-400'>
<Badge
variant='soft'
color={
kandang.approval
? kandang.approval.step_number == 1
? 'success'
: 'neutral'
: 'error'
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
kandang.approval
? kandang.approval.step_number == 1
? 'success'
: 'neutral'
: 'gray'
}
/>
</Badge>
<span className='font-semibold'>
{kandang.kandang.name}
</span>
</div>
<Button
variant='outline'
className='py-1 text-sm'
onClick={() => {
handleChickinClick(kandang);
}}
disabled={projectFlock?.approval?.step_number === 1}
>
Chick In{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div>
))}
</div>
</Card>
</>
) : (
<div className='w-full p-5 text-center'>
<span className='text-lg opacity-50'>
Pilih project flock terlebih dahulu...
</span>
</div>
)}
</div>
</div>
{/* <Card
title='Daftar Kandang' title='Daftar Kandang'
className={{ className={{
wrapper: 'w-full bg-white', wrapper: 'w-full bg-white',
@@ -351,7 +632,7 @@ const ProjectFlockChickinDetail = ({
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
/> />
</Card> </Card> */}
</> </>
); );
}; };
@@ -0,0 +1,305 @@
'use client';
import Button from '@/components/Button';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import Table from '@/components/Table';
import Badge from '@/components/Badge';
import { cn, formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { ProjectFlock } from '@/types/api/production/project-flock';
import {
ClosingExpense,
ProjectFlockKandang,
StockItem,
} from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import useSWR from 'swr';
import { ProjectFlockKandangApi } from '@/services/api/production/project-flock-kandang';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
const ProjectFlockClosingForm = ({
projectFlock,
projectFlockKandang,
}: {
projectFlock: ProjectFlock;
projectFlockKandang: ProjectFlockKandang;
}) => {
const router = useRouter();
const closeModal = useModal();
const isCanClose = projectFlock.approval?.step_number <= 2;
const [isClosingLoading, setIsClosingLoading] = useState(false);
const { data: closingData, isLoading } = useSWR(
`${ProjectFlockKandangApi.basePath}/${projectFlockKandang.id}/closing`,
() => ProjectFlockKandangApi.checkClosing(projectFlockKandang.id)
);
const confirmationModalCloseClickHandler = async () => {
setIsClosingLoading(true);
const deleteProjectFlockRes = await ProjectFlockKandangApi.closing(
projectFlockKandang?.id as number,
{
closed_date: formatDate(new Date(), 'YYYY-MM-DD'),
action: isCanClose ? 'close' : 'unclose',
}
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push(`/production/project-flock`);
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
setIsClosingLoading(false);
closeModal.closeModal();
};
const errorStock = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0)
: true;
}, [closingData]);
const errorExpense = useMemo(() => {
return isResponseSuccess(closingData)
? closingData?.data?.expenses.every((expense) => expense.step < 5)
: true;
}, [closingData]);
const isCanCloseValid = true;
return (
<>
<DrawerHeader
leftIcon='mdi:arrow-left'
leftIconHref={`/production/project-flock/detail?projectFlockId=${projectFlock.id}`}
subtitle={`Close ${projectFlock.flock_name}`}
></DrawerHeader>
{/* Informasi Kandang */}
<div className='divider'></div>
<div className='px-4 pb-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Kandang</h2>
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color='success'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
Aktif
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:home' width={12} height={12} />
{` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`}
</Badge>
</div>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
{/* Area */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock.area?.name}</div>
{/* Lokasi */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock.location?.name}</div>
{/* Kandang */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Kandang
</div>
<div className='col-span-2'>{projectFlockKandang.kandang?.name}</div>
{/* Jumlah DOC */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Jumlah DOC
</div>
<div className='col-span-2'>
{formatNumber(
projectFlockKandang.chickins?.reduce(
(total, chickin) => total + chickin.usage_qty,
0
) ?? 0
)}{' '}
Ekor
</div>
</div>
</div>
{/* Table Biaya */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Biaya</h2>
<Table<ClosingExpense>
data={
isResponseSuccess(closingData) ? closingData.data?.expenses : []
}
columns={[
{
header: 'PO Number',
accessorKey: 'po_number',
},
{
header: 'Total',
accessorKey: 'total',
},
{
header: 'Status',
accessorKey: 'status',
cell(props) {
return (
<Badge
className={{
badge: 'rounded-lg',
}}
variant='soft'
color={
props.row.original.step < 5
? props.row.original.step == 1
? 'neutral'
: 'success'
: 'error'
}
>
{formatTitleCase(props.row.original.step_name)}
</Badge>
);
},
},
]}
className={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-3 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-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{/* {errorExpense && (
<div className='text-center text-error text-sm'>
*Pastikan semua biaya sudah selesai sebelum melakukan closing.
</div>
)} */}
</div>
{/* Table Persediaan Gudang */}
<div className='divider'></div>
<div className='px-4 pb-4'>
<h2 className='text-2xl font-semibold'>Persediaan Gudang</h2>
<Table<StockItem>
data={
isResponseSuccess(closingData)
? closingData.data?.stock_remaining
: []
}
columns={[
{
header: 'Product',
accessorKey: 'product_name',
},
{
header: 'Kategori',
accessorKey: 'product_category',
},
{
header: 'Quantity',
accessorKey: 'quantity',
},
{
header: 'UOM',
accessorKey: 'uom',
},
]}
className={{
containerClassName: cn('my-4'),
tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120',
tableClassName: 'font-inter w-full table-sm min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-3 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-3 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
{/* {errorStock && (
<div className='text-center text-error text-sm'>
*Masih ada sisa stock yang belum dihabiskan.
</div>
)} */}
</div>
<div className='p-4 mt-6'>
<Button
className='w-full'
color='error'
isLoading={isLoading}
disabled={!isCanCloseValid}
onClick={() => closeModal.openModal()}
>
<Icon icon='mdi:checkbox-marked-circle-outline' />{' '}
{isCanClose ? 'Close' : 'Unclose'}
</Button>
</div>
<ConfirmationModal
ref={closeModal.ref}
type='error'
text={
isCanClose
? 'Apakah kamu yakin ingin mengakhiri project ini ? *Pastikan persediaan produk di gudang terkait sudah kosong, dan BOP sudah selesai'
: 'Apakah kamu yakin ingin membuka kembali project ini ? *Project ini akan kembali ke status aktif'
}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isClosingLoading,
onClick: confirmationModalCloseClickHandler,
}}
/>
</>
);
};
export default ProjectFlockClosingForm;
@@ -0,0 +1,476 @@
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import Card from '@/components/Card';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import Tooltip from '@/components/Tooltip';
import DrawerHeader from '@/components/helper/drawer/DrawerHeader';
import {
formatCurrency,
formatDate,
formatNumber,
formatTitleCase,
} from '@/lib/helper';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import toast from 'react-hot-toast';
import ApprovalSteps, {
useApprovalSteps,
} from '@/components/pages/ApprovalSteps';
import {
PROJECT_FLOCK_APPROVAL_LINE,
PROJECT_FLOCK_KANDANGS_APPROVAL_LINE,
} from '@/config/approval-line';
import useSWR from 'swr';
import { ProjectFlockKandangApi } from '@/services/api/production';
const ProjectFlockDetail = ({
projectFlock,
}: {
projectFlock: ProjectFlock;
}) => {
const router = useRouter();
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [openBudgets, setOpenBudget] = useState(false);
const [selectedKandangId, setSelectedKamdangId] = useState<string | null>(
null
);
const selectedKandang = projectFlock.kandangs?.find(
(kandang) => kandang.id === Number(selectedKandangId)
);
const { data: projectFlockKandang, isLoading: projectFlockKandangLoading } =
useSWR(
selectedKandangId
? `${ProjectFlockKandangApi.basePath}/get-detail/${selectedKandangId}`
: null,
selectedKandangId
? () =>
ProjectFlockKandangApi.getSingle(
Number(selectedKandang?.project_flock_kandang_id)
)
: null
);
const {
approvals,
isLoading: approvalsLoading,
refresh: refreshApprovals,
} = useApprovalSteps({
latestApproval: projectFlock?.approval,
approvalLines: PROJECT_FLOCK_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCKS',
moduleId: projectFlock?.id.toString() ?? '',
});
const { approvals: kandangApprovals, isLoading: kandangApprovalsLoading } =
useApprovalSteps({
latestApproval:
selectedKandangId && isResponseSuccess(projectFlockKandang)
? projectFlockKandang?.data?.approval
: undefined,
approvalLines: PROJECT_FLOCK_KANDANGS_APPROVAL_LINE,
moduleName: 'PROJECT_FLOCK_KANDANGS',
moduleId:
selectedKandangId && isResponseSuccess(projectFlockKandang)
? projectFlockKandang?.data?.id?.toString()
: '',
});
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
const deleteProjectFlockRes = await ProjectFlockApi.delete(
projectFlock?.id as number
);
if (isResponseSuccess(deleteProjectFlockRes)) {
toast.success(deleteProjectFlockRes?.message as string);
router.push('/production/project-flock');
}
if (isResponseError(deleteProjectFlockRes)) {
toast.error(deleteProjectFlockRes?.message as string);
}
setIsDeleteLoading(false);
};
return (
<>
<div className='h-full w-full flex flex-col gap-4'>
{/* Header */}
<DrawerHeader
leftIcon='mdi:close'
leftIconHref='/production/project-flock'
subtitle={`Created On ${formatDate(projectFlock.created_at, 'MMM DD, YYYY')}`}
>
<Link
href={`/production/project-flock/detail/edit?projectFlockId=${projectFlock.id}`}
className='p-0'
>
<Tooltip content='Edit' position='bottom'>
<Button variant='link' className='p-0 text-neutral'>
<Icon icon='mdi:square-edit-outline' width={20} height={20} />
</Button>
</Tooltip>
</Link>
<Button
variant='link'
className='p-0 text-error'
onClick={() => {
deleteModal.openModal();
}}
>
<Tooltip content='Hapus' position='bottom'>
<Icon icon='mdi:trash-can-outline' width={20} height={20} />
</Tooltip>
</Button>
</DrawerHeader>
{/* Informasi Umum */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Informasi Umum</h2>
{/* Status Approval */}
{approvals && !approvalsLoading && (
<div className='text-sm my-3'>
<ApprovalSteps approvals={approvals} />
</div>
)}
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
: undefined
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
projectFlock.approval?.step_number == 1
? 'neutral'
: projectFlock.approval?.step_number == 2
? 'success'
: projectFlock.approval?.step_number >= 3
? 'error'
: undefined
}
/>{' '}
{projectFlock.approval?.step_name}
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2' }}
>
<Icon icon='mdi:bookmark' width={12} height={12} />
{` ${formatTitleCase(projectFlock.category ?? '')}`}
</Badge>
</div>
{/* Information Grid */}
<div className='grid grid-cols-3 gap-4'>
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:account' /> Submitted
</div>
<div className='col-span-2'>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:account-circle' width={14} height={14} />{' '}
{projectFlock.created_user?.name}
</Badge>
</div>
{/* <div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon={'mdi:clock'} /> History
</div>
<div className='col-span-2'>
<Button variant='outline' className='py-1 text-sm'>
See History{' '}
<Icon
icon='mdi:arrow-top-right-thin'
width={11}
height={11}
/>
</Button>
</div> */}
{/* BARIS 1 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Area
</div>
<div className='col-span-2'>{projectFlock?.area?.name}</div>
{/* BARIS 2 */}
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> Lokasi
</div>
<div className='col-span-2'>{projectFlock?.location?.name}</div>
<div
className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2
relative
before:content-[""] before:absolute before:left-[5px] before:top-[90%] before:bottom-[-100%] before:w-[1px] before:border-1 before:border-dashed before:border-gray-400'
>
<Icon width={14} height={14} icon='mdi:circle-slice-8' /> FCR
</div>
<div className='col-span-2'>{projectFlock?.fcr?.name}</div>
{/* BARIS 3 (Terakhir - TIDAK PERLU garis di bawahnya) */}
<div className='col-span-1 flex flex-row items-center text-gray-400 font-semibold gap-2'>
<Icon width={14} height={14} icon='mdi:circle-slice-8' />{' '}
Kategori
</div>
<div className='col-span-2'>
{formatTitleCase(projectFlock.category ?? '')}
</div>
</div>
</div>
</div>
{/* Kandang Aktif */}
<div className='border-t-1 border-gray-300'>
<div className='p-4 flex flex-col gap-4'>
<h2 className='text-2xl font-semibold'>Kandang Aktif</h2>
{kandangApprovals && !kandangApprovalsLoading && (
<ApprovalSteps approvals={kandangApprovals} />
)}
{/* Badge Row */}
<div className='flex flex-row gap-2'>
<Badge
variant='soft'
color={'success'}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={'success'}
/>{' '}
Kandang Aktif ({projectFlock.kandangs?.length})
</Badge>
<div className='divider divider-horizontal p-0 m-0'></div>
<Badge
color='neutral'
variant='soft'
className={{ badge: 'rounded-lg px-2 cursor-pointer' }}
onClick={() => {
setOpenBudget(!openBudgets);
}}
>
{` ${formatCurrency(
(projectFlock.project_budgets ?? []).reduce(
(acc, curr) => acc + curr.price * curr.qty,
0
)
)}`}
<Icon
icon={`mdi:${openBudgets ? 'eye' : 'eye-off'}`}
width={12}
height={12}
/>
</Badge>
</div>
{/* Card List Project Budgets */}
{openBudgets &&
(projectFlock.project_budgets ?? []).map((budget) => (
<Card
key={budget.id}
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<div className='flex flex-col gap-6'>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:tag'} width={14} height={14} />{' '}
<span>Jenis Produk</span>
</div>
<div className='text-end text-gray-500'>
{budget?.nonstock?.name}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:tag'} width={14} height={14} />{' '}
<span>Nama Satuan</span>
</div>
<div className='text-end text-gray-500'>
{budget?.nonstock?.uom?.name}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon
icon={'mdi:file-multiple'}
width={14}
height={14}
/>{' '}
<span>Jumlah Pembelian</span>
</div>
<div className='text-end text-gray-500'>
{formatNumber(budget.qty)}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:file'} width={14} height={14} />{' '}
<span>Harga Satuan</span>
</div>
<div className='text-end text-gray-500'>
{formatCurrency(budget.price)}
</div>
</div>
<div className='flex flex-row justify-between items-center'>
<div className='flex flex-row gap-2 items-center text-gray-400'>
<Icon icon={'mdi:calculator'} width={14} height={14} />{' '}
<span>Total Harga</span>
</div>
<div className='text-end text-gray-500'>
{formatCurrency(budget.price * budget.qty)}
</div>
</div>
</div>
</Card>
))}
{/* Card Kandangs */}
<Card
variant='bordered'
className={{
wrapper: 'w-full',
body: 'p-3',
}}
>
<RadioGroup
name='gender'
className={{
radioWrapper: 'grid grid-cols-1 gap-6',
}}
onChange={(e) => setSelectedKamdangId(e.target.value)}
value={selectedKandangId?.toString()}
size='md'
color='neutral'
disabled={projectFlock?.approval?.step_number == 1}
>
{projectFlock.kandangs?.map((kandang) => (
<div
key={kandang.id}
className={`grid grid-cols-2 gap-6 cursor-pointer hover:text-gray-800`}
onClick={() =>
projectFlock?.approval?.step_number > 1 &&
setSelectedKamdangId(kandang?.id?.toString())
}
>
<RadioGroupItem
value={kandang?.id?.toString()}
label={kandang?.name}
disabled={projectFlock?.approval?.step_number == 1}
/>
<div className='text-end'>
<Badge
className={{
badge: 'rounded-lg',
}}
>
Kapasitas {kandang?.capacity} Ekor
</Badge>
</div>
</div>
))}
</RadioGroup>
</Card>
<div className='grid grid-cols-4 gap-3'>
<Link
href={`/production/project-flock/chickin/add/kandang?projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}&projectFlockId=${projectFlock.id}`}
className='m-0 p-0'
>
<Button
className='w-full px-2 py-1 text-sm'
variant='outline'
color='success'
disabled={
!selectedKandangId ||
projectFlock?.approval?.step_number == 1
}
>
Chickin <Icon icon='mdi:checkbox-marked-outline' />
</Button>
</Link>
<Link
href={`/production/project-flock/closing?projectFlockId=${projectFlock.id}&projectFlockKandangId=${selectedKandang?.project_flock_kandang_id}`}
className='m-0 p-0'
>
<Button
className='w-full px-2 py-1 text-sm'
variant='outline'
color='error'
disabled={
!selectedKandangId ||
projectFlock?.approval?.step_number == 1
}
>
Close <Icon icon='mdi:checkbox-marked-circle-outline' />
</Button>
</Link>
</div>
</div>
</div>
</div>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Project Flock ini (${projectFlock?.flock_name} - ${projectFlock?.area?.name})?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: confirmationModalDeleteClickHandler,
}}
/>
</>
);
};
export default ProjectFlockDetail;
@@ -1,6 +1,71 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
export const ProjectFlockFormSchema = Yup.object({ type ProjectFlockFormSchemaType = {
flock: {
value: number | string;
label: string;
} | null;
flock_name: string;
area: {
value: number | string;
label: string;
} | null;
area_id: number;
category_option: {
value: string;
label: string;
} | null;
category: string;
fcr: {
value: number | string;
label: string;
} | null;
fcr_id: number;
location: {
value: number | string;
label: string;
} | null;
location_id: number;
kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[];
};
export type ProjectFlockBudgetsSchemaType = {
nonstock: {
value: number | string;
label: string;
} | null;
nonstock_id: number | string;
qty: number | string;
price: number | string;
total_price: number | string;
};
export const ProjectFlockBudgetsSchema: Yup.ObjectSchema<ProjectFlockBudgetsSchemaType> =
Yup.object({
nonstock: Yup.object({
value: Yup.number().required('ID Nonstock wajib diisi!'),
label: Yup.string().required('Nama Nonstock wajib diisi!'),
}).required('Nonstock wajib diisi!'),
nonstock_id: Yup.number()
.min(1, 'Nonstock wajib diisi!')
.required('Nonstock wajib diisi!'),
qty: Yup.number()
.typeError('Jumlah harus berupa angka!')
.min(1, 'Jumlah minimal 1!')
.required('Jumlah wajib diisi!'),
price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
total_price: Yup.number()
.typeError('Harga harus berupa angka!')
.min(1, 'Harga minimal 1!')
.required('Harga wajib diisi!'),
});
export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType> =
Yup.object({
// Flock // Flock
flock: Yup.object({ flock: Yup.object({
value: Yup.number().required('ID Flock wajib diisi!'), value: Yup.number().required('ID Flock wajib diisi!'),
@@ -31,7 +96,9 @@ export const ProjectFlockFormSchema = Yup.object({
value: Yup.number().required('ID FCR wajib diisi!'), value: Yup.number().required('ID FCR wajib diisi!'),
label: Yup.string().required('Nama FCR wajib diisi!'), label: Yup.string().required('Nama FCR wajib diisi!'),
}).nullable(), }).nullable(),
fcr_id: Yup.number().min(1, 'FCR wajib diisi!').required('FCR wajib diisi!'), fcr_id: Yup.number()
.min(1, 'FCR wajib diisi!')
.required('FCR wajib diisi!'),
// Location // Location
location: Yup.object({ location: Yup.object({
@@ -43,9 +110,14 @@ export const ProjectFlockFormSchema = Yup.object({
.required('Lokasi wajib diisi!'), .required('Lokasi wajib diisi!'),
kandang_ids: Yup.array() kandang_ids: Yup.array()
.of(Yup.number().typeError('Kandang tidak valid!')) .of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!') .min(1, 'Minimal harus ada 1 kandang!')
.required('Kandang wajib diisi!'), .required('Kandang wajib diisi!'),
project_budgets: Yup.array()
.of(ProjectFlockBudgetsSchema)
.min(1, 'Minimal harus ada 1 data budget!')
.required('Data budget wajib diisi!'),
}); });
export type ProjectFlockFormValues = Yup.InferType< export type ProjectFlockFormValues = Yup.InferType<
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
'use client'; 'use client';
import Badge from '@/components/Badge';
import Card from '@/components/Card';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import PillBadge from '@/components/PillBadge'; import PillBadge from '@/components/PillBadge';
import Table from '@/components/Table'; import Table from '@/components/Table';
@@ -9,6 +11,7 @@ import {
ProjectFlock, ProjectFlock,
ProjectFlockPeriods, ProjectFlockPeriods,
} from '@/types/api/production/project-flock'; } from '@/types/api/production/project-flock';
import { Icon } from '@iconify/react';
import { OnChangeFn, Row } from '@tanstack/react-table'; import { OnChangeFn, Row } from '@tanstack/react-table';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -29,163 +32,119 @@ const ProjectFlockKandangTable = ({
initialValues?: ProjectFlock; initialValues?: ProjectFlock;
formType: 'add' | 'edit' | 'detail'; formType: 'add' | 'edit' | 'detail';
}) => { }) => {
const initialKandangIdSet = useMemo(() => { // Fungsi untuk menangani perubahan checkbox
return initialValues?.kandangs.map((k) => k.id) ?? []; const handleCheckboxChange = (kandang: Kandang, isChecked: boolean) => {
}, [initialValues]); // Hanya izinkan perubahan jika tidak dalam mode 'detail'
const isRowEnabled = (row: Row<Kandang>) => { if (formType === 'detail') return;
const isDisabled =
!initialKandangIdSet.includes(row.original.id) && // Pastikan kandang.id ada dan tidak null/undefined
(row.original.status == 'ACTIVE' || if (kandang.id === undefined) return;
row.original.status == 'PENGAJUAN' ||
formType == 'detail'); const kandangIdString = kandang.id.toString();
return !isDisabled;
setRowSelection((prev) => {
const newSelection = { ...prev };
if (isChecked) {
newSelection[kandangIdString] = true;
} else {
delete newSelection[kandangIdString];
}
return newSelection;
});
}; };
return ( return (
<> <>
<Table<Kandang> {listKandang.length > 0 ? (
data={listKandang} <>
columns={[ {/* ... Bagian Badge Status ... */}
<div className='flex flex-row mb-4'>
<Badge
variant='soft'
color='primary'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tersedia (
{ {
id: 'select', listKandang.filter((kandang) => kandang.status == 'NON_ACTIVE')
header: ({ table }) => { .length
const allRows = table.getRowModel().rows; }
// 1. Filter semua baris dengan logika yang sama persis seperti di cell )
const selectableRows = allRows.filter(isRowEnabled); </Badge>
<div className='divider divider-horizontal mx-1'></div>
<Badge
variant='soft'
color='neutral'
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
Tidak Tersedia (
{
listKandang.filter((kandang) => kandang.status != 'NON_ACTIVE')
.length
}
)
</Badge>
</div>
{/* --- */}
<Card
variant='bordered'
className={{
wrapper: 'w-full rounded-lg',
body: 'p-4',
}}
>
<div className='flex flex-col gap-4 w-full'>
{listKandang.map((kandang, index) => {
const kandangIdString =
kandang.id?.toString() ?? `temp-${index}`;
// 2. Cek apakah SEMUA baris yang BISA DIPILIH sudah terpilih const isSelected =
const allSelected = !!rowSelection[kandangIdString] ||
selectableRows.length > 0 && (kandang.id !== undefined &&
selectableRows.every((row) => row.getIsSelected()); selectedIds.includes(kandang.id));
// 3. Cek apakah BEBERAPA baris yang BISA DIPILIH sudah terpilih const isDisabled =
const someSelected = formType == 'detail' || kandang.status != 'NON_ACTIVE';
selectableRows.some((row) => row.getIsSelected()) &&
!allSelected;
// 4. Fungsi toggle HANYA akan mentoggle baris yang BISA DIPILIH
const toggleSelectableRows = () => {
const shouldSelect = !allSelected;
selectableRows.forEach((row) =>
row.toggleSelected(shouldSelect)
);
};
return ( return (
<div className='w-full flex flex-row justify-center'> <div key={index} className='flex flex-row justify-between'>
<CheckboxInput <CheckboxInput
name='allRow' name={`kandang-${kandang.id}`} // Nama unik untuk setiap checkbox
checked={allSelected} label={kandang.name}
indeterminate={someSelected} checked={isSelected}
onChange={toggleSelectableRows} disabled={isDisabled}
disabled={ onChange={(e) =>
selectableRows.length === 0 || formType == 'detail' handleCheckboxChange(kandang, e.currentTarget.checked)
} }
/> />
<Badge
variant='soft'
color={
kandang.status == 'NON_ACTIVE' ? 'primary' : 'neutral'
}
className={{
badge: 'rounded-lg px-2',
}}
>
<Icon icon='mdi:circle' width={12} height={12} />
{kandang.status != 'NON_ACTIVE' && 'Tidak'} Tersedia
</Badge>
</div> </div>
); );
}, })}
cell: ({ row }) => { </div>
return ( </Card>
<CheckboxInput </>
name='row' ) : (
checked={ <div className='text-center py-4 text-gray-400'>
(row.getIsSelected() && Pilih lokasi terlebih dahulu
(row.original.status == 'NON_ACTIVE' || </div>
row.original.status == 'PENGAJUAN')) || )}
(selectedIds && selectedIds.includes(row.original.id))
}
disabled={
formType == 'detail' ||
(!initialKandangIdSet.includes(row.original.id) &&
(row.original.status == 'ACTIVE' ||
row.original.status == 'PENGAJUAN'))
}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
);
},
},
{
accessorFn: (row) => row.name,
header: 'Kandang',
},
{
accessorFn: (row) => row.status,
header: 'Status',
cell: (props) => {
return (
<PillBadge
color={(() => {
switch (props.row.original.status) {
case 'ACTIVE':
return 'red';
case 'PENGAJUAN':
return 'green';
case 'NON_ACTIVE':
return 'blue';
default:
return 'gray';
}
})()}
content={props.row.original.status
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())}
/>
);
},
},
{
accessorFn: (row) => row.capacity,
header: 'Kapasitas',
},
{
accessorFn: (row) => row.location?.name,
header: 'Periode',
cell: (props) => {
console.log('listPeriods');
console.log(listPeriods);
const period =
listPeriods.length > 0
? listPeriods.find((p) => p.id == props.row.original.id)
: undefined;
const calcPeriod = period?.period == 0 ? 1 : period?.period;
const selected = props.row.getIsSelected();
const initPeriod = initialValues?.period;
return formType == 'detail'
? selected
? initPeriod
: '-'
: formType == 'add'
? (calcPeriod ?? '-')
: selected
? (initPeriod ?? '-')
: (calcPeriod ?? '-');
},
},
{
accessorFn: (row) => row.pic?.name,
header: 'Penanggung Jawab',
},
]}
className={{
containerClassName: cn({
'mb-20': listKandang?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
/>
</> </>
); );
}; };
@@ -35,33 +35,32 @@ const RowOptionsMenu = ({
deleteClickHandler, deleteClickHandler,
approveClickHandler, approveClickHandler,
rejectClickHandler, rejectClickHandler,
isGradingCompleted,
}: { }: {
type: 'dropdown' | 'collapse'; type: 'dropdown' | 'collapse';
props: CellContext<Recording, unknown>; props: CellContext<Recording, unknown>;
deleteClickHandler: () => void; deleteClickHandler: () => void;
approveClickHandler: () => void; approveClickHandler: () => void;
rejectClickHandler: () => void; rejectClickHandler: () => void;
isGradingCompleted: (recording: Recording) => boolean;
}) => { }) => {
const isLayingCategory =
props.row.original.project_flock_category === 'LAYING';
const isRecordingApproved = (recording: Recording) => { const isRecordingApproved = (recording: Recording) => {
return ( return (
recording.approval?.action === 'APPROVED' && recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui' && recording.approval?.step_number === 2 &&
recording.approval?.step_number === 3 recording.approval?.step_name === 'Disetujui'
); );
}; };
const isRecordingRejected = (recording: Recording) => {
return recording.approval?.action === 'REJECTED';
};
const isApproved = isRecordingApproved(props.row.original); const isApproved = isRecordingApproved(props.row.original);
const isGradingDone = isGradingCompleted(props.row.original); const isRejected = isRecordingRejected(props.row.original);
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<Button <Button
href={`recording/detail/?recordingId=${props.row.original.id}`} href={`/production/recording/detail/?recordingId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='primary' color='primary'
className='justify-start text-sm' className='justify-start text-sm'
@@ -70,7 +69,7 @@ const RowOptionsMenu = ({
Detail Detail
</Button> </Button>
<Button <Button
href={`recording/detail/edit/?recordingId=${props.row.original.id}`} href={`/production/recording/detail/edit/?recordingId=${props.row.original.id}`}
variant='ghost' variant='ghost'
color='warning' color='warning'
className='justify-start text-sm' className='justify-start text-sm'
@@ -78,7 +77,7 @@ const RowOptionsMenu = ({
<Icon icon='mdi:pencil-outline' width={16} height={16} /> <Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit Edit
</Button> </Button>
{!isApproved && !(isLayingCategory && !isGradingDone) && ( {!isApproved && !isRejected && (
<Button <Button
onClick={approveClickHandler} onClick={approveClickHandler}
variant='ghost' variant='ghost'
@@ -89,7 +88,7 @@ const RowOptionsMenu = ({
Approve Approve
</Button> </Button>
)} )}
{!isApproved && !(isLayingCategory && !isGradingDone) && ( {!isApproved && !isRejected && (
<Button <Button
onClick={rejectClickHandler} onClick={rejectClickHandler}
variant='ghost' variant='ghost'
@@ -370,7 +369,7 @@ const RecordingTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
const [approvalNotes, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const singleDeleteModal = useModal(); const singleDeleteModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
@@ -386,33 +385,10 @@ const RecordingTable = () => {
RecordingApi.getAllFetcher RecordingApi.getAllFetcher
); );
const isRecordingFullyApproved = useCallback( const isRecordingApproved = useCallback((recording: Recording): boolean => {
(recording: Recording): boolean => {
return ( return (
recording.approval?.action === 'APPROVED' && recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui' && recording.approval?.step_name === 'Disetujui'
Number(recording.approval?.step_number) === 3
);
},
[]
);
const isRecordingApproved = useCallback(
(recording: Recording) => {
return isRecordingFullyApproved(recording);
},
[isRecordingFullyApproved]
);
const isGradingCompleted = useCallback((recording: Recording): boolean => {
if (recording.project_flock_category !== 'LAYING') {
return true;
}
return (
recording.egg_grading_status === 'COMPLETED' ||
(recording.approval?.action === 'UPDATED' &&
recording.approval?.step_number === 2)
); );
}, []); }, []);
@@ -506,19 +482,9 @@ const RecordingTable = () => {
if (!isResponseSuccess(recordings) || !recordings.data) return []; if (!isResponseSuccess(recordings) || !recordings.data) return [];
return selectedRowIds.filter((id) => { return selectedRowIds.filter((id) => {
const recording = recordings.data.find((r) => r.id === id); const recording = recordings.data.find((r) => r.id === id);
if (!recording || isRecordingApproved(recording)) return false; return recording && !isRecordingApproved(recording);
if (recording.project_flock_category === 'GROWING') {
return true;
}
if (recording.project_flock_category === 'LAYING') {
return isGradingCompleted(recording);
}
return false;
}); });
}, [selectedRowIds, recordings, isRecordingApproved, isGradingCompleted]); }, [selectedRowIds, recordings, isRecordingApproved]);
useEffect(() => { useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) { if (isResponseSuccess(recordings) && recordings.data) {
@@ -530,14 +496,7 @@ const RecordingTable = () => {
(r) => r.id === parseInt(rowId) (r) => r.id === parseInt(rowId)
); );
if (recording && !isRecordingApproved(recording)) { if (recording && !isRecordingApproved(recording)) {
if (recording.project_flock_category === 'GROWING') {
newSelection[rowId] = true; newSelection[rowId] = true;
} else if (
recording.project_flock_category === 'LAYING' &&
isGradingCompleted(recording)
) {
newSelection[rowId] = true;
}
} }
} }
}); });
@@ -548,13 +507,7 @@ const RecordingTable = () => {
setRowSelection(newSelection); setRowSelection(newSelection);
} }
} }
}, [ }, [recordings, rowSelection, isRecordingApproved, setRowSelection]);
recordings,
rowSelection,
isRecordingApproved,
isGradingCompleted,
setRowSelection,
]);
return ( return (
<div className='w-full p-0 sm:p-4'> <div className='w-full p-0 sm:p-4'>
@@ -562,7 +515,7 @@ const RecordingTable = () => {
<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-col xl:flex-row justify-between items-end xl:items-center gap-2'>
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'> <div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
<Button <Button
href='recording/add' href='/production/recording/add'
variant='outline' variant='outline'
color='primary' color='primary'
className='w-full sm:w-fit' className='w-full sm:w-fit'
@@ -640,40 +593,28 @@ const RecordingTable = () => {
id: 'select', id: 'select',
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
const selectableRows = allRows.filter((row) => {
const selectableGrowingRows = allRows.filter((row) => {
const recording = row.original; const recording = row.original;
return ( return !isRecordingApproved(recording);
recording.project_flock_category === 'GROWING' &&
!isRecordingApproved(recording)
);
}); });
const hasNoSelectableGrowing = selectableGrowingRows.length === 0; const hasNoSelectableRows = selectableRows.length === 0;
const handleSelectAllGrowing = () => { const handleSelectAll = () => {
const isAllSelected = selectableGrowingRows.every((row) => const isAllSelected = selectableRows.every((row) =>
row.getIsSelected() row.getIsSelected()
); );
allRows.forEach((row) => { selectableRows.forEach((row) => {
const recording = row.original;
if (
recording.project_flock_category === 'GROWING' &&
!isRecordingApproved(recording)
) {
row.toggleSelected(!isAllSelected); row.toggleSelected(!isAllSelected);
} else if (recording.project_flock_category === 'LAYING') {
row.toggleSelected(false);
}
}); });
}; };
const isAllGrowingSelected = const isAllSelected =
selectableGrowingRows.length > 0 && selectableRows.length > 0 &&
selectableGrowingRows.every((row) => row.getIsSelected()); selectableRows.every((row) => row.getIsSelected());
const isSomeGrowingSelected = selectableGrowingRows.some((row) => const isSomeSelected = selectableRows.some((row) =>
row.getIsSelected() row.getIsSelected()
); );
@@ -681,33 +622,20 @@ const RecordingTable = () => {
<div className='w-full flex flex-row justify-center'> <div className='w-full flex flex-row justify-center'>
<CheckboxInput <CheckboxInput
name='allRow' name='allRow'
checked={isAllGrowingSelected} checked={isAllSelected}
indeterminate={ indeterminate={isSomeSelected && !isAllSelected}
isSomeGrowingSelected && !isAllGrowingSelected onChange={handleSelectAll}
} disabled={hasNoSelectableRows}
onChange={handleSelectAllGrowing}
disabled={hasNoSelectableGrowing}
/> />
</div> </div>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const isApproved = isRecordingApproved(row.original);
const isLayingCategory =
row.original.project_flock_category === 'LAYING';
if (isLayingCategory) {
return null;
}
const isDisabled = !row.getCanSelect() || isApproved;
return ( return (
<div> <div>
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={row.getIsSelected()} checked={row.getIsSelected()}
disabled={isDisabled}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={row.getToggleSelectedHandler()}
/> />
@@ -883,7 +811,6 @@ const RecordingTable = () => {
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
approveClickHandler={approveClickHandler} approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler} rejectClickHandler={rejectClickHandler}
isGradingCompleted={isGradingCompleted}
/> />
</RowDropdownOptions> </RowDropdownOptions>
)} )}
@@ -896,7 +823,6 @@ const RecordingTable = () => {
deleteClickHandler={deleteClickHandler} deleteClickHandler={deleteClickHandler}
approveClickHandler={approveClickHandler} approveClickHandler={approveClickHandler}
rejectClickHandler={rejectClickHandler} rejectClickHandler={rejectClickHandler}
isGradingCompleted={isGradingCompleted}
/> />
</RowCollapseOptions> </RowCollapseOptions>
)} )}
@@ -4,7 +4,6 @@ import {
CreateGrowingRecordingPayload, CreateGrowingRecordingPayload,
CreateLayingRecordingPayload, CreateLayingRecordingPayload,
CreateEggPayload, CreateEggPayload,
CreateGradingPayload,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
type RecordingGrowingFormSchemaType = { type RecordingGrowingFormSchemaType = {
@@ -32,14 +31,7 @@ type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: { eggs: {
product_warehouse_id: number; product_warehouse_id: number;
qty: number | string; qty: number | string;
}[]; weight: number | string;
};
type RecordingGradingFormSchemaType = {
eggs_grading: {
recording_egg_id: number;
grade: string;
qty: number | string;
}[]; }[];
}; };
@@ -62,6 +54,7 @@ export type DepletionSchema = {
export type EggSchema = { export type EggSchema = {
product_warehouse_id: number; product_warehouse_id: number;
qty: number | string; qty: number | string;
weight: number | string;
}; };
const BodyWeightObjectSchema: Yup.ObjectSchema<BodyWeightSchema> = Yup.object({ const BodyWeightObjectSchema: Yup.ObjectSchema<BodyWeightSchema> = Yup.object({
@@ -109,6 +102,10 @@ const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
.required('Jumlah telur wajib diisi!') .required('Jumlah telur wajib diisi!')
.min(1, 'Jumlah telur tidak boleh 0!') .min(1, 'Jumlah telur tidak boleh 0!')
.typeError('Jumlah telur harus berupa angka!'), .typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number()
.required('Berat telur wajib diisi!')
.min(1, 'Berat telur minimal 1 gram!')
.typeError('Berat telur harus berupa angka!'),
}); });
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> = export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
@@ -190,30 +187,6 @@ export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({
.required('Project Flock Kandang wajib diisi!'), .required('Project Flock Kandang wajib diisi!'),
}); });
export const RecordingGradingFormSchema: Yup.ObjectSchema<RecordingGradingFormSchemaType> =
Yup.object({
eggs_grading: Yup.array()
.of(
Yup.object({
recording_egg_id: Yup.number()
.required('Recording Egg ID wajib diisi!')
.min(1, 'Recording Egg ID minimal 1!')
.typeError('Recording Egg ID harus berupa angka!'),
grade: Yup.string()
.required('Grade telur wajib diisi!')
.typeError('Grade telur harus berupa string!'),
qty: Yup.number()
.required('Jumlah telur wajib diisi!')
.min(1, 'Jumlah telur minimal 1!')
.typeError('Jumlah telur harus berupa angka!'),
})
)
.min(1, 'Minimal harus ada 1 data grading telur!')
.required('Data grading telur wajib diisi!'),
});
export const UpdateRecordingGradingFormSchema = RecordingGradingFormSchema;
export type RecordingGrowingFormValues = Yup.InferType< export type RecordingGrowingFormValues = Yup.InferType<
typeof RecordingGrowingFormSchema typeof RecordingGrowingFormSchema
>; >;
@@ -222,10 +195,6 @@ export type RecordingLayingFormValues = Yup.InferType<
typeof RecordingLayingFormSchema typeof RecordingLayingFormSchema
>; >;
export type RecordingGradingFormValues = Yup.InferType<
typeof RecordingGradingFormSchema
>;
type RecordingFormData = Partial<Recording> & { type RecordingFormData = Partial<Recording> & {
body_weights?: CreateGrowingRecordingPayload['body_weights']; body_weights?: CreateGrowingRecordingPayload['body_weights'];
stocks?: CreateGrowingRecordingPayload['stocks'] | Recording['stocks']; stocks?: CreateGrowingRecordingPayload['stocks'] | Recording['stocks'];
@@ -295,26 +264,12 @@ export const getRecordingLayingFormInitialValues = (
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({ eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: egg.product_warehouse_id, product_warehouse_id: egg.product_warehouse_id,
qty: egg.qty, qty: egg.qty,
weight: egg.weight,
})) ?? [ })) ?? [
{ {
product_warehouse_id: 0, product_warehouse_id: 0,
qty: '', qty: '',
}, weight: '',
],
});
export const getRecordingGradingFormInitialValues = (
initialValues?: Partial<CreateGradingPayload> & { recording_egg_id?: number }
): RecordingGradingFormValues => ({
eggs_grading: initialValues?.eggs_grading?.map((grading) => ({
recording_egg_id: grading.recording_egg_id,
grade: grading.grade,
qty: grading.qty,
})) ?? [
{
recording_egg_id: initialValues?.recording_egg_id ?? 0,
grade: '',
qty: '',
}, },
], ],
}); });
@@ -16,7 +16,6 @@ import CheckboxInput from '@/components/input/CheckboxInput';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import Tooltip from '@/components/Tooltip';
import { import {
ProjectFlockKandangApi, ProjectFlockKandangApi,
@@ -48,7 +47,7 @@ import {
getRecordingLayingFormInitialValues, getRecordingLayingFormInitialValues,
UpdateRecordingGrowingFormSchema, UpdateRecordingGrowingFormSchema,
UpdateRecordingLayingFormSchema, UpdateRecordingLayingFormSchema,
} from './RecordingForm.schema'; } from '@/components/pages/production/recording/form/RecordingForm.schema';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
@@ -98,9 +97,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const [recordingFormErrorMessage, setRecordingFormErrorMessage] = const [recordingFormErrorMessage, setRecordingFormErrorMessage] =
useState(''); useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [newRecordingData, setNewRecordingData] = useState<Recording | null>( const [, setNewRecordingData] = useState<Recording | null>(null);
null
);
const [nextDayRecording, setNextDayRecording] = const [nextDayRecording, setNextDayRecording] =
useState<NextDayRecording | null>(null); useState<NextDayRecording | null>(null);
@@ -111,19 +108,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const isRecordingApproved = useCallback((recording?: Recording) => { const isRecordingApproved = useCallback((recording?: Recording) => {
return ( return (
recording?.approval?.action === 'APPROVED' && recording?.approval?.action === 'APPROVED' &&
recording?.approval?.step_name === 'Disetujui' && recording?.approval?.step_name === 'Disetujui'
recording?.approval?.step_number === 3
); );
}, []); }, []);
const hasGradingData = useCallback((recording?: Recording) => { const isRecordingRejected = useCallback((recording?: Recording) => {
if (!recording || !recording.eggs) return false; return recording?.approval?.action === 'REJECTED';
return recording.eggs.some(
(egg) =>
egg.gradings &&
egg.gradings.length > 0 &&
egg.gradings.some((grading) => grading.qty > 0)
);
}, []); }, []);
// ===== PAYLOAD CREATION HELPERS ===== // ===== PAYLOAD CREATION HELPERS =====
@@ -181,6 +171,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
eggs: (values.eggs ?? []).map((egg) => ({ eggs: (values.eggs ?? []).map((egg) => ({
product_warehouse_id: egg.product_warehouse_id, product_warehouse_id: egg.product_warehouse_id,
qty: Number(egg.qty) || 0, qty: Number(egg.qty) || 0,
weight:
typeof egg.weight === 'number'
? egg.weight
: parseFloat(String(egg.weight)) || 0,
})), })),
}; };
}, },
@@ -203,35 +197,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
[router] [router]
); );
const createRecordingHandlerWithRedirect = useCallback(
async (
payload: CreateGrowingRecordingPayload | CreateLayingRecordingPayload,
redirectToGrading: boolean = false
) => {
const res = await RecordingApi.create(payload);
if (isResponseError(res)) {
setRecordingFormErrorMessage(res.message);
return null;
}
toast.success(res?.message as string);
if (res?.status === 'success' && res.data) {
setNewRecordingData(res.data);
return res.data;
}
if (redirectToGrading) {
toast.error(
'Gagal mendapatkan ID recording. Silakan coba dari halaman list.'
);
router.push('/production/recording');
}
return null;
},
[router]
);
const updateRecordingHandler = useCallback( const updateRecordingHandler = useCallback(
async ( async (
recordingId: number, recordingId: number,
@@ -650,7 +615,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const hasPakanFlag = product.product.flags?.includes('PAKAN'); const hasPakanFlag = product.product.flags?.includes('PAKAN');
const hasOvkFlag = product.product.flags?.includes('OVK'); const hasOvkFlag = product.product.flags?.includes('OVK');
// Only include products that are in the same location as the selected kandang
if (hasPakanFlag || hasOvkFlag) { if (hasPakanFlag || hasOvkFlag) {
options.push({ options.push({
value: product.id, value: product.id,
@@ -690,7 +654,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
depletionProductsData.data.forEach((product) => { depletionProductsData.data.forEach((product) => {
const productName = product.product.name; const productName = product.product.name;
// Filter for depletion-related products (culling, mati, afkir)
if ( if (
productName.toLowerCase().includes('culling') || productName.toLowerCase().includes('culling') ||
productName.toLowerCase().includes('mati') || productName.toLowerCase().includes('mati') ||
@@ -732,7 +695,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
eggProductsData.data.forEach((product) => { eggProductsData.data.forEach((product) => {
const productName = product.product.name; const productName = product.product.name;
// Filter for egg-related products
if ( if (
productName.toLowerCase().includes('telur') || productName.toLowerCase().includes('telur') ||
productName.toLowerCase().includes('egg') || productName.toLowerCase().includes('egg') ||
@@ -1019,54 +981,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
); );
}, [formik.values.stocks, getStockUsageError, type]); }, [formik.values.stocks, getStockUsageError, type]);
const hasConsumableEggs = useMemo(() => {
if (!isLayingCategory) return false;
const layingValues = formik.values as RecordingLayingFormValues;
if (!layingValues.eggs || layingValues.eggs.length === 0) return false;
return layingValues.eggs.some((egg) => {
if (!egg.product_warehouse_id || Number(egg.qty) <= 0) return false;
const product = eggProducts.find(
(opt) => opt.value === egg.product_warehouse_id
);
if (!product) return false;
const productName = product.label.toLowerCase();
return (
productName.includes('konsumsi') &&
productName.includes('baik') &&
Number(egg.qty) > 0
);
});
}, [isLayingCategory, formik.values, eggProducts]);
const hasConsumableEggsInRecording = useCallback((recording?: Recording) => {
if (!recording || !recording.eggs || recording.eggs.length === 0)
return false;
return recording.eggs.some((egg) => {
if (!egg.product_warehouse || !egg.product_warehouse.product)
return false;
if (Number(egg.qty) <= 0) return false;
const productName = egg.product_warehouse.product.name.toLowerCase();
return (
productName.includes('konsumsi') &&
productName.includes('baik') &&
Number(egg.qty) > 0
);
});
}, []);
const hasConsumableEggsInCurrentRecording = useMemo(() => {
return (
hasConsumableEggsInRecording(initialValues) ||
hasConsumableEggsInRecording(newRecordingData || undefined)
);
}, [initialValues, newRecordingData, hasConsumableEggsInRecording]);
const isRepeaterInputError = ( const isRepeaterInputError = (
arrayName: 'body_weights' | 'stocks' | 'depletions' | 'eggs', arrayName: 'body_weights' | 'stocks' | 'depletions' | 'eggs',
column: string, column: string,
@@ -1278,7 +1192,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setIsRejectLoading(false); setIsRejectLoading(false);
}; };
// Body Weights Handlers
const addBodyWeight = () => { const addBodyWeight = () => {
const newBodyWeights = [ const newBodyWeights = [
...(formik.values.body_weights || []), ...(formik.values.body_weights || []),
@@ -1397,7 +1310,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedBodyWeights([]); setSelectedBodyWeights([]);
}; };
// Stocks Handlers
const addStock = () => { const addStock = () => {
const newStocks = [ const newStocks = [
...(formik.values.stocks || []), ...(formik.values.stocks || []),
@@ -1430,7 +1342,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedStocks([]); setSelectedStocks([]);
}; };
// Depletions Handlers
const addDepletion = () => { const addDepletion = () => {
const newDepletions = [ const newDepletions = [
...(formik.values.depletions || []), ...(formik.values.depletions || []),
@@ -1465,7 +1376,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
setSelectedDepletions([]); setSelectedDepletions([]);
}; };
// Eggs Handlers
const addEgg = () => { const addEgg = () => {
const newEggs = [ const newEggs = [
...((formik.values as RecordingLayingFormValues).eggs || []), ...((formik.values as RecordingLayingFormValues).eggs || []),
@@ -1485,6 +1395,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
[formik] [formik]
); );
const handleEggWeightChangeWrapper = useCallback(
(idx: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value) || 0;
formik.setFieldValue(`eggs.${idx}.weight`, value);
},
[formik]
);
const removeEgg = (idx: number) => { const removeEgg = (idx: number) => {
const updatedEggs = ( const updatedEggs = (
formik.values as RecordingLayingFormValues formik.values as RecordingLayingFormValues
@@ -1570,8 +1488,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</Button> </Button>
{type === 'detail' && {type === 'detail' &&
initialValues?.approval &&
!isRecordingApproved(initialValues) && !isRecordingApproved(initialValues) &&
(!isLayingCategory || hasGradingData(initialValues)) && ( !isRecordingRejected(initialValues) && (
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Button <Button
variant='outline' variant='outline'
@@ -1916,7 +1835,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{formik.values.body_weights?.map((bw, idx) => ( {formik.values.body_weights?.map((bw, idx) => (
<tr key={`body-weight-${idx}`}> <tr key={`body-weight-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`body-weight-${idx}`} name={`body-weight-${idx}`}
checked={selectedBodyWeights.includes(idx)} checked={selectedBodyWeights.includes(idx)}
@@ -2166,7 +2085,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{formik.values.stocks?.map((stock, idx) => ( {formik.values.stocks?.map((stock, idx) => (
<tr key={`stock-${idx}`}> <tr key={`stock-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`stock-${idx}`} name={`stock-${idx}`}
checked={selectedStocks.includes(idx)} checked={selectedStocks.includes(idx)}
@@ -2386,7 +2305,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{formik.values.depletions?.map((depletion, idx) => ( {formik.values.depletions?.map((depletion, idx) => (
<tr key={`depletion-${idx}`}> <tr key={`depletion-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`depletion-${idx}`} name={`depletion-${idx}`}
checked={selectedDepletions.includes(idx)} checked={selectedDepletions.includes(idx)}
@@ -2587,6 +2506,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<span className='text-error'>*</span> <span className='text-error'>*</span>
</span> </span>
</th> </th>
<th>
Berat (gram)
<span
className='tooltip tooltip-error tooltip-bottom '
data-tip='required'
>
<span className='text-error'>*</span>
</span>
</th>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<th>Action</th> <th>Action</th>
)} )}
@@ -2597,7 +2525,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(egg, idx) => ( (egg, idx) => (
<tr key={`egg-${idx}`}> <tr key={`egg-${idx}`}>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td className='!align-middle'> <td className='align-middle!'>
<CheckboxInput <CheckboxInput
name={`egg-${idx}`} name={`egg-${idx}`}
checked={selectedEggs.includes(idx)} checked={selectedEggs.includes(idx)}
@@ -2662,7 +2590,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
/> />
</td> </td>
<td> <td>
<div className='flex flex-col gap-1'>
<NumberInput <NumberInput
required required
name={`eggs.${idx}.qty`} name={`eggs.${idx}.qty`}
@@ -2674,8 +2601,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
thousandSeparator=',' thousandSeparator=','
decimalSeparator='.' decimalSeparator='.'
isError={ isError={
isRepeaterInputError('eggs', 'qty', idx) isRepeaterInputError('eggs', 'qty', idx).isError
.isError
} }
errorMessage={ errorMessage={
isRepeaterInputError('eggs', 'qty', idx) isRepeaterInputError('eggs', 'qty', idx)
@@ -2687,7 +2613,32 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}} }}
placeholder='Masukkan jumlah telur' placeholder='Masukkan jumlah telur'
/> />
</div> </td>
<td>
<NumberInput
required
name={`eggs.${idx}.weight`}
value={egg.weight ?? ''}
onChange={handleEggWeightChangeWrapper(idx)}
onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
isError={
isRepeaterInputError('eggs', 'weight', idx)
.isError
}
errorMessage={
isRepeaterInputError('eggs', 'weight', idx)
.errorMessage
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
placeholder='Masukkan berat telur (gram)...'
/>
</td> </td>
{(type as 'add' | 'edit' | 'detail') !== 'detail' && ( {(type as 'add' | 'edit' | 'detail') !== 'detail' && (
<td> <td>
@@ -2779,46 +2730,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div> </div>
{/* Right side actions */} {/* Right side actions */}
<div className='flex flex-col sm:flex-row sm:justify-end gap-2 w-full sm:w-auto'> <div className='flex flex-col sm:flex-row sm:justify-end gap-2 w-full sm:w-auto'>
{type === 'detail' && isLayingCategory && (
<Tooltip
content={
hasConsumableEggsInCurrentRecording
? 'Lanjut ke proses grading untuk telur konsumsi baik'
: 'Hanya bisa melanjutkan ke grading jika ada Telur Konsumsi Baik'
}
position='left'
color={
hasConsumableEggsInCurrentRecording ? 'info' : 'warning'
}
>
<Button
type='button'
color='primary'
disabled={!hasConsumableEggsInCurrentRecording}
className='w-full sm:w-auto'
onClick={() => {
const recordingId =
newRecordingData?.id || initialValues?.id;
if (recordingId) {
router.push(
`/production/recording/grading/add?recording_id=${recordingId}`
);
} else {
toast.error(
'Recording ID tidak ditemukan. Silakan refresh halaman.'
);
}
}}
>
<Icon icon='material-symbols:egg' width={24} height={24} />
{hasGradingData(initialValues) ||
hasGradingData(newRecordingData || undefined)
? 'Edit Grading'
: 'Lanjut ke Grading'}
</Button>
</Tooltip>
)}
{type === 'edit' && ( {type === 'edit' && (
<div className='flex flex-col sm:flex-row gap-2 w-full sm:w-auto'> <div className='flex flex-col sm:flex-row gap-2 w-full sm:w-auto'>
<Button <Button
@@ -2870,79 +2781,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
> >
Submit Submit
</Button> </Button>
{isLayingCategory && (
<Tooltip
content={
hasConsumableEggs
? 'Lanjut ke proses grading untuk telur konsumsi baik'
: 'Hanya bisa melanjutkan ke grading jika ada Telur Konsumsi Baik'
}
position='left'
color={hasConsumableEggs ? 'info' : 'warning'}
>
<Button
type='button'
color='info'
className='px-4'
isLoading={formik.isSubmitting}
disabled={
hasExceededStock ||
!formik.isValid ||
formik.isSubmitting ||
!hasConsumableEggs
}
onClick={async () => {
if (!formik.isValid) {
await formik.validateForm();
return;
}
setRecordingFormErrorMessage('');
formik.setSubmitting(true);
try {
if (isLayingCategory) {
const layingValues =
formik.values as RecordingLayingFormValues;
const layingPayload =
createLayingPayload(layingValues);
const recordingData =
await createRecordingHandlerWithRedirect(
layingPayload as CreateLayingRecordingPayload,
true
);
if (recordingData?.id) {
toast.success(
'Recording berhasil disimpan! Mengalihkan ke form Grading...'
);
setTimeout(() => {
router.push(
`/production/recording/grading/add?recording_id=${recordingData.id}`
);
}, 1000);
}
}
} catch (error) {
console.error('Error creating recording:', error);
toast.error(
'Gagal membuat recording. Silakan coba lagi.'
);
} finally {
formik.setSubmitting(false);
}
}}
>
<Icon
icon='material-symbols:egg'
width={24}
height={24}
/>
Next Step: Grading
</Button>
</Tooltip>
)}
</div> </div>
)} )}
</div> </div>
@@ -2981,7 +2819,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{/* Approve Confirmation Modal */} {/* Approve Confirmation Modal */}
{(type as 'add' | 'edit' | 'detail') === 'detail' && {(type as 'add' | 'edit' | 'detail') === 'detail' &&
!isRecordingApproved(initialValues) && !isRecordingApproved(initialValues) &&
(!isLayingCategory || hasGradingData(initialValues)) && ( !isRecordingRejected(initialValues) && (
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={approveModal.ref} ref={approveModal.ref}
type='success' type='success'
@@ -3003,8 +2841,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{/* Reject Confirmation Modal */} {/* Reject Confirmation Modal */}
{(type as 'add' | 'edit' | 'detail') === 'detail' && {(type as 'add' | 'edit' | 'detail') === 'detail' &&
!isRecordingApproved(initialValues) && !isRecordingApproved(initialValues) && (
(!isLayingCategory || hasGradingData(initialValues)) && (
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={rejectModal.ref} ref={rejectModal.ref}
type='error' type='error'
File diff suppressed because it is too large Load Diff
@@ -226,9 +226,7 @@ export const getFilledTransferToLayingFormInitialValues = async (
// targetKandang.target_project_flock_kandang.kandang.capacity, // targetKandang.target_project_flock_kandang.kandang.capacity,
// TODO: integrate this to real API kandang capacity // TODO: integrate this to real API kandang capacity
maxQuantity: maxQuantity: Infinity,
targetKandang.target_project_flock_kandang.kandang.capacity ??
Infinity,
})) }))
: [], : [],
@@ -16,7 +16,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowCollapseOptions from '@/components/table/RowCollapseOptions'; import RowCollapseOptions from '@/components/table/RowCollapseOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { cn, formatDate, formatCurrency } from '@/lib/helper'; import { cn, formatDate } 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';
@@ -136,14 +136,6 @@ const PurchaseTable = () => {
? formatDate(props.row.original.po_date, 'DD MMM YYYY') ? 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', header: 'Aging',
cell: (props) => { cell: (props) => {
@@ -156,11 +148,6 @@ const PurchaseTable = () => {
return `${diffDays} hari`; return `${diffDays} hari`;
}, },
}, },
{
accessorKey: 'grand_total',
header: 'Total (Rp.)',
cell: (props) => formatCurrency(props.row.original.grand_total),
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props) => { cell: (props) => {
@@ -18,7 +18,7 @@ import {
PurchaseRequestAcceptApprovalFormDefaultValues, PurchaseRequestAcceptApprovalFormDefaultValues,
PurchaseRequestAcceptApprovalFormInitialValues, PurchaseRequestAcceptApprovalFormInitialValues,
PurchaseRequestAcceptApprovalFormSchema, PurchaseRequestAcceptApprovalFormSchema,
} from './PurchaseOrderForm.schema'; } from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { PurchaseApi } from '@/services/api/purchase'; import { PurchaseApi } from '@/services/api/purchase';
import { import {
@@ -52,6 +52,8 @@ const PurchaseOrderAcceptApprovalForm = ({
const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] = const [purchaseOrderFormErrorMessage, setPurchaseOrderFormErrorMessage] =
useState(''); useState('');
const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
// ===== UTILITY FUNCTIONS ===== // ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = ( const isRepeaterInputError = (
idx: number, idx: number,
@@ -64,7 +66,6 @@ const PurchaseOrderAcceptApprovalForm = ({
| 'expedition_vendor_id' | 'expedition_vendor_id'
| 'received_qty' | 'received_qty'
| 'transport_per_item' | 'transport_per_item'
| 'transport_total'
): { isError: boolean; errorMessage: string } => { ): { isError: boolean; errorMessage: string } => {
const touchedItem = formik.touched.items?.[idx]; const touchedItem = formik.touched.items?.[idx];
const errorItem = formik.errors.items?.[idx] as const errorItem = formik.errors.items?.[idx] as
@@ -163,6 +164,7 @@ const PurchaseOrderAcceptApprovalForm = ({
validateOnBlur: true, validateOnBlur: true,
onSubmit: async (values) => { onSubmit: async (values) => {
const payload: CreateAcceptApprovalRequestPayload = { const payload: CreateAcceptApprovalRequestPayload = {
action: 'APPROVED',
notes: values.notes || '', notes: values.notes || '',
items: items:
values.items?.map((formItem) => { values.items?.map((formItem) => {
@@ -181,10 +183,6 @@ const PurchaseOrderAcceptApprovalForm = ({
typeof formItem.transport_per_item === 'string' typeof formItem.transport_per_item === 'string'
? parseFloat(formItem.transport_per_item) || 0 ? parseFloat(formItem.transport_per_item) || 0
: 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,
}; };
}) || [], }) || [],
}; };
@@ -239,9 +237,8 @@ const PurchaseOrderAcceptApprovalForm = ({
vehicle_number: item.vehicle_number || '', vehicle_number: item.vehicle_number || '',
expedition_vendor: null, expedition_vendor: null,
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: item.total_qty || '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
}; };
}); });
formik.setFieldValue('items', updatedItems); formik.setFieldValue('items', updatedItems);
@@ -301,7 +298,7 @@ const PurchaseOrderAcceptApprovalForm = ({
// ===== PURCHASE ITEM OPERATIONS ===== // ===== PURCHASE ITEM OPERATIONS =====
const handlePurchaseItemChange = ( const handlePurchaseItemChange = (
idx: number, idx: number,
field: 'received_qty' | 'transport_per_item' | 'transport_total', field: 'received_qty' | 'transport_per_item',
value: string | number value: string | number
) => { ) => {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value; const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
@@ -318,26 +315,6 @@ const PurchaseOrderAcceptApprovalForm = ({
: parseFloat( : parseFloat(
formik.values.items?.[idx]?.transport_per_item as string formik.values.items?.[idx]?.transport_per_item as string
) || 0; ) || 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
);
}
} }
}; };
@@ -386,10 +363,6 @@ const PurchaseOrderAcceptApprovalForm = ({
Transport/Item Transport/Item
<span className='text-error'>*</span> <span className='text-error'>*</span>
</th> </th>
<th>
Total Transport
<span className='text-error'>*</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -657,37 +630,6 @@ const PurchaseOrderAcceptApprovalForm = ({
}} }}
/> />
</td> </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> </tr>
); );
})} })}
@@ -732,7 +674,8 @@ const PurchaseOrderAcceptApprovalForm = ({
disabled={ disabled={
!formik.isValid || !formik.isValid ||
formik.isSubmitting || formik.isSubmitting ||
hasQuantityExceededErrors hasQuantityExceededErrors ||
isRejected
} }
> >
Submit Submit
@@ -23,10 +23,12 @@ type PurchaseRequestStaffApprovalFormSchemaType = {
}; };
type PurchaseRequestManagerApprovalFormSchemaType = { type PurchaseRequestManagerApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null; notes: string | null;
}; };
type PurchaseRequestAcceptApprovalFormSchemaType = { type PurchaseRequestAcceptApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null; notes: string | null;
items: { items: {
purchase_item?: { purchase_item?: {
@@ -45,7 +47,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
expedition_vendor_id: number; expedition_vendor_id: number;
received_qty: number | string; received_qty: number | string;
transport_per_item: number | string; transport_per_item: number | string;
transport_total: number | string;
}[]; }[];
}; };
@@ -83,7 +84,6 @@ export type PurchaseAcceptApprovalItemSchema = {
expedition_vendor_id: number; expedition_vendor_id: number;
received_qty: number | string; received_qty: number | string;
transport_per_item: number | string; transport_per_item: number | string;
transport_total: number | string;
}; };
export type PurchaseDeleteItemsSchema = { export type PurchaseDeleteItemsSchema = {
@@ -152,6 +152,10 @@ const PurchaseStaffApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseStaffAppro
const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> = const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManagerApprovalFormSchemaType> =
Yup.object({ 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), notes: Yup.string().nullable().default(null),
}); });
@@ -230,20 +234,6 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
} }
) )
.typeError('Biaya transport per item harus berupa angka!'), .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> = export const PurchaseRequestStaffApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestStaffApprovalFormSchemaType> =
@@ -368,6 +358,7 @@ export const PurchaseRequestManagerApprovalFormDefaultValues = (
purchase?: Purchase purchase?: Purchase
): PurchaseRequestManagerApprovalFormSchemaType => { ): PurchaseRequestManagerApprovalFormSchemaType => {
return { return {
action: 'APPROVED',
notes: purchase?.notes ?? null, notes: purchase?.notes ?? null,
}; };
}; };
@@ -378,6 +369,10 @@ export type PurchaseRequestManagerApprovalFormValues = Yup.InferType<
export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestAcceptApprovalFormSchemaType> = export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseRequestAcceptApprovalFormSchemaType> =
Yup.object({ 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), notes: Yup.string().nullable().default(null),
items: Yup.array() items: Yup.array()
.of(PurchaseAcceptApprovalItemObjectSchema) .of(PurchaseAcceptApprovalItemObjectSchema)
@@ -388,6 +383,7 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType = export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
{ {
action: 'APPROVED',
notes: '', notes: '',
items: [ items: [
{ {
@@ -399,7 +395,6 @@ export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcce
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
}, },
], ],
}; };
@@ -408,6 +403,7 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
purchase?: Purchase purchase?: Purchase
): PurchaseRequestAcceptApprovalFormSchemaType => { ): PurchaseRequestAcceptApprovalFormSchemaType => {
return { return {
action: 'APPROVED',
notes: purchase?.notes ?? null, notes: purchase?.notes ?? null,
items: purchase?.items items: purchase?.items
? purchase.items.map((item) => ({ ? purchase.items.map((item) => ({
@@ -419,7 +415,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
})) }))
: [ : [
{ {
@@ -431,7 +426,6 @@ export const PurchaseRequestAcceptApprovalFormDefaultValues = (
expedition_vendor_id: 0, expedition_vendor_id: 0,
received_qty: '', received_qty: '',
transport_per_item: '', transport_per_item: '',
transport_total: '',
}, },
], ],
}; };
@@ -21,7 +21,7 @@ import {
PurchaseRequestStaffApprovalFormInitialValues, PurchaseRequestStaffApprovalFormInitialValues,
PurchaseRequestStaffApprovalFormSchema, PurchaseRequestStaffApprovalFormSchema,
PurchaseStaffApprovalItemSchema, PurchaseStaffApprovalItemSchema,
} from './PurchaseOrderForm.schema'; } from '@/components/pages/purchase/form/order/PurchaseOrderForm.schema';
import { isResponseError } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { PurchaseApi } from '@/services/api/purchase'; import { PurchaseApi } from '@/services/api/purchase';
@@ -61,7 +61,7 @@ const PurchaseOrderStaffApprovalForm = ({
return 'add'; return 'add';
} }
const currentStep = initialValues?.approval?.step_number || 1; const currentStep = initialValues?.latest_approval?.step_number || 1;
switch (currentStep) { switch (currentStep) {
case 1: case 1:
@@ -77,7 +77,9 @@ const PurchaseOrderStaffApprovalForm = ({
// Step 4+ (Penerimaan Barang dan selesai), tidak boleh edit kalau sudah disetujui // Step 4+ (Penerimaan Barang dan selesai), tidak boleh edit kalau sudah disetujui
return 'edit'; return 'edit';
} }
}, [rawDataApprovals, propType, initialValues?.approval?.step_number]); }, [rawDataApprovals, propType, initialValues?.latest_approval?.step_number]);
const isRejected = initialValues?.latest_approval?.action === 'REJECTED';
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -93,16 +95,16 @@ const PurchaseOrderStaffApprovalForm = ({
// ===== UTILITY FUNCTIONS ===== // ===== UTILITY FUNCTIONS =====
const canUpdatePurchaseItems = useMemo(() => { const canUpdatePurchaseItems = useMemo(() => {
if (!initialValues?.approval) return false; if (!initialValues?.latest_approval) return false;
const currentStep = initialValues.approval.step_number; const currentStep = initialValues.latest_approval.step_number;
return currentStep >= 3; return currentStep >= 3;
}, [initialValues?.approval]); }, [initialValues?.latest_approval]);
const canShowDeleteAddButtons = useMemo(() => { const canShowDeleteAddButtons = useMemo(() => {
if (!initialValues?.approval) return false; if (!initialValues?.latest_approval) return false;
const currentStep = initialValues.approval.step_number; const currentStep = initialValues.latest_approval.step_number;
// Step 2 (Staff Purchase) dengan mode 'add' tidak boleh add/delete items // Step 2 (Staff Purchase) dengan mode 'add' tidak boleh add/delete items
// User hanya boleh input harga dan total harga untuk items yang sudah ada // User hanya boleh input harga dan total harga untuk items yang sudah ada
@@ -112,7 +114,7 @@ const PurchaseOrderStaffApprovalForm = ({
// Step 3 (Manager Purchase) boleh add/delete items // Step 3 (Manager Purchase) boleh add/delete items
return currentStep === 3; return currentStep === 3;
}, [initialValues?.approval, type]); }, [initialValues?.latest_approval, type]);
const isRepeaterInputError = ( const isRepeaterInputError = (
idx: number, idx: number,
@@ -241,9 +243,8 @@ const PurchaseOrderStaffApprovalForm = ({
); );
formik.setFieldValue('items', updatedPurchaseItems); formik.setFieldValue('items', updatedPurchaseItems);
} }
} catch (error) { } catch {
toast.error('Terjadi kesalahan saat menghapus item pembelian'); toast.error('Terjadi kesalahan saat menghapus item pembelian');
console.error('Delete item error:', error);
} }
}, [ }, [
initialValues?.id, initialValues?.id,
@@ -313,7 +314,9 @@ const PurchaseOrderStaffApprovalForm = ({
const isNewItemForm = const isNewItemForm =
!formItem.purchase_item_id || formItem.purchase_item_id === 0; !formItem.purchase_item_id || formItem.purchase_item_id === 0;
let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; let cleanPayload: NonNullable<
UpdateStaffApprovalRequestPayload['items']
>[0];
if (isNewItemForm) { if (isNewItemForm) {
cleanPayload = { cleanPayload = {
@@ -361,7 +364,9 @@ const PurchaseOrderStaffApprovalForm = ({
const isNewItemForm = const isNewItemForm =
!formItem.purchase_item_id || formItem.purchase_item_id === 0; !formItem.purchase_item_id || formItem.purchase_item_id === 0;
let cleanPayload: UpdateStaffApprovalRequestPayload['items'][0]; let cleanPayload: NonNullable<
UpdateStaffApprovalRequestPayload['items']
>[0];
if (isNewItemForm) { if (isNewItemForm) {
cleanPayload = { cleanPayload = {
@@ -720,7 +725,10 @@ const PurchaseOrderStaffApprovalForm = ({
'min-w-52 md:min-w-72 lg:min-w-80', 'min-w-52 md:min-w-72 lg:min-w-80',
}} }}
bottomLabel={ bottomLabel={
'Previous: ' + purchaseItem.product.name type === 'edit'
? 'Previous: ' +
purchaseItem.product.name
: undefined
} }
/> />
</td> </td>
@@ -820,7 +828,11 @@ const PurchaseOrderStaffApprovalForm = ({
thousandSeparator=',' thousandSeparator=','
decimalSeparator='.' decimalSeparator='.'
inputPrefix={'Rp'} inputPrefix={'Rp'}
bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.price || 0, 'id-ID', 2, 2)}`} bottomLabel={
type === 'edit'
? `Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.price || 0, 'id-ID', 2, 2)}`
: undefined
}
isError={ isError={
isRepeaterInputError( isRepeaterInputError(
formItemIndex, formItemIndex,
@@ -858,7 +870,11 @@ const PurchaseOrderStaffApprovalForm = ({
thousandSeparator=',' thousandSeparator=','
decimalSeparator='.' decimalSeparator='.'
inputPrefix={'Rp'} inputPrefix={'Rp'}
bottomLabel={`Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.total_price || 0, 'id-ID', 2, 2)}`} bottomLabel={
type === 'edit'
? `Previous: Rp${formatNumber(initialValues?.items?.find((item) => item.id === purchaseItem.id)?.total_price || 0, 'id-ID', 2, 2)}`
: undefined
}
isError={ isError={
isRepeaterInputError( isRepeaterInputError(
formItemIndex, formItemIndex,
@@ -1132,7 +1148,7 @@ const PurchaseOrderStaffApprovalForm = ({
color='primary' color='primary'
className='px-4' className='px-4'
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
disabled={!formik.isValid || formik.isSubmitting} disabled={!formik.isValid || formik.isSubmitting || isRejected}
> >
Submit Submit
</Button> </Button>
@@ -78,14 +78,14 @@ export const PurchaseRequestFormSchema: Yup.ObjectSchema<PurchaseRequestFormSche
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).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() supplier_id: Yup.number()
.required('Supplier wajib dipilih!') .required('Supplier wajib dipilih!')
.min(1, 'Supplier wajib dipilih!') .min(1, 'Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'), .typeError('Supplier wajib dipilih!'),
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!'),
area: Yup.object({ area: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -22,13 +22,12 @@ import {
PurchaseRequestFormValues, PurchaseRequestFormValues,
getPurchaseRequestFormInitialValues, getPurchaseRequestFormInitialValues,
UpdatePurchaseRequestFormSchema, UpdatePurchaseRequestFormSchema,
} from './PurchaseRequestForm.schema'; } from '@/components/pages/purchase/form/request/PurchaseRequestForm.schema';
import { import {
SupplierApi, SupplierApi,
AreaApi, AreaApi,
LocationApi, LocationApi,
WarehouseApi, WarehouseApi,
ProductApi,
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier'; import { Supplier, SupplierProducts } from '@/types/api/master-data/supplier';
import { isResponseSuccess, isResponseError } from '@/lib/api-helper'; import { isResponseSuccess, isResponseError } from '@/lib/api-helper';
@@ -27,6 +27,7 @@ import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrde
import Card from '@/components/Card'; import Card from '@/components/Card';
import { import {
CreateAcceptApprovalRequestPayload,
CreateManagerApprovalRequestPayload, CreateManagerApprovalRequestPayload,
CreateStaffApprovalRequestPayload, CreateStaffApprovalRequestPayload,
Purchase, Purchase,
@@ -88,6 +89,8 @@ const PurchaseOrderDetail = ({
const staffApprovalModal = useModal(); const staffApprovalModal = useModal();
const staffRejectionModal = useModal(); const staffRejectionModal = useModal();
const acceptApprovalModal = useModal(); const acceptApprovalModal = useModal();
const acceptRejectionModal = useModal();
const managerRejectionModal = useModal();
const editModal = useModal(); const editModal = useModal();
const penerimaanBarangModal = useModal(); const penerimaanBarangModal = useModal();
const deleteModal = useModal(); const deleteModal = useModal();
@@ -156,9 +159,9 @@ const PurchaseOrderDetail = ({
}, [goodsReceiptItems]); }, [goodsReceiptItems]);
const approvalStep = useMemo(() => { const approvalStep = useMemo(() => {
if (!initialValues?.approval) return null; if (!initialValues?.latest_approval) return null;
return initialValues.approval.step_number; return initialValues.latest_approval.step_number;
}, [initialValues?.approval]); }, [initialValues?.latest_approval]);
const { const {
approvals, approvals,
@@ -166,7 +169,7 @@ const PurchaseOrderDetail = ({
rawDataApprovals, rawDataApprovals,
refresh: refreshApprovals, refresh: refreshApprovals,
} = useApprovalSteps({ } = useApprovalSteps({
latestApproval: initialValues?.approval, latestApproval: initialValues?.latest_approval,
approvalLines: PURCHASE_ORDER_APPROVAL_LINE, approvalLines: PURCHASE_ORDER_APPROVAL_LINE,
moduleName: 'PURCHASES', moduleName: 'PURCHASES',
moduleId: initialValues?.id?.toString() ?? '', moduleId: initialValues?.id?.toString() ?? '',
@@ -177,19 +180,22 @@ const PurchaseOrderDetail = ({
}); });
const showApprovalButton = const showApprovalButton =
approvalStep !== null && approvalStep >= 1 && approvalStep <= 3; approvalStep !== null &&
approvalStep >= 1 &&
approvalStep <= 3 &&
initialValues?.latest_approval?.action !== 'REJECTED';
const canDeleteItems = useMemo(() => { const canDeleteItems = useMemo(() => {
if (!initialValues?.approval) return false; if (!initialValues?.latest_approval) return false;
const currentStep = initialValues.approval.step_number; const currentStep = initialValues.latest_approval.step_number;
const hasReachedStep5 = rawDataApprovals?.some( const hasReachedStep5 = rawDataApprovals?.some(
(approval) => approval.step_number === 5 (approval) => approval.step_number === 5
); );
return currentStep === 3 && !hasReachedStep5; return currentStep === 3 && !hasReachedStep5;
}, [initialValues?.approval, rawDataApprovals]); }, [initialValues?.latest_approval, rawDataApprovals]);
const handleApprovalClick = () => { const handleApprovalClick = () => {
if (!approvalStep) return; if (!approvalStep) return;
@@ -216,24 +222,30 @@ const PurchaseOrderDetail = ({
case 1: case 1:
staffRejectionModal.openModal(); staffRejectionModal.openModal();
break; break;
case 2:
managerRejectionModal.openModal();
break;
case 3:
acceptRejectionModal.openModal();
break;
default: default:
break; break;
} }
}; };
const canShowPurchaseOrderInvoice = useMemo(() => { const canShowPurchaseOrderInvoice = useMemo(() => {
if (!initialValues?.approval) return false; if (!initialValues?.latest_approval) return false;
const currentStep = initialValues.approval.step_number; const currentStep = initialValues.latest_approval.step_number;
return currentStep >= 3; return currentStep >= 3;
}, [initialValues?.approval]); }, [initialValues?.latest_approval]);
const canShowPenerimaanBarang = useMemo(() => { const canShowPenerimaanBarang = useMemo(() => {
if (!initialValues?.approval) return false; if (!initialValues?.latest_approval) return false;
const currentStep = initialValues.approval.step_number; const currentStep = initialValues.latest_approval.step_number;
return currentStep === 5; return currentStep === 5;
}, [initialValues?.approval]); }, [initialValues?.latest_approval]);
const totalBeforeTax = useMemo(() => { const totalBeforeTax = useMemo(() => {
return purchaseOrderItems.reduce( return purchaseOrderItems.reduce(
@@ -296,6 +308,33 @@ const PurchaseOrderDetail = ({
[initialValues?.id, searchParams, refetchData] [initialValues?.id, searchParams, refetchData]
); );
const createAcceptApprovalHandler = useCallback(
async (payload: CreateAcceptApprovalRequestPayload) => {
const purchaseRequestId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: initialValues?.id || 1;
if (!purchaseRequestId) {
toast.error('Purchase Request ID is required');
return;
}
const res = await PurchaseApi.acceptApproval.create(
purchaseRequestId,
payload
);
if (isResponseError(res)) {
toast.error(res.message);
return;
}
toast.success(res?.message as string);
refreshApprovals();
refetchData?.();
},
[initialValues?.id, searchParams, refreshApprovals, refetchData]
);
// ===== MODAL HANDLERS ===== // ===== MODAL HANDLERS =====
const handleStaffApprovalModalClose = useCallback(() => { const handleStaffApprovalModalClose = useCallback(() => {
refreshApprovals(); refreshApprovals();
@@ -544,11 +583,6 @@ const PurchaseOrderDetail = ({
accessorKey: 'transport_per_item', accessorKey: 'transport_per_item',
cell: (props) => formatCurrency(props.getValue() as number), cell: (props) => formatCurrency(props.getValue() as number),
}, },
{
header: 'Transport Total',
accessorKey: 'transport_total',
cell: (props) => formatCurrency(props.getValue() as number),
},
]; ];
const summaryData = [ const summaryData = [
@@ -647,7 +681,7 @@ const PurchaseOrderDetail = ({
<div className='space-y-4'> <div className='space-y-4'>
<div className='group'> <div className='group'>
<div className='flex items-start'> <div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'> <span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Area Area
</span> </span>
<span className='text-gray-900 ml-3 break-all'> <span className='text-gray-900 ml-3 break-all'>
@@ -657,7 +691,7 @@ const PurchaseOrderDetail = ({
</div> </div>
<div className='group'> <div className='group'>
<div className='flex items-start'> <div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'> <span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Lokasi Lokasi
</span> </span>
<span className='text-gray-900 ml-3 break-all'> <span className='text-gray-900 ml-3 break-all'>
@@ -671,7 +705,7 @@ const PurchaseOrderDetail = ({
</div> </div>
<div className='group'> <div className='group'>
<div className='flex items-start'> <div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'> <span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Gudang Gudang
</span> </span>
<span className='text-gray-900 ml-3 break-all'> <span className='text-gray-900 ml-3 break-all'>
@@ -685,7 +719,7 @@ const PurchaseOrderDetail = ({
<div className='space-y-4'> <div className='space-y-4'>
<div className='group'> <div className='group'>
<div className='flex items-start'> <div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'> <span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Nama Vendor Nama Vendor
</span> </span>
<span className='text-gray-900 font-medium ml-3 break-all'> <span className='text-gray-900 font-medium ml-3 break-all'>
@@ -696,7 +730,7 @@ const PurchaseOrderDetail = ({
</div> </div>
<div className='group'> <div className='group'>
<div className='flex items-start'> <div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'> <span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Kategori Vendor Kategori Vendor
</span> </span>
<span className='text-gray-900 ml-3 break-all'> <span className='text-gray-900 ml-3 break-all'>
@@ -706,18 +740,7 @@ const PurchaseOrderDetail = ({
</div> </div>
<div className='group'> <div className='group'>
<div className='flex items-start'> <div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'> <span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Tgl. Jatuh Tempo
</span>
<span className='text-gray-900 ml-3 break-all'>
: {formatDate(purchaseData.due_date, 'D MMM YYYY')} (
{purchaseData.credit_term} hari)
</span>
</div>
</div>
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'>
Nomor Nomor
</span> </span>
<span className='text-gray-900 ml-3 break-all'> <span className='text-gray-900 ml-3 break-all'>
@@ -727,7 +750,7 @@ const PurchaseOrderDetail = ({
</div> </div>
<div className='group'> <div className='group'>
<div className='flex items-start'> <div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] flex-shrink-0'> <span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Nomor PO Nomor PO
</span> </span>
<div className='ml-3'> <div className='ml-3'>
@@ -925,6 +948,7 @@ const PurchaseOrderDetail = ({
color: 'success', color: 'success',
onClick: async (notes) => { onClick: async (notes) => {
const payload: CreateManagerApprovalRequestPayload = { const payload: CreateManagerApprovalRequestPayload = {
action: 'APPROVED',
notes: notes || null, notes: notes || null,
}; };
@@ -1028,7 +1052,6 @@ const PurchaseOrderDetail = ({
const payload: CreateStaffApprovalRequestPayload = { const payload: CreateStaffApprovalRequestPayload = {
action: 'REJECTED', action: 'REJECTED',
notes: notes || null, notes: notes || null,
items: [],
}; };
await createStaffApprovalHandler(payload); await createStaffApprovalHandler(payload);
@@ -1041,6 +1064,60 @@ const PurchaseOrderDetail = ({
}} }}
/> />
{/* Accept Rejection Modal */}
<ConfirmationModalWithNotes
ref={acceptRejectionModal.ref}
type='error'
text='Apakah Anda yakin ingin menolak (reject) penerimaan barang ini?'
placeholder='(Opsional) Masukkan alasan penolakan...'
rows={4}
closeOnBackdrop
primaryButton={{
text: 'Ya, Tolak',
color: 'error',
onClick: async (notes) => {
const payload: CreateAcceptApprovalRequestPayload = {
action: 'REJECTED',
notes: notes || null,
};
await createAcceptApprovalHandler(payload);
await refetchData?.();
acceptRejectionModal.closeModal();
},
}}
secondaryButton={{
text: 'Batal',
}}
/>
{/* Manager Rejection Modal */}
<ConfirmationModalWithNotes
ref={managerRejectionModal.ref}
type='error'
text='Apakah Anda yakin ingin menolak (reject) approval manajer untuk permintaan pembelian ini?'
placeholder='(Opsional) Masukkan alasan penolakan...'
rows={4}
closeOnBackdrop
primaryButton={{
text: 'Ya, Tolak',
color: 'error',
onClick: async (notes) => {
const payload: CreateManagerApprovalRequestPayload = {
action: 'REJECTED',
notes: notes || null,
};
await createManagerApprovalHandler(payload);
await refetchData?.();
managerRejectionModal.closeModal();
},
}}
secondaryButton={{
text: 'Batal',
}}
/>
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
<ConfirmationModal <ConfirmationModal
ref={deleteModal.ref} ref={deleteModal.ref}
@@ -12,6 +12,7 @@ import {
pdf, pdf,
} from '@react-pdf/renderer'; } from '@react-pdf/renderer';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import toast from 'react-hot-toast';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { Purchase } from '@/types/api/purchase/purchase'; import { Purchase } from '@/types/api/purchase/purchase';
@@ -251,7 +252,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (!purchaseData) { if (!purchaseData) {
alert('No purchase order data available'); toast.error('No purchase order data available');
return; return;
} }
@@ -502,9 +503,8 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch {
console.error('Error generating PDF:', error); toast.error('Failed to generate PDF. Please try again.');
alert('Failed to generate PDF. Please try again.');
} finally { } finally {
setIsGeneratingPDF(false); setIsGeneratingPDF(false);
} }
+23 -16
View File
@@ -9,9 +9,28 @@ export const PROJECT_FLOCK_APPROVAL_LINE: ApprovalLine = [
step_number: 2, step_number: 2,
step_name: 'Aktif', step_name: 'Aktif',
}, },
{
step_number: 3,
step_name: 'Selesai',
},
] as const; ] as const;
export const PROJECT_FLOCK_KANDANG_APPROVAL_LINE: ApprovalLine = [ export const PROJECT_FLOCK_KANDANGS_APPROVAL_LINE: ApprovalLine = [
{
step_number: 1,
step_name: 'Pengajuan',
},
{
step_number: 2,
step_name: 'Disetujui',
},
{
step_number: 3,
step_name: 'Selesai',
},
] as const;
export const CHICKINS_APPROVAL_LINE: ApprovalLine = [
{ {
step_number: 1, step_number: 1,
step_name: 'Pengajuan', step_name: 'Pengajuan',
@@ -51,14 +70,10 @@ export const MARKETING_APPROVAL_LINE: ApprovalLine = [
export const RECORDING_APPROVAL_LINE: ApprovalLine = [ export const RECORDING_APPROVAL_LINE: ApprovalLine = [
{ {
step_number: 1, step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan', step_name: 'Pengajuan',
}, },
{ {
step_number: 3, step_number: 2,
step_name: 'Disetujui', step_name: 'Disetujui',
}, },
] as const; ] as const;
@@ -66,14 +81,10 @@ export const RECORDING_APPROVAL_LINE: ApprovalLine = [
export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [ export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [
{ {
step_number: 1, step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan', step_name: 'Pengajuan',
}, },
{ {
step_number: 3, step_number: 2,
step_name: 'Disetujui', step_name: 'Disetujui',
}, },
] as const; ] as const;
@@ -81,14 +92,10 @@ export const GROWING_RECORDING_APPROVAL_LINE: ApprovalLine = [
export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [ export const LAYING_RECORDING_APPROVAL_LINE: ApprovalLine = [
{ {
step_number: 1, step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan', step_name: 'Pengajuan',
}, },
{ {
step_number: 3, step_number: 2,
step_name: 'Disetujui', step_name: 'Disetujui',
}, },
] as const; ] as const;
+5 -5
View File
@@ -50,6 +50,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/inventory', link: '/inventory',
icon: 'heroicons-outline:folder', icon: 'heroicons-outline:folder',
submenu: [ submenu: [
{
text: 'Produk',
link: '/inventory/product',
},
{ {
text: 'Penyesuaian Stok', text: 'Penyesuaian Stok',
link: '/inventory/adjustment', link: '/inventory/adjustment',
@@ -229,14 +233,10 @@ export const APPROVAL_WORKFLOWS = [
steps: [ steps: [
{ {
step_number: 1, step_number: 1,
step_name: 'Grading-Telur',
},
{
step_number: 2,
step_name: 'Pengajuan', step_name: 'Pengajuan',
}, },
{ {
step_number: 3, step_number: 2,
step_name: 'Disetujui', step_name: 'Disetujui',
}, },
], ],
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
/**
* Redirects the user to the SSO login page with loop protection.
*
* This function checks a session storage timestamp to ensure that redirects
* do not happen too frequently (blocking infinite redirect loops).
*/
export const redirectToSSO = () => {
if (typeof window === 'undefined') return;
const lastRedirect = sessionStorage.getItem('auth_redirect_timestamp');
const now = Date.now();
// Loop protection: allow redirect only if last one was > 5 seconds ago
// or if no redirect has happened yet.
if (!lastRedirect || now - parseInt(lastRedirect, 10) > 5000) {
sessionStorage.setItem('auth_redirect_timestamp', now.toString());
// const ssoLoginUrl = `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${window.location.href}`;
const ltiSsoStart = `${process.env.NEXT_PUBLIC_API_BASE_URL as string}/sso/start?client_id=${process.env.NEXT_PUBLIC_CLIENT_ID as string}&redirect_url=${window.location.href}`;
const ssoLoginUrl = `${process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string}?redirect_url=${ltiSsoStart}`;
window.location.href = ssoLoginUrl;
} else {
console.error('Redirect loop detected. Aborting redirect.');
}
};
+8
View File
@@ -31,6 +31,14 @@ export const formatNumber = (
}).format(value); }).format(value);
}; };
export const formatTitleCase = (value: string) => {
return value
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
export function formatVechicleNumber(value: string): string { export function formatVechicleNumber(value: string): string {
let result = ''; let result = '';
for (let i = 0; i < (value?.length ?? 0); i++) { for (let i = 0; i < (value?.length ?? 0); i++) {
+151 -6
View File
@@ -6,19 +6,85 @@ import {
ClosingGeneralInformation, ClosingGeneralInformation,
ClosingIncomingSapronak, ClosingIncomingSapronak,
ClosingOutgoingSapronak, ClosingOutgoingSapronak,
ClosingOverhead,
ClosingSapronakCalculation,
ClosingProductionData, ClosingProductionData,
} from '@/types/api/closing'; } from '@/types/api/closing';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import {
dummyGetAllFetcher,
dummyGetSingle,
dummyGetAllIncomingSapronakFetcher,
dummyGetAllOutgoingSapronakFetcher,
dummyGetGeneralInfo,
dummyGetPerhitunganSapronak,
dummyGetOverhead,
} from '@/dummy/closing.dummy';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { ClosingSales } from '@/types/api/closing';
export class ClosingApiService extends BaseApiService<Closing, null, null> { export class ClosingApiService extends BaseApiService<Closing, null, null> {
constructor(basePath: string) { constructor(basePath: string) {
super(basePath); super(basePath);
} }
async getAllFetcher(endpoint: string): Promise<BaseApiResponse<Closing[]>> {
// TODO: Remove this block when backend is ready
// return await dummyGetAllFetcher();
// Uncomment this when backend is ready
return await httpClientFetcher<BaseApiResponse<Closing[]>>(endpoint);
}
async getSingle(id: number): Promise<BaseApiResponse<Closing> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetSingle(id);
// } catch (error) {
// if (axios.isAxiosError<BaseApiResponse<Closing>>(error)) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const getSinglePath = `${this.basePath}/${id}`;
const getSingleRes =
await httpClient<BaseApiResponse<Closing>>(getSinglePath);
return getSingleRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<Closing>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async getPenjualan(
id: number
): Promise<BaseApiResponse<ClosingSales> | undefined> {
try {
const getPenjualanPath = `${this.basePath}/${id}/penjualan`;
const getPenjualanRes =
await httpClient<BaseApiResponse<ClosingSales>>(getPenjualanPath);
return getPenjualanRes;
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<ClosingSales>>(error)) {
return error.response?.data;
}
return undefined;
}
}
async getAllIncomingSapronakFetcher( async getAllIncomingSapronakFetcher(
endpoint: string endpoint: string
): Promise<BaseApiResponse<ClosingIncomingSapronak[]>> { ): Promise<BaseApiResponse<ClosingIncomingSapronak[]>> {
// TODO: Remove this block when backend is ready
// return await dummyGetAllIncomingSapronakFetcher();
// Uncomment this when backend is ready
return await httpClientFetcher<BaseApiResponse<ClosingIncomingSapronak[]>>( return await httpClientFetcher<BaseApiResponse<ClosingIncomingSapronak[]>>(
endpoint endpoint
); );
@@ -27,19 +93,37 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
async getAllOutgoingSapronakFetcher( async getAllOutgoingSapronakFetcher(
endpoint: string endpoint: string
): Promise<BaseApiResponse<ClosingOutgoingSapronak[]>> { ): Promise<BaseApiResponse<ClosingOutgoingSapronak[]>> {
return await httpClientFetcher<BaseApiResponse<ClosingOutgoingSapronak[]>>( // TODO: Remove this block when backend is ready
endpoint return await dummyGetAllOutgoingSapronakFetcher();
);
// Uncomment this when backend is ready
// return await httpClientFetcher<BaseApiResponse<ClosingOutgoingSapronak[]>>(
// endpoint
// );
} }
async getGeneralInfo(id: number) { async getGeneralInfo(
id: number
): Promise<BaseApiResponse<ClosingGeneralInformation> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetGeneralInfo(id);
// } catch (error) {
// if (
// axios.isAxiosError<BaseApiResponse<ClosingGeneralInformation>>(error)
// ) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try { try {
const getGeneralInfoPath = `${this.basePath}/${id}`; const getGeneralInfoPath = `${this.basePath}/${id}`;
const getGeneralInfoRes = const getGeneralInfoRes =
await httpClient<BaseApiResponse<ClosingGeneralInformation>>( await httpClient<BaseApiResponse<ClosingGeneralInformation>>(
getGeneralInfoPath getGeneralInfoPath
); );
return getGeneralInfoRes; return getGeneralInfoRes;
} catch (error) { } catch (error) {
if ( if (
@@ -66,6 +150,67 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
return undefined; return undefined;
} }
} }
async getPerhitunganSapronak(
id: number
): Promise<BaseApiResponse<ClosingSapronakCalculation> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetPerhitunganSapronak(id);
// } catch (error) {
// if (
// axios.isAxiosError<BaseApiResponse<ClosingSapronakCalculation>>(error)
// ) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const path = `${this.basePath}/${id}/perhitungan_sapronak`;
return await httpClient<BaseApiResponse<ClosingSapronakCalculation>>(
path,
{
method: 'GET',
}
);
} catch (error) {
if (
axios.isAxiosError<BaseApiResponse<ClosingSapronakCalculation>>(error)
) {
return error.response?.data;
}
return undefined;
}
}
async getOverhead(
id: number
): Promise<BaseApiResponse<ClosingOverhead> | undefined> {
// TODO: Remove this block when backend is ready
// try {
// return await dummyGetOverhead(id);
// } catch (error) {
// if (axios.isAxiosError<BaseApiResponse<ClosingOverhead>>(error)) {
// return error.response?.data;
// }
// return undefined;
// }
// Uncomment this when backend is ready
try {
const path = `${this.basePath}/${id}/overhead`;
return await httpClient<BaseApiResponse<ClosingOverhead>>(path, {
method: 'GET',
});
} catch (error) {
if (axios.isAxiosError<BaseApiResponse<ClosingOverhead>>(error)) {
return error.response?.data;
}
return undefined;
}
}
} }
export const ClosingApi = new ClosingApiService('/closings'); export const ClosingApi = new ClosingApiService('/closings');
+4 -4
View File
@@ -492,8 +492,8 @@ export class ExpenseApiService extends BaseApiService<
}); });
formData.append( formData.append(
'cost_per_kandangs', 'expense_nonstocks',
JSON.stringify(payload.cost_per_kandangs) JSON.stringify(payload.expense_nonstocks)
); );
return formData; return formData;
@@ -514,8 +514,8 @@ export class ExpenseApiService extends BaseApiService<
}); });
formData.append( formData.append(
'cost_per_kandang', 'expense_nonstocks',
JSON.stringify(payload.cost_per_kandang) JSON.stringify(payload.expense_nonstocks)
); );
return formData; return formData;
+8 -1
View File
@@ -12,6 +12,7 @@ import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
InventoryAdjustment, InventoryAdjustment,
} from '@/types/api/inventory/adjustment'; } from '@/types/api/inventory/adjustment';
import { InventoryProduct } from '@/types/api/inventory/product';
export const ProductWarehouseApi = new BaseApiService< export const ProductWarehouseApi = new BaseApiService<
ProductWarehouse, ProductWarehouse,
@@ -25,8 +26,14 @@ export const MovementApi = new BaseApiService<
unknown unknown
>('/inventory/transfers'); >('/inventory/transfers');
export const inventoryAdjustmentApi = new BaseApiService< export const InventoryAdjustmentApi = new BaseApiService<
InventoryAdjustment, InventoryAdjustment,
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
unknown unknown
>('/inventory/adjustments'); >('/inventory/adjustments');
export const InventoryProductApi = new BaseApiService<
InventoryProduct,
unknown,
unknown
>('/inventory/product-stocks');
+1 -25
View File
@@ -1,4 +1,4 @@
import { BaseApiService } from './base'; import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
CreateProjectFlockPayload, CreateProjectFlockPayload,
@@ -9,8 +9,6 @@ import {
CreateRecordingPayload, CreateRecordingPayload,
Recording, Recording,
UpdateRecordingPayload, UpdateRecordingPayload,
CreateGradingPayload,
UpdateGradingPayload,
NextDayRecording, NextDayRecording,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
@@ -64,28 +62,6 @@ export class RecordingService extends BaseApiService<
}); });
} }
async createGrading(
payload: CreateGradingPayload
): Promise<BaseApiResponse<unknown> | undefined> {
return await this.customRequest<BaseApiResponse<unknown>>('gradings', {
method: 'POST',
payload,
});
}
async updateGrading(
gradingId: number,
payload: UpdateGradingPayload
): Promise<BaseApiResponse<unknown> | undefined> {
return await this.customRequest<BaseApiResponse<unknown>>(
`gradings/${gradingId}`,
{
method: 'PUT',
payload,
}
);
}
async deleteGrading( async deleteGrading(
gradingId: number gradingId: number
): Promise<BaseApiResponse<unknown> | undefined> { ): Promise<BaseApiResponse<unknown> | undefined> {
@@ -2,10 +2,187 @@ import { BaseApiService } from '@/services/api/base';
import { import {
BaseProjectFlockKandang, BaseProjectFlockKandang,
ProjectFlockKandang, ProjectFlockKandang,
ClosingProjectFlockKandangPayload,
CheckClosingResponse,
} from '@/types/api/production/project-flock-kandang'; } from '@/types/api/production/project-flock-kandang';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import axios from 'axios';
export const ProjectFlockKandangApi = new BaseApiService< export class ProjectFlockKandangService extends BaseApiService<
BaseProjectFlockKandang, BaseProjectFlockKandang,
ProjectFlockKandang, ProjectFlockKandang,
unknown unknown
>('project-flock-kandang'); > {
constructor(basePath: string = '') {
super(basePath);
}
/**
* Close or Unclose Project Flock Kandang
*/
async closing(
id: number,
payload: ClosingProjectFlockKandangPayload
): Promise<BaseApiResponse<{ message: string }> | undefined> {
try {
const path = `${this.basePath}/${id}/closing`;
const headers = {
'Content-Type': 'application/json',
...(this.header ?? {}),
};
return await httpClient<BaseApiResponse<{ message: string }>>(path, {
method: 'POST',
body: payload,
headers,
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
}
}
/**
* Check Closing Requirements for Project Flock Kandang
* TODO: Replace with actual API call when backend is ready
*/
async checkClosing(
id: number
): Promise<BaseApiResponse<CheckClosingResponse> | undefined> {
// Dummy data - replace with actual API call when backend is ready
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve({
// code: 200,
// status: 'success',
// message: 'Cek persyaratan closing kandang',
// data: {
// unfinished_expenses: id % 2 === 1 ? 2 : 0,
// stock_remaining: [
// {
// id: 1,
// product_id: 1,
// warehouse_id: 1,
// quantity: id % 2 === 1 ? 100 : 0,
// product: {
// id: 1,
// name: 'Pakan Starter',
// brand: 'Brand A',
// sku: 'PKN-STR-001',
// product_price: 15000,
// selling_price: 17000,
// tax: 0,
// expiry_period: 365,
// flags: ['active'],
// uom: {
// id: 1,
// name: 'Kg',
// created_user: {
// id: 1,
// id_user: 1,
// email: 'admin@example.com',
// name: 'Admin User',
// },
// created_at: '2024-01-01',
// updated_at: '2024-01-01',
// },
// product_category: {
// id: 1,
// name: 'Pakan',
// code: 'PKN',
// created_user: {
// id: 1,
// id_user: 1,
// email: 'admin@example.com',
// name: 'Admin User',
// },
// created_at: '2024-01-01',
// updated_at: '2024-01-01',
// },
// suppliers: [],
// created_user: {
// id: 1,
// id_user: 1,
// email: 'admin@example.com',
// name: 'Admin User',
// },
// created_at: '2024-01-01',
// updated_at: '2024-01-01',
// },
// warehouse: {
// id: 1,
// name: 'Gudang Utama',
// type: 'AREA',
// area: {
// id: 1,
// name: 'Area 1',
// },
// created_user: {
// id: 1,
// id_user: 1,
// email: 'admin@example.com',
// name: 'Admin User',
// },
// created_at: '2024-01-01',
// updated_at: '2024-01-01',
// },
// created_user: {
// id: 1,
// id_user: 1,
// email: 'admin@example.com',
// name: 'Admin User',
// },
// created_at: '2025-01-01',
// updated_at: '2025-01-01',
// },
// ],
// expenses: [
// {
// id: 1,
// po_number: 'PO-BOP-LTI-00001',
// category: 'NON-BOP',
// total: 110000,
// status: id % 2 === 1 ? 'PENGAJUAN' : 'SELESAI',
// step_name: id % 2 === 1 ? 'Approval Finance' : 'Selesai',
// step: id % 2 === 1 ? 1 : 5,
// reference_number: 'BOP-LTI-00001',
// },
// {
// id: 3,
// po_number: 'PO-BOP-LTI-00003',
// category: 'BOP',
// total: 110000,
// status: id % 2 === 1 ? 'PENGAJUAN' : 'SELESAI',
// step_name: id % 2 === 1 ? 'Approval Finance' : 'Selesai',
// step: id % 2 === 1 ? 1 : 5,
// reference_number: 'BOP-LTI-00003',
// },
// ],
// },
// });
// }, 500); // Simulate network delay
// });
// Original API call - uncomment when backend is ready
try {
const path = `${this.basePath}/${id}/closing/check`;
return await httpClient<BaseApiResponse<CheckClosingResponse>>(path, {
method: 'GET',
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<CheckClosingResponse>>(error)) {
return error.response?.data;
}
return undefined;
}
}
}
export const ProjectFlockKandangApi = new ProjectFlockKandangService(
'/production/project-flock-kandangs'
);

Some files were not shown because too many files have changed in this diff Show More