diff --git a/build-filter.sh b/build-filter.sh new file mode 100644 index 00000000..ae7081e4 --- /dev/null +++ b/build-filter.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "VERCEL_GIT_COMMIT_REF: $VERCEL_GIT_COMMIT_REF" + +if [[ "$VERCEL_GIT_COMMIT_REF" == "master" || "$VERCEL_GIT_COMMIT_REF" == "development" ]]; then +echo "✅ - Build can proceed" +exit 1 +else +echo "🛑 - Build cancelled" +exit 0 +fi diff --git a/src/app/globals.css b/src/app/globals.css index 386e7620..97be6978 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,5 +1,43 @@ @import 'tailwindcss'; @plugin "daisyui"; +@import '../styles/daisyui.css'; + +@plugin "daisyui/theme" { + name: "lti"; + default: false; + prefersdark: false; + color-scheme: "light"; + --color-base-100: oklch(98% 0.001 106.423); + --color-base-200: oklch(97% 0.001 106.424); + --color-base-300: oklch(92% 0.003 48.717); + --color-base-content: oklch(22.389% 0.031 278.072); + --color-primary: oklch(60% 0.126 221.723); + --color-primary-content: oklch(100% 0 0); + --color-secondary: oklch(52% 0.105 223.128); + --color-secondary-content: oklch(100% 0 0); + --color-accent: oklch(45% 0.085 224.283); + --color-accent-content: oklch(100% 0 0); + --color-neutral: oklch(39% 0.07 227.392); + --color-neutral-content: oklch(100% 0 0); + --color-info: oklch(58% 0.158 241.966); + --color-info-content: oklch(100% 0 0); + --color-success: oklch(62% 0.194 149.214); + --color-success-content: oklch(100% 0 0); + --color-warning: oklch(85% 0.199 91.936); + --color-warning-content: oklch(0% 0 0); + --color-error: oklch(57% 0.245 27.325); + --color-error-content: oklch(100% 0 0); + --radius-selector: 0rem; + --radius-field: 0.25rem; + --radius-box: 0.25rem; + --size-selector: 0.21875rem; + --size-field: 0.1875rem; + --border: 1px; + --depth: 0; + --noise: 0; +} + + :root { --color-primary: #1f74bf; diff --git a/src/app/inventory/adjustment/detail/layout.tsx b/src/app/inventory/adjustment/detail/layout.tsx new file mode 100644 index 00000000..b41c70f9 --- /dev/null +++ b/src/app/inventory/adjustment/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from "@/components/helper/SuspenseHelper" + +const Layout = ({ + children +}: Readonly<{ + children: React.ReactNode +}>) => { + return {children} +} + +export default Layout; \ No newline at end of file diff --git a/src/app/inventory/movement/add/page.tsx b/src/app/inventory/movement/add/page.tsx new file mode 100644 index 00000000..f883de95 --- /dev/null +++ b/src/app/inventory/movement/add/page.tsx @@ -0,0 +1,11 @@ +import MovementForm from '@/components/pages/inventory/movement/form/MovementForm'; + +const AddMovement = () => { + return ( +
+ +
+ ); +}; + +export default AddMovement; diff --git a/src/app/inventory/movement/detail/edit/page.tsx b/src/app/inventory/movement/detail/edit/page.tsx new file mode 100644 index 00000000..bde4ece1 --- /dev/null +++ b/src/app/inventory/movement/detail/edit/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import MovementForm from '@/components/pages/inventory/movement/form/MovementForm'; +import { MovementApi } from '@/services/api/inventory'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const MovementEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const movementId = searchParams.get('movementId'); + + const { data: movement, isLoading: isLoadingMovement } = useSWR( + movementId, + (id: number) => MovementApi.getSingle(id) + ); + + if (!movementId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingMovement && (!movement || isResponseError(movement))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingMovement && ( + + )} + {!isLoadingMovement && isResponseSuccess(movement) && ( + + )} +
+ ); +}; + +export default MovementEdit; diff --git a/src/app/inventory/movement/detail/layout.tsx b/src/app/inventory/movement/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/inventory/movement/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/inventory/movement/detail/page.tsx b/src/app/inventory/movement/detail/page.tsx new file mode 100644 index 00000000..5947cd1b --- /dev/null +++ b/src/app/inventory/movement/detail/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import MovementForm from '@/components/pages/inventory/movement/form/MovementForm'; +import { MovementApi } from '@/services/api/inventory'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const MovementDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const movementId = searchParams.get('movementId'); + + const { data: movement, isLoading: isLoadingMovement } = useSWR( + movementId, + (id: number) => MovementApi.getSingle(id) + ); + + if (!movementId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingMovement && (!movement || isResponseError(movement))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingMovement && ( + + )} + {!isLoadingMovement && isResponseSuccess(movement) && ( + + )} +
+ ); +}; + +export default MovementDetail; diff --git a/src/app/inventory/movement/page.tsx b/src/app/inventory/movement/page.tsx new file mode 100644 index 00000000..a2c25612 --- /dev/null +++ b/src/app/inventory/movement/page.tsx @@ -0,0 +1,11 @@ +import MovementTable from '@/components/pages/inventory/movement/MovementTable'; + +const Movement = () => { + return ( +
+ +
+ ); +}; + +export default Movement; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef28da38..c19b8a77 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -28,7 +28,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/src/app/master-data/customer/detail/layout.tsx b/src/app/master-data/customer/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/customer/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/master-data/flock/add/page.tsx b/src/app/master-data/flock/add/page.tsx new file mode 100644 index 00000000..5ee3958e --- /dev/null +++ b/src/app/master-data/flock/add/page.tsx @@ -0,0 +1,11 @@ +import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; + +const AddFlock = () => { + return ( +
+ +
+ ); +} + +export default AddFlock; diff --git a/src/app/master-data/flock/detail/edit/page.tsx b/src/app/master-data/flock/detail/edit/page.tsx new file mode 100644 index 00000000..c9651727 --- /dev/null +++ b/src/app/master-data/flock/detail/edit/page.tsx @@ -0,0 +1,49 @@ +'use client' + +import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { FlockApi } from "@/services/api/master-data"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const FlockEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const flockId = searchParams.get('flockId'); + + // Fetch Data + const { data: flock, isLoading: isLoadingFlock } = useSWR( + flockId, + (id: number) => FlockApi.getSingle(id) + ); + + if (!flockId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingFlock && (!flock || isResponseError(flock))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFlock && ( + + )} + {!isLoadingFlock && isResponseSuccess(flock) && ( + + )} +
+ ); +} + +export default FlockEdit; \ No newline at end of file diff --git a/src/app/master-data/flock/detail/layout.tsx b/src/app/master-data/flock/detail/layout.tsx new file mode 100644 index 00000000..b41c70f9 --- /dev/null +++ b/src/app/master-data/flock/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from "@/components/helper/SuspenseHelper" + +const Layout = ({ + children +}: Readonly<{ + children: React.ReactNode +}>) => { + return {children} +} + +export default Layout; \ No newline at end of file diff --git a/src/app/master-data/flock/detail/page.tsx b/src/app/master-data/flock/detail/page.tsx new file mode 100644 index 00000000..8a805911 --- /dev/null +++ b/src/app/master-data/flock/detail/page.tsx @@ -0,0 +1,46 @@ +'use client' + +import FlockForm from "@/components/pages/master-data/flock/form/FlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { FlockApi } from "@/services/api/master-data"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const FlockDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const flockId = searchParams.get('flockId'); + + // Fetch Data + const { data: flock, isLoading: isLoadingFlock } = useSWR(flockId, (id: number) => FlockApi.getSingle(id)); + + if(!flockId){ + router.back(); + + return ( +
+ +
+ ); + } + + if(!isLoadingFlock && (!flock || isResponseError(flock))){ + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFlock && ( + + )} + {!isLoadingFlock && isResponseSuccess(flock) && ( + + )} +
+ ); +} + +export default FlockDetail; \ No newline at end of file diff --git a/src/app/master-data/flock/page.tsx b/src/app/master-data/flock/page.tsx new file mode 100644 index 00000000..b317091a --- /dev/null +++ b/src/app/master-data/flock/page.tsx @@ -0,0 +1,11 @@ +import FlockTable from "@/components/pages/master-data/flock/FlocksTable"; + +const Flock = () => { + return ( +
+ +
+ ); +} + +export default Flock; diff --git a/src/app/master-data/supplier/detail/layout.tsx b/src/app/master-data/supplier/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/supplier/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/production/chickin/add/layout.tsx b/src/app/production/chickin/add/layout.tsx new file mode 100644 index 00000000..b41c70f9 --- /dev/null +++ b/src/app/production/chickin/add/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from "@/components/helper/SuspenseHelper" + +const Layout = ({ + children +}: Readonly<{ + children: React.ReactNode +}>) => { + return {children} +} + +export default Layout; \ No newline at end of file diff --git a/src/app/production/chickin/add/page.tsx b/src/app/production/chickin/add/page.tsx new file mode 100644 index 00000000..e7fa4c24 --- /dev/null +++ b/src/app/production/chickin/add/page.tsx @@ -0,0 +1,249 @@ +'use client'; + +import Button from '@/components/Button'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import Modal, { useModal } from '@/components/Modal'; +import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm'; +import Table from '@/components/Table'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { cn } from '@/lib/helper'; +import { ProjectFlockApi } from '@/services/api/production'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { BaseApiResponse } from '@/types/api/api-general'; +import { Kandang } from '@/types/api/master-data/kandang'; +import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; +import { Icon } from '@iconify/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import useSWR from 'swr'; + +const AddChickin = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + const projectFlockId = searchParams.get('projectFlockId'); + + // Tables Props + const { state: tableFilterState } = useTableFilter({ + initial: { search: '' }, + paramMap: { page: 'page', pageSize: 'limit' }, + }); + + // States + const [selectedKandang, setSelectedKandang] = useState( + undefined + ); + const [searchProjectFlock, setSearchProjectFlock] = useState(''); + + // Fetch Data + const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR( + projectFlockId, + (id: number) => ProjectFlockApi.getSingle(id) + ); + const { data: listProjectFlock, isLoading: isLoadingListProjectFlock } = + useSWR(`${ProjectFlockApi.basePath}?${new URLSearchParams({ + search: searchProjectFlock, + }).toString()}`, ProjectFlockApi.getAllFetcher); + + const getProjectFlockKandangUrl = `/kandangs/lookup`; + const { + data: projectFlockKandang, + isLoading: isLoadingProjectFlockKandang, + mutate: refreshProjectFlockKandang, + } = useSWR(getProjectFlockKandangUrl, () => + ProjectFlockApi.customRequest, 'GET'>( + getProjectFlockKandangUrl, + { + method: 'GET', + params: { + project_flock_id: projectFlockId ?? 0, + kandang_id: selectedKandang?.id, + }, + } + ) + ); + + // Mapping Options + const options = isResponseSuccess(listProjectFlock) + ? listProjectFlock?.data.map((projectFlock) => { + return { + value: projectFlock.id, + label: `${projectFlock?.flock.name} - ${projectFlock?.category} - Periode ${projectFlock.period}` , + }; + }) + : []; + + const chickinModal = useModal(); + + // Use Effect + useEffect(() => { + refreshProjectFlockKandang(); + }, [selectedKandang, refreshProjectFlockKandang]); + + if (!projectFlockId) { + router.back(); + + return ( +
+ +
+ ); + } + + if ( + !isLoadingProjectFlock && + (!projectFlock || isResponseError(projectFlock)) + ) { + router.replace('/404'); + return; + } + + // Handle Function + const handleChickinClick = (kandang: Kandang) => { + setSelectedKandang(kandang); + refreshProjectFlockKandang(); + chickinModal.openModal(); + }; + const handleAfterSubmit = () => { + refreshProjectFlockKandang(); + chickinModal.closeModal(); + router.push('/production/chickin'); + }; + + return ( + <> + {isResponseSuccess(projectFlock) && ( + <> +
+
+ + +
+
+ + router.push( + `/production/chickin/add?projectFlockId=${ + (val as OptionType | null)?.value + }` + ) + } + onInputChange={(val) => { + setSearchProjectFlock(val); + }} + /> +
+
+
+ + data={projectFlock.data.kandangs} + columns={[ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama Kandang', + }, + { + header: 'Aksi', + cell: (props) => { + return ( + <> + + + ); + }, + }, + ]} + page={undefined} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(projectFlock) && + projectFlock.data.kandangs?.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', + }} + /> +
+ +
+

+ Chickin Kandang - {selectedKandang?.name} +

+ +
+ {isResponseSuccess(projectFlockKandang) && + !isLoadingProjectFlockKandang && ( + + )} +
+ + )} + + ); +}; + +export default AddChickin; diff --git a/src/app/production/chickin/page.tsx b/src/app/production/chickin/page.tsx new file mode 100644 index 00000000..ad662f65 --- /dev/null +++ b/src/app/production/chickin/page.tsx @@ -0,0 +1,10 @@ +import ChickinTable from "@/components/pages/production/chickin/ChickinTable"; + +const Chickin = () => { + return ( +
+ +
+ ); +} +export default Chickin; \ No newline at end of file diff --git a/src/app/production/project-flock/add/page.tsx b/src/app/production/project-flock/add/page.tsx new file mode 100644 index 00000000..60141d80 --- /dev/null +++ b/src/app/production/project-flock/add/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; + +const AddProjectFlock = () => { + return ( +
+ +
+ ); +} + +export default AddProjectFlock; \ No newline at end of file diff --git a/src/app/production/project-flock/detail/edit/page.tsx b/src/app/production/project-flock/detail/edit/page.tsx new file mode 100644 index 00000000..858d0ca8 --- /dev/null +++ b/src/app/production/project-flock/detail/edit/page.tsx @@ -0,0 +1,46 @@ +'use client' + + +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { ProjectFlockApi } from "@/services/api/production"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const ProjectFlockEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const projectFlockId = searchParams.get("projectFlockId"); + + const { data: projectFlock, isLoading: isLoadingCostumer } = useSWR( + projectFlockId, + (id: number) => ProjectFlockApi.getSingle(id) + ); + + if(!projectFlockId){ + router.back(); + + return ( +
+ +
+ ); + } + + if(!isLoadingCostumer && (!projectFlock || isResponseError(projectFlock))){ + router.replace("/404"); + return; + } + + return ( +
+ {isLoadingCostumer && } + {!isLoadingCostumer && isResponseSuccess(projectFlock) && ( + + )} +
+ ) +} + +export default ProjectFlockEdit; \ No newline at end of file diff --git a/src/app/production/project-flock/detail/layout.tsx b/src/app/production/project-flock/detail/layout.tsx new file mode 100644 index 00000000..b41c70f9 --- /dev/null +++ b/src/app/production/project-flock/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from "@/components/helper/SuspenseHelper" + +const Layout = ({ + children +}: Readonly<{ + children: React.ReactNode +}>) => { + return {children} +} + +export default Layout; \ No newline at end of file diff --git a/src/app/production/project-flock/detail/page.tsx b/src/app/production/project-flock/detail/page.tsx new file mode 100644 index 00000000..5efe83d8 --- /dev/null +++ b/src/app/production/project-flock/detail/page.tsx @@ -0,0 +1,46 @@ +'use client' + + +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm"; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import { ProjectFlockApi } from "@/services/api/production"; +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; + +const ProjectFlockDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const projectFlockId = searchParams.get("projectFlockId"); + + const { data: projectFlock, isLoading: isLoadingCostumer } = useSWR( + projectFlockId, + (id: number) => ProjectFlockApi.getSingle(id) + ); + + if(!projectFlockId){ + router.back(); + + return ( +
+ +
+ ); + } + + if(!isLoadingCostumer && (!projectFlock || isResponseError(projectFlock))){ + router.replace("/404"); + return; + } + + return ( +
+ {isLoadingCostumer && } + {!isLoadingCostumer && isResponseSuccess(projectFlock) && ( + + )} +
+ ) +} + +export default ProjectFlockDetail; \ No newline at end of file diff --git a/src/app/production/project-flock/page.tsx b/src/app/production/project-flock/page.tsx new file mode 100644 index 00000000..fdb8775d --- /dev/null +++ b/src/app/production/project-flock/page.tsx @@ -0,0 +1,12 @@ +import ProjectFlockForm from "@/components/pages/production/project-flock/form/ProjectFlockForm" +import ProjectFlockTable from "@/components/pages/production/project-flock/ProjectFlockTable"; + +const ProjectFlock = () => { + return ( +
+ +
+ ); +} + +export default ProjectFlock; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index c67a29c2..7cad5b58 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,7 +1,5 @@ import react from 'react'; - import Link from 'next/link'; - import { cn } from '@/lib/helper'; import { Color } from '@/types/theme'; @@ -10,6 +8,8 @@ interface ButtonProps extends react.ComponentProps<'button'> { color?: Color; href?: string; isLoading?: boolean; + target?: string; + rel?: string; } const Button = ({ @@ -22,6 +22,8 @@ const Button = ({ className, disabled, onClick, + target, + rel, ...props }: ButtonProps) => { const btnBaseClassName = cn( @@ -43,7 +45,7 @@ const Button = ({ 'btn-warning': color === 'warning', 'btn-error': color === 'error', }, - 'h-fit justify-center items-center gap-2 rounded-xl p-2 text-base transition-all' + 'h-fit justify-center items-center gap-2 rounded p-2 text-base transition-all' ); return ( @@ -68,6 +70,8 @@ const Button = ({ {href && ( { + const tooltipBaseClassName = cn('tooltip', { + 'tooltip-open': typeof open === 'boolean' && open, + + 'tooltip-top': position === 'top', + 'tooltip-bottom': position === 'bottom', + 'tooltip-left': position === 'left', + 'tooltip-right': position === 'right', + + 'tooltip-primary': color === 'primary', + 'tooltip-secondary': color === 'secondary', + 'tooltip-accent': color === 'accent', + 'tooltip-neutral': color === 'neutral', + 'tooltip-info': color === 'info', + 'tooltip-success': color === 'success', + 'tooltip-warning': color === 'warning', + 'tooltip-error': color === 'error', + }); + return ( +
+
+ {content} +
+ + {children} +
+ ); +}; + +export default Tooltip; diff --git a/src/components/helper/form/FormActions.tsx b/src/components/helper/form/FormActions.tsx new file mode 100644 index 00000000..92c2a92c --- /dev/null +++ b/src/components/helper/form/FormActions.tsx @@ -0,0 +1,87 @@ +import { Icon } from '@iconify/react'; +import { FormikContextType } from 'formik'; +import Button from '@/components/Button'; +import { cn } from '@/lib/helper'; + +interface FormActionsProps { + type: 'add' | 'edit' | 'detail'; + formik: FormikContextType; + editUrl?: string; + onDelete?: () => void; + disableSubmit?: boolean; +} + +export const FormActions = ({ + type, + formik, + editUrl, + onDelete, + disableSubmit = false, +}: FormActionsProps) => { + return ( +
+ {type !== 'add' && onDelete && ( +
+ + {type !== 'edit' && editUrl && ( + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ ); +}; diff --git a/src/components/helper/form/FormHeader.tsx b/src/components/helper/form/FormHeader.tsx new file mode 100644 index 00000000..ebc1d7ae --- /dev/null +++ b/src/components/helper/form/FormHeader.tsx @@ -0,0 +1,24 @@ +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; + +interface FormHeaderProps { + type: 'add' | 'edit' | 'detail'; + title: string; + backUrl: string; +} + +export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => { + return ( +
+ +

+ {type === 'add' && `Tambah ${title}`} + {type === 'edit' && `Edit ${title}`} + {type === 'detail' && `Detail ${title}`} +

+
+ ); +}; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx new file mode 100644 index 00000000..0ef80fee --- /dev/null +++ b/src/components/input/DateInput.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { + ChangeEventHandler, + FocusEventHandler, + ReactNode, +} from 'react'; + +import { cn } from '@/lib/helper'; + +export interface DateInputProps { + label?: string; + bottomLabel?: string; + name: string; + value?: string; + placeholder?: string; + min?: string; + max?: string; + className?: { + wrapper?: string; + label?: string; + inputWrapper?: string; + input?: string; + }; + isError?: boolean; + isValid?: boolean; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + isLoading?: boolean; + errorMessage?: string; + startAdornment?: ReactNode; + endAdornment?: ReactNode; + onChange?: ChangeEventHandler; + onBlur?: FocusEventHandler; +} + +const DateInput = ({ + label, + bottomLabel, + name, + value, + placeholder, + min, + max, + className, + isError, + isValid, + errorMessage, + startAdornment, + endAdornment, + disabled = false, + required = false, + onChange, + onBlur, + readOnly = false, + isLoading = false, +}: DateInputProps) => { + return ( +
+ {label && ( + + )} + +
+ {startAdornment && startAdornment} + + + + {(isLoading || endAdornment) && ( +
+ {isLoading && } + {endAdornment && endAdornment} +
+ )} +
+ + {!isError && bottomLabel && ( +

{bottomLabel}

+ )} + {isError && errorMessage && ( +

{errorMessage}

+ )} +
+ ); +}; + +export default DateInput; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 43a3f622..b35ad7dd 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - ComponentType, - ReactNode, - useEffect, - useMemo, - useState, -} from 'react'; +import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; import Select, { OptionProps, GroupBase, @@ -98,10 +92,7 @@ const SelectInput = (props: SelectInputProps) => { return { ...base, IndicatorSeparator: () => null }; }, [isAnimated]); - const internalInputChangeHandler = ( - val: string, - meta: InputActionMeta - ) => { + const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'menu-close') setInternalInputValue(''); }; @@ -113,9 +104,7 @@ const SelectInput = (props: SelectInputProps) => { const SelectComponent = createables ? CreatableSelect : Select; /** 🎯 handleChange tanpa any */ - const handleChange = ( - val: MultiValue | SingleValue - ): void => { + const handleChange = (val: MultiValue | SingleValue | null): void => { if (!val) { onChange?.(null); return; @@ -145,15 +134,15 @@ const SelectInput = (props: SelectInputProps) => { > {label} {required && ( - - * + + * )} )} > - instanceId="select" + instanceId='select' value={value ?? (isMulti ? [] : null)} onChange={handleChange} options={options} @@ -225,9 +214,9 @@ const SelectInput = (props: SelectInputProps) => { }} /> - {isError &&

{errorMessage}

} + {isError &&

{errorMessage}

} {!isError && bottomLabel && ( -

{bottomLabel}

+

{bottomLabel}

)} ); diff --git a/src/components/pages/ApprovalSteps.tsx b/src/components/pages/ApprovalSteps.tsx new file mode 100644 index 00000000..4022e254 --- /dev/null +++ b/src/components/pages/ApprovalSteps.tsx @@ -0,0 +1,64 @@ +import { Icon } from '@iconify/react'; +import Steps from '@/components/steps/Steps'; +import StepItem from '@/components/steps/StepItem'; +import Tooltip from '@/components/Tooltip'; + +import { formatDate } from '@/lib/helper'; +import { ApprovalsLine } from '@/types/api/api-general'; + +interface ApprovalStepsProps { + approvals: ApprovalsLine; +} + +const ApprovalSteps = ({ approvals }: ApprovalStepsProps) => { + return ( + + {approvals.map((approval, idx) => { + const stepItemColor = + approval.status === 'approved' + ? 'success' + : approval.status === 'rejected' + ? 'error' + : undefined; + + const stepItemIcon = + approval.status === 'approved' + ? 'material-symbols:check-rounded' + : approval.status === 'rejected' + ? 'material-symbols:close-rounded' + : 'bxs:hourglass'; + + return ( + + {formatDate(approval.date, 'YYYY-MM-DD')} + Oleh: {approval.action_by} + Catatan: {approval.notes} + + } + > + + + ) + } + > + {approval.role} + + ); + })} + + ); +}; + +export default ApprovalSteps; diff --git a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx index 1bb1692d..9a19ced1 100644 --- a/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx +++ b/src/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.tsx @@ -13,7 +13,7 @@ import toast from 'react-hot-toast'; import { InventoryAdjustmentFormSchema, InventoryAdjustmentFormValues, -} from './InventoryAdjustmentForm.schema'; +} from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema'; import useSWR from 'swr'; import { ProductApi, diff --git a/src/components/pages/inventory/movement/MovementTable.tsx b/src/components/pages/inventory/movement/MovementTable.tsx new file mode 100644 index 00000000..61be40f8 --- /dev/null +++ b/src/components/pages/inventory/movement/MovementTable.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useState } from 'react'; +import useSWR from 'swr'; +import { SortingState } from '@tanstack/react-table'; + +import Table from '@/components/Table'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { Movement } from '@/types/api/inventory/movement'; +import { MovementApi } from '@/services/api/inventory'; +import { cn } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { TableToolbar } from '@/components/table/TableToolbar'; +import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; +import { OptionType } from '@/components/input/SelectInput'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import { TableRowOptions } from '@/components/table/TableRowOptions'; + +const MovementTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '' }, + paramMap: { page: 'page', pageSize: 'limit' }, + }); + + const [sorting, setSorting] = useState([]); + const [selectedMovement, setSelectedMovement] = useState< + Movement | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const deleteModal = useModal(); + + const { + data: movements, + isLoading, + mutate: refreshMovements, + } = useSWR( + `${MovementApi.basePath}${getTableFilterQueryString()}`, + MovementApi.getAllFetcher + ); + + const searchChangeHandler = (e: React.ChangeEvent) => { + updateFilter('search', e.target.value); + setPage(1); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + try { + await MovementApi.delete(selectedMovement?.id as number); + refreshMovements(); + deleteModal.closeModal(); + } finally { + setIsDeleteLoading(false); + } + }; + + return ( +
+
+ + +
+ + + data={isResponseSuccess(movements) ? movements?.data : []} + columns={[ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorFn: (row) => row.source_warehouse?.name, + header: 'Gudang Asal', + }, + { + accessorFn: (row) => row.destination_warehouse?.name, + header: 'Gudang Tujuan', + }, + { + accessorKey: 'transfer_reason', + header: 'Catatan', + }, + { + accessorKey: 'transfer_date', + header: 'Tanggal', + cell: (props) => + new Date(props.row.original.transfer_date).toLocaleDateString( + 'id-ID' + ), + }, + { + accessorFn: (row) => { + const totalCost = row.deliveries?.reduce( + (sum, d) => sum + (d.shipping_cost_total || 0), + 0 + ); + return totalCost?.toLocaleString('id-ID'); + }, + header: 'Biaya Pengiriman', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = + props.table.getPaginationRowModel().rows.length; + const currentPageRows = + props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedMovement(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(movements) ? movements?.meta?.page : 0} + totalItems={ + isResponseSuccess(movements) ? movements?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(movements) && movements?.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', + }} + /> + + +
+ ); +}; + +export default MovementTable; diff --git a/src/components/pages/inventory/movement/form/MovementForm.schema.ts b/src/components/pages/inventory/movement/form/MovementForm.schema.ts new file mode 100644 index 00000000..5df66930 --- /dev/null +++ b/src/components/pages/inventory/movement/form/MovementForm.schema.ts @@ -0,0 +1,218 @@ +import * as Yup from 'yup'; +import { Movement } from '@/types/api/inventory/movement'; + +export type ProductSchema = { + product: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number; +}; + +export type DeliverySchema = { + delivery_cost?: number | undefined; + delivery_cost_per_item?: number | undefined; + document?: File | string | null; + document_path?: string | null; + driver_name: string; + vehicle_plate: string; + supplier: { + value: number; + label: string; + } | null; + supplier_id: number; + products: { + product: { + value: number; + label: string; + } | null; + product_id: number; + product_qty: number; + }[]; +}; + +const ProductObjectSchema: Yup.ObjectSchema = Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + product_qty: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), +}); + +const DeliveryProductObjectSchema = Yup.object({ + product: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + product_id: Yup.number().required('Produk wajib diisi!'), + product_qty: Yup.number() + .required('Qty wajib diisi!') + .min(1, 'Qty minimal 1!') + .typeError('Qty harus berupa angka!'), +}); + +const DeliveryObjectSchema: Yup.ObjectSchema = Yup.object({ + delivery_cost: Yup.number() + .transform((value) => (isNaN(value) || value === 0 ? undefined : value)) + .min(1, 'Biaya minimal 1!') + .typeError('Biaya harus berupa angka!') + .test( + 'one-of-cost-fields', + 'Biaya pengiriman atau biaya per item wajib diisi!', + function (value) { + const { delivery_cost_per_item } = this.parent; + return ( + (value !== undefined && value > 0) || + (delivery_cost_per_item !== undefined && delivery_cost_per_item > 0) + ); + } + ), + delivery_cost_per_item: Yup.number() + .transform((value) => (isNaN(value) || value === 0 ? undefined : value)) + .min(1, 'Biaya per item minimal 1!') + .typeError('Biaya per item harus berupa angka!') + .test( + 'one-of-cost-fields', + 'Biaya pengiriman atau biaya per item wajib diisi!', + function (value) { + const { delivery_cost } = this.parent; + return ( + (value !== undefined && value > 0) || + (delivery_cost !== undefined && delivery_cost > 0) + ); + } + ), + document_path: Yup.string().optional(), + document_index: Yup.number().optional(), + document: Yup.mixed() + .nullable() + .test('fileSize', 'Ukuran dokumen maksimal 2 MB', (value) => { + if (!value) return true; + if (typeof value === 'string') return true; + if (value instanceof File) return value.size <= 2 * 1024 * 1024; + return false; + }), + driver_name: Yup.string().required('Nama sopir wajib diisi!'), + vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), + supplier: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + }).nullable(), + supplier_id: Yup.number().required('Supplier wajib diisi!'), + products: Yup.array() + .of(DeliveryProductObjectSchema) + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), +}); + +export const MovementFormSchema = Yup.object({ + transfer_reason: Yup.string().required('Alasan transfer wajib diisi!'), + transfer_date: Yup.string().required('Tanggal transfer wajib diisi!'), + source_warehouse: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), + }).nullable(), + source_warehouse_id: Yup.number() + .required('Gudang asal wajib diisi!') + .typeError('Gudang asal wajib diisi!'), + destination_warehouse: Yup.object({ + value: Yup.number().min(1).required(), + label: Yup.string().required(), + area: Yup.string().optional(), + location: Yup.string().optional(), + }).nullable(), + destination_warehouse_id: Yup.number() + .required('Gudang tujuan wajib diisi!') + .typeError('Gudang tujuan wajib diisi!'), + products: Yup.array() + .of(ProductObjectSchema) + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), + deliveries: Yup.array() + .of(DeliveryObjectSchema) + .min(1, 'Minimal harus ada 1 pengiriman!') + .required('Pengiriman wajib diisi!'), +}); + +export const UpdateMovementFormSchema = MovementFormSchema; + +export type MovementFormValues = Yup.InferType; + +export const getMovementFormInitialValues = ( + initialValues?: Movement +): MovementFormValues => { + const detailIdToProductId = new Map(); + initialValues?.details?.forEach((detail) => { + detailIdToProductId.set(detail.id, { + id: detail.product.id, + name: detail.product.name, + }); + }); + + return { + transfer_reason: initialValues?.transfer_reason ?? '', + transfer_date: initialValues?.transfer_date ?? '', + source_warehouse: initialValues?.source_warehouse + ? { + value: initialValues.source_warehouse.id, + label: initialValues.source_warehouse.name, + area: initialValues.source_warehouse.area?.name ?? undefined, + location: initialValues.source_warehouse.location?.name ?? undefined, + } + : null, + source_warehouse_id: initialValues?.source_warehouse?.id ?? 0, + destination_warehouse: initialValues?.destination_warehouse + ? { + value: initialValues.destination_warehouse.id, + label: initialValues.destination_warehouse.name, + area: initialValues.destination_warehouse.area?.name ?? undefined, + location: + initialValues.destination_warehouse.location?.name ?? undefined, + } + : null, + destination_warehouse_id: initialValues?.destination_warehouse?.id ?? 0, + products: + initialValues?.details?.map((detail) => ({ + product: { + value: detail.product.id, + label: detail.product.name, + }, + product_id: detail.product.id, + product_qty: detail.quantity, + })) ?? [], + deliveries: + initialValues?.deliveries?.map((d) => ({ + delivery_cost: d.shipping_cost_total ?? undefined, + delivery_cost_per_item: d.shipping_cost_item ?? undefined, + document_number: d.document_number ?? '', + document: d.document_path ?? null, + document_path: d.document_path ?? null, + driver_name: d.driver_name ?? '', + vehicle_plate: d.vehicle_plate ?? '', + supplier: d.supplier + ? { value: d.supplier.id, label: d.supplier.name } + : null, + supplier_id: d.supplier?.id ?? 0, + products: + d.items?.map((item) => { + const productData = detailIdToProductId.get( + item.stock_transfer_detail_id + ); + return { + product: productData + ? { value: productData.id, label: productData.name } + : null, + product_id: productData?.id ?? 0, + product_qty: item.quantity, + }; + }) ?? [], + })) ?? [], + }; +}; diff --git a/src/components/pages/inventory/movement/form/MovementForm.tsx b/src/components/pages/inventory/movement/form/MovementForm.tsx new file mode 100644 index 00000000..34027209 --- /dev/null +++ b/src/components/pages/inventory/movement/form/MovementForm.tsx @@ -0,0 +1,1355 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useFormik } from 'formik'; +import useSWR from 'swr'; + +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import TextInput from '@/components/input/TextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import { FormActions } from '@/components/helper/form/FormActions'; +import { + CreateMovementPayload, + Movement, +} from '@/types/api/inventory/movement'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { + MovementFormSchema, + MovementFormValues, + UpdateMovementFormSchema, + getMovementFormInitialValues, + ProductSchema, + DeliverySchema, +} from '@/components/pages/inventory/movement/form/MovementForm.schema'; +import { useMovementFormHandlers } from './useMovementFormHandlers'; +import { SupplierApi, WarehouseApi } from '@/services/api/master-data'; +import { ProductWarehouseApi } from '@/services/api/inventory'; +import { toast } from 'react-hot-toast'; +import FileInput from '@/components/input/FileInput'; + +interface MovementFormProps { + type?: 'add' | 'edit' | 'detail'; + initialValues?: Movement; +} + +const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => { + const [, setMovementFormErrorMessage] = useState(''); + const [ + productWarehouseSelectInputValue, + setProductWarehouseSelectInputValue, + ] = useState(''); + const [selectedProducts, setSelectedProducts] = useState([]); + const [selectedDeliveries, setSelectedDeliveries] = useState([]); + + const { + deleteModal, + movementFormErrorMessage, + isDeleteLoading, + createMovementHandler, + updateMovementHandler, + deleteMovementClickHandler, + confirmationModalDeleteClickHandler, + } = useMovementFormHandlers(initialValues?.id); + + const formikInitialValues = useMemo( + () => getMovementFormInitialValues(initialValues), + [initialValues] + ); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: + type === 'edit' ? UpdateMovementFormSchema : MovementFormSchema, + validateOnChange: true, + validateOnBlur: true, + validateOnMount: false, + enableReinitialize: true, + onSubmit: async (values) => { + setMovementFormErrorMessage(''); + const documents: File[] = []; + const deliveriesPayload = values.deliveries.map((d, idx) => { + let documentIndex = 0; + + if (d.document && d.document instanceof File) { + documents.push(d.document); + documentIndex = documents.length - 1; + } else { + } + + return { + delivery_cost: d.delivery_cost ?? 0, + delivery_cost_per_item: d.delivery_cost_per_item ?? 0, + document_index: documentIndex, + document_path: d.document_path, + driver_name: d.driver_name, + vehicle_plate: d.vehicle_plate, + supplier_id: d.supplier_id, + products: d.products.map((p) => ({ + product_id: p.product_id, + product_qty: p.product_qty, + })), + }; + }); + + const payload: CreateMovementPayload = { + transfer_reason: values.transfer_reason, + transfer_date: values.transfer_date, + source_warehouse_id: values.source_warehouse_id, + destination_warehouse_id: values.destination_warehouse_id, + products: values.products.map((p) => ({ + product_id: p.product_id, + product_qty: p.product_qty, + })), + deliveries: deliveriesPayload, + }; + + switch (type) { + case 'add': + await createMovementHandler(payload, documents); + break; + case 'edit': + await updateMovementHandler( + initialValues?.id as number, + payload, + documents + ); + break; + } + }, + }); + + const addProduct = () => { + const newProducts = [ + ...(formik.values.products || []), + { + product: null, + product_id: 0, + product_qty: 0, + }, + ]; + formik.setFieldValue('products', newProducts); + }; + + const removeProduct = useCallback( + (i: number) => { + const updatedProducts = + formik.values.products?.reduce((acc: ProductSchema[], item, index) => { + if (index !== i) { + acc.push(item); + } + return acc; + }, []) ?? []; + + formik.setFieldValue('products', updatedProducts); + }, + [formik] + ); + + const bulkRemoveProduct = useCallback(() => { + const updatedProducts = + formik.values.products?.filter( + (_, idx) => !selectedProducts.includes(idx) + ) ?? []; + formik.setFieldValue('products', updatedProducts); + setSelectedProducts([]); + }, [formik, selectedProducts]); + + const addDelivery = () => { + formik.setFieldValue('deliveries', [ + ...(formik.values.deliveries || []), + { + delivery_cost: undefined, + delivery_cost_per_item: undefined, + document: null, + driver_name: '', + vehicle_plate: '', + supplier: null, + supplier_id: 0, + products: [ + { + product: null, + product_id: 0, + product_qty: 0, + }, + ], + }, + ]); + }; + + const removeDelivery = useCallback( + (i: number) => { + const updatedDeliveries = + formik.values.deliveries?.reduce( + (acc: DeliverySchema[], item, index) => { + if (index !== i) { + acc.push(item); + } + return acc; + }, + [] + ) ?? []; + + formik.setFieldValue('deliveries', updatedDeliveries); + }, + [formik] + ); + + const bulkRemoveDelivery = useCallback(() => { + const updatedDeliveries = + formik.values.deliveries?.filter( + (_, idx) => !selectedDeliveries.includes(idx) + ) ?? []; + formik.setFieldValue('deliveries', updatedDeliveries); + setSelectedDeliveries([]); + }, [formik, selectedDeliveries]); + + const isRepeaterInputError = ( + arrayName: T, + column: T extends 'products' ? keyof ProductSchema : keyof DeliverySchema, + idx: number + ) => { + if ( + !formik.touched[arrayName] || + !Array.isArray(formik.touched[arrayName]) + ) { + return { + isError: false, + errorMessage: undefined, + }; + } + + const touchedField = formik.touched[arrayName]?.[idx]?.[column as string]; + const errorField = formik.errors[arrayName]?.[idx] as Record< + string, + string + >; + + return { + isError: touchedField && Boolean(errorField?.[column as string]), + errorMessage: touchedField ? errorField?.[column as string] : undefined, + }; + }; + + const isDeliveryProductInputError = ( + deliveryIdx: number, + productIdx: number, + column: keyof DeliverySchema['products'][number] + ) => { + const touchedDelivery = formik.touched.deliveries?.[deliveryIdx]; + const errorDelivery = formik.errors.deliveries?.[deliveryIdx] as + | { products: Array> } + | undefined; + + if (!touchedDelivery?.products || !errorDelivery?.products) { + return { + isError: false, + errorMessage: undefined, + }; + } + + const touchedField = touchedDelivery.products[productIdx]?.[column]; + const errorField = errorDelivery.products[productIdx]?.[column]; + + return { + isError: Boolean(touchedField && errorField), + errorMessage: touchedField ? errorField : undefined, + }; + }; + + interface WarehouseOptionType extends OptionType { + area?: string; + location?: string; + } + + interface ProductWarehouseOptionType extends OptionType { + product_id: number; + warehouse_id: number; + warehouse_name: string; + quantity: number; + } + + const allProductWarehousesUrl = `${ProductWarehouseApi.basePath}`; + const { data: allProductWarehouses } = useSWR( + allProductWarehousesUrl, + ProductWarehouseApi.getAllFetcher + ); + + const warehouseStockMap = useMemo(() => { + if (!isResponseSuccess(allProductWarehouses)) return new Map(); + + const stockMap = new Map< + number, + { totalQty: number; productCount: number } + >(); + + allProductWarehouses.data.forEach((pw) => { + const warehouseId = pw.warehouse.id; + const existing = stockMap.get(warehouseId) || { + totalQty: 0, + productCount: 0, + }; + + stockMap.set(warehouseId, { + totalQty: existing.totalQty + pw.quantity, + productCount: existing.productCount + 1, + }); + }); + + return stockMap; + }, [allProductWarehouses]); + + // Warehouse selection + const [warehouseSelectInputValue, setWarehouseSelectInputValue] = + useState(''); + const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`; + const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( + warehousesUrl, + WarehouseApi.getAllFetcher + ); + const warehouseOptions = isResponseSuccess(warehouses) + ? warehouses?.data.map((w) => { + const stockInfo = warehouseStockMap.get(w.id); + const stockLabel = stockInfo + ? ` (Stock: ${stockInfo.totalQty.toLocaleString('id-ID')} items, ${stockInfo.productCount} produk)` + : ' (Kosong)'; + + return { + value: w.id, + label: `${w.name}${stockLabel}`, + area: w.area?.name, + location: + 'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG') + ? w.location?.name + : undefined, + }; + }) + : []; + + // Product Warehouse selection - Filter by source warehouse + const productWarehouseParams = new URLSearchParams({ + search: productWarehouseSelectInputValue, + }); + if (formik.values.source_warehouse_id) { + productWarehouseParams.append( + 'warehouse_id', + formik.values.source_warehouse_id.toString() + ); + } + const productWarehousesUrl = `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`; + const { data: productWarehouses, isLoading: isLoadingProductWarehouses } = + useSWR( + formik.values.source_warehouse_id ? productWarehousesUrl : null, + ProductWarehouseApi.getAllFetcher + ); + const productWarehouseOptions = isResponseSuccess(productWarehouses) + ? productWarehouses?.data.map((pw) => ({ + value: pw.product.id, + label: `${pw.product.name} - ${pw.warehouse.name} (Stock: ${pw.quantity.toLocaleString('id-ID')})`, + product_id: pw.product.id, + warehouse_id: pw.warehouse.id, + warehouse_name: pw.warehouse.name, + quantity: pw.quantity, + })) + : []; + + // Supplier selection + const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); + const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue }).toString()}`; + const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( + suppliersUrl, + SupplierApi.getAllFetcher + ); + const supplierOptions = isResponseSuccess(suppliers) + ? suppliers?.data.map((s) => ({ value: s.id, label: s.name })) + : []; + + // Handle cost calculation when delivery_cost changes + const handleDeliveryCostChange = useCallback( + (idx: number, value: string) => { + const numValue = parseFloat(value) || 0; + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, numValue); + + const delivery = formik.values.deliveries?.[idx]; + if (delivery) { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty > 0 && numValue > 0) { + const perItem = numValue / productQty; + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } else if (numValue === 0) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0); + } + } + }, + [formik] + ); + + // Handle cost calculation when delivery_cost_per_item changes + const handleDeliveryCostPerItemChange = useCallback( + (idx: number, value: string) => { + const numValue = parseFloat(value) || 0; + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + numValue + ); + + const delivery = formik.values.deliveries?.[idx]; + if (delivery) { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + if (productQty > 0 && numValue > 0) { + const totalCost = numValue * productQty; + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } else if (numValue === 0) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, 0); + } + } + }, + [formik] + ); + + // Auto-recalculate when product quantity changes + useEffect(() => { + formik.values.deliveries?.forEach((delivery, idx) => { + const productQty = delivery.products.reduce( + (sum, p) => sum + p.product_qty, + 0 + ); + + // If delivery_cost is set, recalculate delivery_cost_per_item + if ( + delivery.delivery_cost && + delivery.delivery_cost > 0 && + productQty > 0 + ) { + const perItem = delivery.delivery_cost / productQty; + if (Math.abs((delivery.delivery_cost_per_item || 0) - perItem) > 0.01) { + formik.setFieldValue( + `deliveries.${idx}.delivery_cost_per_item`, + perItem + ); + } + } + // If delivery_cost_per_item is set, recalculate delivery_cost + else if ( + delivery.delivery_cost_per_item && + delivery.delivery_cost_per_item > 0 && + productQty > 0 + ) { + const totalCost = delivery.delivery_cost_per_item * productQty; + if (Math.abs((delivery.delivery_cost || 0) - totalCost) > 0.01) { + formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); + } + } + }); + }, [ + formik.values.deliveries + ?.map((d) => d.products.reduce((sum, p) => sum + p.product_qty, 0)) + .join(','), + ]); + + useEffect(() => { + if ( + formik.values.source_warehouse_id && + type !== 'edit' && + type !== 'detail' + ) { + formik.setFieldValue('products', []); + formik.setFieldValue('deliveries', []); + } + }, [formik.values.source_warehouse_id]); + + const getFilteredProductWarehouseOptions = useCallback(() => { + return ( + formik.values.products + ?.filter((p) => p.product) + .map((p) => ({ + value: p.product_id, + label: (p.product as OptionType)?.label, + })) ?? [] + ); + }, [formik.values.products]); + + const getAvailableStock = useCallback( + (productId: number) => { + if (type === 'detail') return 0; + const productWarehouse = productWarehouseOptions.find( + (pw) => pw.product_id === productId + ); + return productWarehouse?.quantity ?? 0; + }, + [productWarehouseOptions, type] + ); + + const getProductQtyAdornment = useCallback( + (productIdx: number) => { + if (type === 'detail') return null; + const product = formik.values.products?.[productIdx]; + if (!product || !product.product_id) return null; + + const availableStock = getAvailableStock(product.product_id); + const requestedQty = Number(product.product_qty) || 0; + const remainingStock = availableStock - requestedQty; + + if (requestedQty > 0) { + return ( + + (sisa: {remainingStock.toLocaleString('id-ID')}) + + ); + } + + return ( + + (tersedia: {availableStock.toLocaleString('id-ID')}) + + ); + }, + [formik.values.products, getAvailableStock, type] + ); + + const getProductQtyError = useCallback( + (productIdx: number) => { + if (type === 'detail') return null; + const product = formik.values.products?.[productIdx]; + if (!product || !product.product_id) return null; + + const availableStock = getAvailableStock(product.product_id); + const requestedQty = Number(product.product_qty) || 0; + + if (requestedQty > availableStock) { + return `Qty melebihi stok tersedia! Maksimal: ${availableStock.toLocaleString('id-ID')}`; + } + + return null; + }, + [formik.values.products, getAvailableStock, type] + ); + + const validateDeliveryQty = useCallback( + (deliveryIdx: number, deliveryProductIdx: number, qty: number) => { + if (type === 'detail') return true; + const delivery = formik.values.deliveries?.[deliveryIdx]; + if (!delivery) return true; + + const deliveryProduct = delivery.products[deliveryProductIdx]; + if (!deliveryProduct) return true; + + const productId = deliveryProduct.product_id; + if (!productId) return true; + + const relatedProduct = formik.values.products?.find( + (p) => p.product_id === productId + ); + if (!relatedProduct) return true; + + const totalQtyUsed = + formik.values.deliveries?.reduce((total, d, dIdx) => { + const productQty = d.products.reduce((sum, p, pIdx) => { + if ( + p.product_id === productId && + !(dIdx === deliveryIdx && pIdx === deliveryProductIdx) + ) { + return sum + (Number(p.product_qty) || 0); + } + return sum; + }, 0); + return total + productQty; + }, 0) || 0; + + return totalQtyUsed + qty <= Number(relatedProduct.product_qty); + }, + [formik.values.deliveries, formik.values.products, type] + ); + + const getDeliveryQtyError = useCallback( + (deliveryIdx: number, deliveryProductIdx: number) => { + if (type === 'detail') return null; + const delivery = formik.values.deliveries?.[deliveryIdx]; + if (!delivery) return null; + + const deliveryProduct = delivery.products[deliveryProductIdx]; + if (!deliveryProduct || !deliveryProduct.product_id) return null; + + const qty = Number(deliveryProduct.product_qty) || 0; + const productId = deliveryProduct.product_id; + + const relatedProduct = formik.values.products?.find( + (p) => p.product_id === productId + ); + if (!relatedProduct) return null; + + const totalQtyUsed = + formik.values.deliveries?.reduce((total, d, dIdx) => { + const productQty = d.products.reduce((sum, p, pIdx) => { + if ( + p.product_id === productId && + !(dIdx === deliveryIdx && pIdx === deliveryProductIdx) + ) { + return sum + (Number(p.product_qty) || 0); + } + return sum; + }, 0); + return total + productQty; + }, 0) || 0; + + const availableQty = Number(relatedProduct.product_qty) - totalQtyUsed; + + if (totalQtyUsed + qty > Number(relatedProduct.product_qty)) { + return `Qty melebihi stok produk! Tersedia: ${availableQty}, Total digunakan: ${totalQtyUsed + qty}`; + } + + return null; + }, + [formik.values.deliveries, formik.values.products, type] + ); + + const invalidQtyRows = useMemo( + () => + type === 'detail' + ? [] + : (formik.values.deliveries?.flatMap((delivery, deliveryIdx) => + delivery.products.map((product, productIdx) => { + const qty = Number(product.product_qty) || 0; + return !validateDeliveryQty(deliveryIdx, productIdx, qty); + }) + ) ?? []), + [ + formik.values.deliveries, + formik.values.products, + validateDeliveryQty, + type, + ] + ); + + const hasInvalidQty = useMemo( + () => (type === 'detail' ? false : invalidQtyRows.some(Boolean)), + [invalidQtyRows, type] + ); + + const hasExceededStock = useMemo(() => { + if (type === 'detail') return false; + return ( + formik.values.products?.some((product, idx) => { + return getProductQtyError(idx) !== null; + }) ?? false + ); + }, [formik.values.products, getProductQtyError, type]); + + return ( + <> +
+ +
+ {/* Top card - Movement details */} +
+
+
+ + +
+
+
+ + {/* Warehouse cards */} +
+
+
+

Gudang Asal

+ { + formik.setFieldValue('source_warehouse', val); + formik.setFieldValue( + 'source_warehouse_id', + (val as WarehouseOptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.source_warehouse_id && + Boolean(formik.errors.source_warehouse_id) + } + errorMessage={formik.errors.source_warehouse_id as string} + isDisabled={type === 'detail'} + isClearable + /> + + {/* Area and Location Info */} +
+ + +
+
+
+ +
+
+

Gudang Tujuan

+ { + formik.setFieldValue('destination_warehouse', val); + formik.setFieldValue( + 'destination_warehouse_id', + (val as WarehouseOptionType)?.value + ); + }} + options={warehouseOptions} + onInputChange={setWarehouseSelectInputValue} + isLoading={isLoadingWarehouses} + isError={ + formik.touched.destination_warehouse_id && + Boolean(formik.errors.destination_warehouse_id) + } + errorMessage={ + formik.errors.destination_warehouse_id as string + } + isDisabled={type === 'detail'} + isClearable + /> + + {/* Area and Location Info */} +
+ + +
+
+
+
+ + {/* Products table */} +
+
+

Produk

+
+ + + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && } + + + + {formik.values.products?.map((product, idx) => ( + + {type !== 'detail' && ( + + )} + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedProducts( + formik.values.products?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedProducts([]); + } + }} + /> + ProdukQtyAksi
+ { + if (e.target.checked) { + setSelectedProducts([ + ...selectedProducts, + idx, + ]); + } else { + setSelectedProducts( + selectedProducts.filter((i) => i !== idx) + ); + } + }} + /> + + { + formik.setFieldValue( + `products.${idx}.product`, + val + ); + formik.setFieldValue( + `products.${idx}.product_id`, + (val as ProductWarehouseOptionType)?.value + ); + }} + options={productWarehouseOptions} + onInputChange={setProductWarehouseSelectInputValue} + isLoading={isLoadingProductWarehouses} + isDisabled={ + type === 'detail' || + !formik.values.source_warehouse_id + } + placeholder={ + !formik.values.source_warehouse_id + ? 'Pilih gudang asal terlebih dahulu' + : 'Pilih produk' + } + isClearable + {...isRepeaterInputError( + 'products', + 'product', + idx + )} + className={{ + wrapper: + 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedProducts.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Deliveries table */} +
+
+

Pengiriman

+
+ + + + {type !== 'detail' && ( + + )} + + + + + + + + + {type !== 'detail' && } + + + + {formik.values.deliveries?.map((delivery, idx) => ( + + {type !== 'detail' && ( + + )} + + + + + + + + + {type !== 'detail' && ( + + )} + + ))} + +
+ 0 + } + onChange={(e) => { + if (e.target.checked) { + setSelectedDeliveries( + formik.values.deliveries?.map( + (_, idx) => idx + ) ?? [] + ); + } else { + setSelectedDeliveries([]); + } + }} + /> + ProdukQtySupplierPlat NomorDokumenBiaya Pengiriman (Rp.)Biaya Per Item (Rp.)Nama SopirAksi
+ { + if (e.target.checked) { + setSelectedDeliveries([ + ...selectedDeliveries, + idx, + ]); + } else { + setSelectedDeliveries( + selectedDeliveries.filter((i) => i !== idx) + ); + } + }} + /> + + { + formik.setFieldValue( + `deliveries.${idx}.products.0.product`, + val + ); + formik.setFieldValue( + `deliveries.${idx}.products.0.product_id`, + (val as OptionType)?.value + ); + }} + options={getFilteredProductWarehouseOptions()} + isDisabled={type === 'detail'} + isClearable + className={{ + wrapper: + 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + { + formik.setFieldValue( + `deliveries.${idx}.supplier`, + val + ); + formik.setFieldValue( + `deliveries.${idx}.supplier_id`, + (val as OptionType)?.value + ); + }} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isDisabled={type === 'detail'} + isClearable + className={{ + wrapper: + 'w-full min-w-52 md:min-w-72 lg:min-w-80', + }} + /> + + + + {type === 'detail' ? ( + + ) : ( + { + const file = e.target.files?.[0]; + if (file) { + if (file.size > 2 * 1024 * 1024) { + toast.error( + 'Ukuran dokumen maksimal 2 MB!' + ); + return; + } + formik.setFieldValue( + `deliveries.${idx}.document`, + file + ); + } + }} + {...isRepeaterInputError( + 'deliveries', + 'document', + idx + )} + className={{ + wrapper: + 'w-full min-w-72 md:w-min-80 lg:w-min-96', + }} + /> + )} + + + handleDeliveryCostChange(idx, e.target.value) + } + onBlur={formik.handleBlur} + {...isRepeaterInputError( + 'deliveries', + 'delivery_cost', + idx + )} + readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-48', + }} + /> + + + handleDeliveryCostPerItemChange( + idx, + e.target.value + ) + } + onBlur={formik.handleBlur} + {...isRepeaterInputError( + 'deliveries', + 'delivery_cost_per_item', + idx + )} + readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-48', + }} + /> + + + + +
+
+ {type !== 'detail' && ( +
+ {selectedDeliveries.length > 0 && ( + + )} + +
+ )} +
+
+ + {/* Action buttons */} + + type={type} + formik={formik} + disableSubmit={hasInvalidQty || hasExceededStock} + /> + + {movementFormErrorMessage && ( +
+ + {movementFormErrorMessage} +
+ )} + +
+ + ); +}; + +export default MovementForm; diff --git a/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts new file mode 100644 index 00000000..0ad31e38 --- /dev/null +++ b/src/components/pages/inventory/movement/form/useMovementFormHandlers.ts @@ -0,0 +1,95 @@ +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'react-hot-toast'; +import { useModal } from '@/components/Modal'; +import { MovementApi } from '@/services/api/inventory'; +import { + CreateMovementPayload, + UpdateMovementPayload, +} from '@/types/api/inventory/movement'; +import { isResponseError } from '@/lib/api-helper'; + +export const useMovementFormHandlers = (initialValuesId?: number) => { + const router = useRouter(); + const deleteModal = useModal(); + const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const createMovementHandler = useCallback( + async (payload: CreateMovementPayload, documents: File[] = []) => { + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + const res = await MovementApi.create( + formData as unknown as CreateMovementPayload + ); + if (isResponseError(res)) { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.push('/inventory/movement'); + }, + [router] + ); + + const updateMovementHandler = useCallback( + async ( + movementId: number, + payload: UpdateMovementPayload, + documents: File[] = [] + ) => { + let finalPayload: UpdateMovementPayload | FormData; + + if (documents.length > 0) { + const formData = new FormData(); + formData.append('data', JSON.stringify(payload)); + documents.forEach((file, index) => { + formData.append(`documents[${index}]`, file); + }); + + finalPayload = formData as unknown as UpdateMovementPayload; + } else { + finalPayload = payload; + } + + const res = await MovementApi.update(movementId, finalPayload); + if (res?.status === 'error') { + setMovementFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + router.refresh(); + router.push('/inventory/movement'); + }, + [router] + ); + + const deleteMovementClickHandler = useCallback(() => { + deleteModal.openModal(); + }, [deleteModal]); + + const confirmationModalDeleteClickHandler = useCallback(async () => { + if (!initialValuesId) return; + + setIsDeleteLoading(true); + await MovementApi.delete(initialValuesId); + deleteModal.closeModal(); + toast.success('Successfully delete Movement!'); + setIsDeleteLoading(false); + router.push('/inventory/movement'); + }, [deleteModal, initialValuesId, router]); + + return { + deleteModal, + movementFormErrorMessage, + isDeleteLoading, + createMovementHandler, + updateMovementHandler, + deleteMovementClickHandler, + confirmationModalDeleteClickHandler, + }; +}; diff --git a/src/components/pages/master-data/customer/form/CustomerForm.tsx b/src/components/pages/master-data/customer/form/CustomerForm.tsx index 533e0c38..ac848834 100644 --- a/src/components/pages/master-data/customer/form/CustomerForm.tsx +++ b/src/components/pages/master-data/customer/form/CustomerForm.tsx @@ -11,7 +11,7 @@ import { import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; -import { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } from './CustomerForm.schema'; +import { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } from '@/components/pages/master-data/customer/form/CustomerForm.schema'; import { useFormik } from 'formik'; import Button from '@/components/Button'; import { Icon } from '@iconify/react'; diff --git a/src/components/pages/master-data/flock/FlocksTable.tsx b/src/components/pages/master-data/flock/FlocksTable.tsx new file mode 100644 index 00000000..b0684a1a --- /dev/null +++ b/src/components/pages/master-data/flock/FlocksTable.tsx @@ -0,0 +1,278 @@ +'use client'; + +import { CellContext, ColumnDef } from '@tanstack/react-table'; +import { Flock } from '@/types/api/master-data/flock'; +import { cn } from '@/lib/helper'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { useState } from 'react'; +import useSWR from 'swr'; +import { FlockApi } from '@/services/api/master-data'; +import { useModal } from '@/components/Modal'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import toast from 'react-hot-toast'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { ROWS_OPTIONS } from '@/config/constant'; +import Table from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +const RowsOptions = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + +
+ ); +}; + +const FlockTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { search: '', nameSort: '' }, + paramMap: { + page: 'page', + pageSize: 'limit', + nameSort: 'sort_name', + }, + }); + + // Fetch Data + const { + data: flocks, + isLoading, + mutate: refreshFlocks, + } = useSWR( + `${FlockApi.basePath}${getTableFilterQueryString()}`, + FlockApi.getAllFetcher + ); + + // State + const deleteModal = useModal(); + const [selectedFlock, setSelectedFlock] = useState( + undefined + ); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + // Columns Definition + const flocksColumns: ColumnDef[] = [ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorKey: 'name', + header: 'Nama', + }, + { + accessorKey: 'created_at', + header: 'Dibuat pada', + cell: (props) => + new Date(props.row.original.created_at).toLocaleDateString(), + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = props.table.getPaginationRowModel().rows.length; + const currentPageRows = props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedFlock(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + + // Handler + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await FlockApi.delete(selectedFlock?.id as number); + refreshFlocks(); + + deleteModal.closeModal(); + toast.success('Successfully delete Flock!'); + setIsDeleteLoading(false); + }; + const searchChangeHandler = (e: React.ChangeEvent) => { + updateFilter('search', e.target.value); + }; + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; + + return ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + data={isResponseSuccess(flocks) ? flocks?.data : []} + columns={flocksColumns} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(flocks) ? flocks?.meta?.page : 0} + totalItems={ + isResponseSuccess(flocks) ? flocks?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + className={{ + containerClassName: cn({ + 'mb-20': isResponseSuccess(flocks) && flocks?.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', + }} + /> +
+ + + ); +}; + +export default FlockTable; \ No newline at end of file diff --git a/src/components/pages/master-data/flock/form/FlockForm.schema.ts b/src/components/pages/master-data/flock/form/FlockForm.schema.ts new file mode 100644 index 00000000..76445610 --- /dev/null +++ b/src/components/pages/master-data/flock/form/FlockForm.schema.ts @@ -0,0 +1,14 @@ +import * as Yup from 'yup'; + +export const FlockFormSchema = Yup.object({ + name: Yup.string() + .required('Nama wajib diisi!') + .matches( + /^[\p{L}\p{N}\s]+$/u, + 'Nama tidak boleh mengandung simbol' + ), +}); + +export const UpdateFlockFormSchema = FlockFormSchema; + +export type FlockFormValues = Yup.InferType; diff --git a/src/components/pages/master-data/flock/form/FlockForm.tsx b/src/components/pages/master-data/flock/form/FlockForm.tsx new file mode 100644 index 00000000..cc227fa6 --- /dev/null +++ b/src/components/pages/master-data/flock/form/FlockForm.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useModal } from '@/components/Modal'; +import { FlockApi } from '@/services/api/master-data'; +import { Flock } from '@/types/api/master-data/flock'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; +import { FlockFormSchema, FlockFormValues, UpdateFlockFormSchema } from '@/components/pages/master-data/flock/form/FlockForm.schema'; +import { useFormik } from 'formik'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import TextInput from '@/components/input/TextInput'; +import { cn } from '@/lib/helper'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; + +interface FlockCustomProps { + formType?: 'add' | 'edit' | 'detail'; + initialValues?: Flock; +} + +const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => { + const router = useRouter(); + const deleteModal = useModal(); + + // State + const [flockFormErrorMessage, setFlockFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + // Handler + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await FlockApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + setIsDeleteLoading(false); + router.push('/master-data/flock'); + }; + + // Initital Value + const formikInitialValue = useMemo(() => { + return { + name: initialValues?.name ?? '', + }; + }, [initialValues]); + + // Formik + const formik = useFormik({ + initialValues: formikInitialValue, + enableReinitialize: true, + validationSchema: formType === 'edit' ? UpdateFlockFormSchema : FlockFormSchema, + onSubmit: async (values) => { + // reset error message + setFlockFormErrorMessage(''); + + // create payload + const payload = { + name: values.name, + }; + + // cek type form yang disubmit + switch (formType) { + case 'add': + await FlockApi.create(payload); + break; + case 'edit': + await FlockApi.update(initialValues?.id as number, payload); + break; + default: + break; + } + + router.push('/master-data/flock'); + }, + }); + + // Initialize Formik + const { setValues: formikSetValues } = formik; + useEffect(() => { + formikSetValues(formikInitialValue); + }, [formikSetValues, formikInitialValue]); + + // Render + return ( + <> +
+
+ + +

+ {formType === 'add' && 'Tambah Flock'} + {formType === 'edit' && 'Ubah Flock'} + {formType === 'detail' && 'Detail Flock'} +

+
+
+ {/* Fields Form */} +
+ +
+ + {/* Action Button */} +
+ {formType !== 'add' && ( +
+ + {formType !== 'edit' && ( + + )} +
+ )} + + {formType !== 'detail' && ( +
+ + + +
+ )} +
+ + {flockFormErrorMessage && ( +
+ + {flockFormErrorMessage} +
+ )} +
+
+ + {formType !== 'add' && ( + + )} + + ); +}; + +export default FlockForm; diff --git a/src/components/pages/master-data/supplier/form/SupplierForm.tsx b/src/components/pages/master-data/supplier/form/SupplierForm.tsx index 74c4da27..e400ead2 100644 --- a/src/components/pages/master-data/supplier/form/SupplierForm.tsx +++ b/src/components/pages/master-data/supplier/form/SupplierForm.tsx @@ -15,7 +15,7 @@ import { SupplierFormSchema, SupplierFormValues, UpdateSupplierFormSchema, -} from './SupplierForm.schema'; +} from '@/components/pages/master-data/supplier/form/SupplierForm.schema'; import { useFormik } from 'formik'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import { Icon } from '@iconify/react'; diff --git a/src/components/pages/production/chickin/ChickinTable.tsx b/src/components/pages/production/chickin/ChickinTable.tsx new file mode 100644 index 00000000..e2b527d3 --- /dev/null +++ b/src/components/pages/production/chickin/ChickinTable.tsx @@ -0,0 +1,325 @@ +'use client'; + +import Button from '@/components/Button'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import { OptionType } from '@/components/input/SelectInput'; +import Modal, { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import Table from '@/components/Table'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn } from '@/lib/helper'; +import { ChickinApi, ProjectFlockApi } from '@/services/api/production'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { Chickin } from '@/types/api/production/chickin'; +import { Icon } from '@iconify/react'; +import { CellContext, SortingState } from '@tanstack/react-table'; +import { useState } from 'react'; +import useSWR from 'swr'; +import ChickinForm from './form/ChickinForm'; + +const ChickinTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + search: 'search', + }, + }); + + const [sorting, setSorting] = useState([]); + const [selectedChickin, setSelectedChickin] = useState( + undefined + ); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const deleteModal = useModal(); + const chickinModal = useModal(); + + // Data Fetching + const { + data: chickins, + isLoading, + mutate: refreshChickins, + } = useSWR( + `${ChickinApi.basePath}${getTableFilterQueryString()}`, + ChickinApi.getAllFetcher + ); + const { + data: projectFlocks, + isLoading: isLoadingProjectFlocks, + } = useSWR( + `${ProjectFlockApi.basePath}${getTableFilterQueryString()}`, + ProjectFlockApi.getAllFetcher + ); + + const searchChangeHandler = (event: React.ChangeEvent) => { + updateFilter('search', event.target.value); + setPage(1); + }; + + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + try { + await ChickinApi.delete(selectedChickin?.id as number); + refreshChickins(); + deleteModal.closeModal(); + } finally { + setIsDeleteLoading(false); + } + }; + + return ( + <> +
+
+
+ + +
+ +
+
+ + data={isResponseSuccess(chickins) ? chickins?.data : []} + columns={[ + { + header: '#', + cell: (props) => + tableFilterState.pageSize * (tableFilterState.page - 1) + + props.row.index + + 1, + }, + { + accessorFn: (row) => row.project_flock_kandang?.kandang.name, + header: 'Kandang', + }, + { + accessorFn: (row) => row.quantity, + header: 'Jumlah Chickin', + }, + { + accessorFn: (row) => row.chick_in_date, + header: 'Tanggal Chickin', + cell: (props) => { + if (props.row.original.chick_in_date) { + return new Date(props.row.original.chick_in_date).toLocaleDateString( + 'id-ID' + ); + } else { + return '-'; + } + } + }, + { + accessorFn: (row) => row.note, + header: 'Catatan', + }, + { + header: 'Aksi', + cell: (props) => { + const currentPageSize = + props.table.getPaginationRowModel().rows.length; + const currentPageRows = + props.table.getPaginationRowModel().flatRows; + const currentRowRelativeIndex = + currentPageRows.findIndex((r) => r.id === props.row.id) + 1; + + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const deleteClickHandler = () => { + setSelectedChickin(props.row.original); + deleteModal.openModal(); + }; + + const editClickHandler = () => { + setSelectedChickin(props.row.original); + chickinModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]} + pageSize={tableFilterState.pageSize} + page={isResponseSuccess(chickins) ? chickins?.meta?.page : 0} + totalItems={ + isResponseSuccess(chickins) ? chickins?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoading} + sorting={sorting} + setSorting={setSorting} + className={{ + containerClassName: cn({ + 'mb-20': + isResponseSuccess(chickins) && chickins?.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', + }} + /> + + +
+

+ Chickin Kandang - { selectedChickin?.project_flock_kandang && selectedChickin?.project_flock_kandang.kandang?.name} +

+ +
+ { + refreshChickins() + chickinModal.closeModal() + }}/> +
+ + ); +}; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + editClickHandler, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + editClickHandler: () => void; + deleteClickHandler: () => void; +}) => { + return ( +
+ + + +
+ ); +}; + +export default ChickinTable; diff --git a/src/components/pages/production/chickin/form/ChickinForm.schema.ts b/src/components/pages/production/chickin/form/ChickinForm.schema.ts new file mode 100644 index 00000000..42d3b6ea --- /dev/null +++ b/src/components/pages/production/chickin/form/ChickinForm.schema.ts @@ -0,0 +1,11 @@ +import * as Yup from 'yup'; + +export const ChickinFormSchema = Yup.object({ + chick_in_date: Yup.string().required('Tanggal masuk wajib diisi!'), + note: Yup.string().required('Catatan wajib diisi!'), + quantity: Yup.number().min(1, 'Jumlah wajib diisi!').required('Jumlah wajib diisi!'), +}) + +export type ChickinFormValues = Yup.InferType; + +export const UpdateChickinFormSchema = ChickinFormSchema; \ No newline at end of file diff --git a/src/components/pages/production/chickin/form/ChickinForm.tsx b/src/components/pages/production/chickin/form/ChickinForm.tsx new file mode 100644 index 00000000..c6674df4 --- /dev/null +++ b/src/components/pages/production/chickin/form/ChickinForm.tsx @@ -0,0 +1,206 @@ +'use client'; + +import Button from '@/components/Button'; +import { + Chickin, + CreateChickinPayload, + UpdateChickinPayload, +} from '@/types/api/production/chickin'; +import { + ChickinFormSchema, + ChickinFormValues, + UpdateChickinFormSchema, +} from '@/components/pages/production/chickin/form/ChickinForm.schema'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useFormik } from 'formik'; +import { ChickinApi } from '@/services/api/production'; +import DateInput from '@/components/input/DateInput'; +import { isResponseError } from '@/lib/api-helper'; +import toast from 'react-hot-toast'; +import { Icon } from '@iconify/react'; +import TextArea from '@/components/input/TextArea'; +import TextInput from '@/components/input/TextInput'; + +interface ChickinFormProps { + formType?: 'add' | 'detail' | 'edit'; + initialValues?: Chickin; + afterSubmit?: () => void; +} + +const ChickinForm = ({ + formType = 'add', + initialValues, + afterSubmit, +}: ChickinFormProps) => { + // Helper Function + const formatDateForInput = (dateString?: string): string => { + if (!dateString) return ''; + return new Date(dateString).toISOString().split('T')[0]; + }; + + // State + const [chickinFormErrorMessage, setChickinFormErrorMessage] = useState(''); + + // Initial Value + const formikInitialValue = useMemo(() => { + return { + chick_in_date: formatDateForInput(initialValues?.chick_in_date) ?? '', + note: initialValues?.note ?? `Catatan Chickin ${initialValues?.project_flock_kandang?.project_flock.flock.name}`, + quantity: initialValues?.quantity ?? 1, + }; + }, [initialValues]); + + // Handle Submit Function + const handleCreate = useCallback( + async ( + payload: CreateChickinPayload, + afterSubmit: (() => void) | undefined + ) => { + const res = await ChickinApi.create(payload); + if (isResponseError(res)) { + setChickinFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + afterSubmit?.(); + }, + [] + ); + const handleUpdate = useCallback( + async ( + payload: UpdateChickinPayload, + afterSubmit: (() => void) | undefined + ) => { + const res = await ChickinApi.update( + payload.project_flock_kandang_id as number, + payload + ); + if (isResponseError(res)) { + setChickinFormErrorMessage(res.message); + return; + } + toast.success(res?.message as string); + afterSubmit?.(); + }, + [] + ); + + // Formik + const formik = useFormik({ + initialValues: formikInitialValue, + enableReinitialize: true, + validationSchema: + formType === 'edit' ? UpdateChickinFormSchema : ChickinFormSchema, + onSubmit: async (values) => { + // reset error message + setChickinFormErrorMessage(''); + + if (initialValues?.project_flock_kandang?.id == undefined) { + return; + } + + // create payload + const payload = { + chick_in_date: values.chick_in_date, + project_flock_kandang_id: initialValues?.project_flock_kandang?.id, + }; + + // cek type form yang disubmit + switch (formType) { + case 'add': + handleCreate(payload, afterSubmit); + break; + case 'edit': + handleUpdate(payload, afterSubmit); + break; + default: + break; + } + }, + }); + + // Initialize Formik + const { setValues: formikSetValues } = formik; + useEffect(() => { + formikSetValues(formikInitialValue); + }, [formikSetValues, formikInitialValue]); + + return ( + <> +
+ + +