diff --git a/.husky/pre-commit b/.husky/pre-commit index 3782914b..e69de29b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +0,0 @@ -npm run format -npm run lint -npm run build \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index c3d05c67..e50e020d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -48,3 +48,8 @@ html { scrollbar-gutter: initial; } + +.react-select__menu-portal { + position: relative; + z-index: 99999 !important; +} diff --git a/src/app/marketing/sales-orders/add/page.tsx b/src/app/marketing/sales-orders/add/page.tsx new file mode 100644 index 00000000..e60085ef --- /dev/null +++ b/src/app/marketing/sales-orders/add/page.tsx @@ -0,0 +1,11 @@ +import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; + +const AddSalesOrder = () => { + return ( +
+ +
+ ); +}; + +export default AddSalesOrder; diff --git a/src/app/marketing/sales-orders/detail/edit/page.tsx b/src/app/marketing/sales-orders/detail/edit/page.tsx new file mode 100644 index 00000000..86cafcb6 --- /dev/null +++ b/src/app/marketing/sales-orders/detail/edit/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import SalesForm from '@/components/pages/marketing/sales-orders/form/SalesForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { MarketingApi } from '@/services/api/marketing/marketing'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const EditSalesOrder = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const soId = searchParams.get('salesOrderId'); + + const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => + MarketingApi.getSingle(id) + ); + + if (!soId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoading && (!marketing || isResponseError(marketing))) { + router.replace('/404'); + return; + } + return ( +
+ {isLoading && } + {!isLoading && isResponseSuccess(marketing) && ( + + )} +
+ ); +}; +export default EditSalesOrder; diff --git a/src/app/production/chickin/add/layout.tsx b/src/app/marketing/sales-orders/detail/layout.tsx similarity index 100% rename from src/app/production/chickin/add/layout.tsx rename to src/app/marketing/sales-orders/detail/layout.tsx diff --git a/src/app/marketing/sales-orders/detail/page.tsx b/src/app/marketing/sales-orders/detail/page.tsx new file mode 100644 index 00000000..22d2651c --- /dev/null +++ b/src/app/marketing/sales-orders/detail/page.tsx @@ -0,0 +1,44 @@ +'use client'; + +import SalesOrderDetail from '@/components/pages/marketing/sales-orders/detail/SalesOrderDetail'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { MarketingApi } from '@/services/api/marketing/marketing'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const DetailSalesOrder = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const soId = searchParams.get('salesOrderId'); + + const { data: marketing, isLoading: isLoading } = useSWR(soId, (id: number) => + MarketingApi.getSingle(id) + ); + + if (!soId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoading && (!marketing || isResponseError(marketing))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoading && } + {!isLoading && isResponseSuccess(marketing) && ( + + )} +
+ ); +}; + +export default DetailSalesOrder; diff --git a/src/app/marketing/sales-orders/page.tsx b/src/app/marketing/sales-orders/page.tsx new file mode 100644 index 00000000..3494b6a1 --- /dev/null +++ b/src/app/marketing/sales-orders/page.tsx @@ -0,0 +1,10 @@ +import SalesOrderTable from '@/components/pages/marketing/sales-orders/SalesOrderTable'; + +const SalesOrder = () => { + return ( +
+ +
+ ); +}; +export default SalesOrder; diff --git a/src/app/production/chickin/add/page.tsx b/src/app/production/chickin/add/page.tsx deleted file mode 100644 index 3ef73396..00000000 --- a/src/app/production/chickin/add/page.tsx +++ /dev/null @@ -1,270 +0,0 @@ -'use client'; - -import Button from '@/components/Button'; -import SelectInput, { OptionType } from '@/components/input/SelectInput'; -import Modal, { useModal } from '@/components/Modal'; -import ConfirmationModal from '@/components/modal/ConfirmationModal'; -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 { 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 [projectFlockKandang, setProjectFlockKandang] = - useState>(); - const [isLoadingProjectFlockKandang, setIsLoadingProjectFlockKandang] = - useState(false); - 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`; - // 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(); - const alertModal = useModal(); - - if (!projectFlockId) { - router.back(); - - return ( -
- -
- ); - } - - if ( - !isLoadingProjectFlock && - (!projectFlock || isResponseError(projectFlock)) - ) { - router.replace('/404'); - return; - } - - // Handle Function - const handleChickinClick = async (kandang: Kandang) => { - setIsLoadingProjectFlockKandang(true); - setSelectedKandang(kandang); - const ProjectFlockKandangRes = await ProjectFlockApi.customRequest< - BaseApiResponse, - 'GET' - >(getProjectFlockKandangUrl, { - method: 'GET', - params: { - project_flock_id: projectFlockId ?? 0, - kandang_id: kandang.id, - }, - }); - if (isResponseSuccess(ProjectFlockKandangRes)) { - setProjectFlockKandang(ProjectFlockKandangRes); - setIsLoadingProjectFlockKandang(false); - if ( - ProjectFlockKandangRes.data.available_quantity && - ProjectFlockKandangRes.data.available_quantity > 0 - ) { - chickinModal.openModal(); - } else { - alertModal.openModal(); - } - } - }; - const handleAfterSubmit = () => { - 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 && ( - - )} -
- { - alertModal.closeModal(); - }, - }} - /> - - )} - - ); -}; - -export default AddChickin; diff --git a/src/app/production/chickin/detail/layout.tsx b/src/app/production/project-flock/chickin/add/kandang/layout.tsx similarity index 100% rename from src/app/production/chickin/detail/layout.tsx rename to src/app/production/project-flock/chickin/add/kandang/layout.tsx diff --git a/src/app/production/project-flock/chickin/add/kandang/page.tsx b/src/app/production/project-flock/chickin/add/kandang/page.tsx new file mode 100644 index 00000000..a22039d1 --- /dev/null +++ b/src/app/production/project-flock/chickin/add/kandang/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { ProjectFlockKandangApi } from '@/services/api/production'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +export default function AddChickinKandang() { + const searchParams = useSearchParams(); + const projectFlockKandangId = searchParams.get('projectFlockKandangId'); + const projectFlockId = searchParams.get('projectFlockId'); + const router = useRouter(); + + const { + data: projectFlockKandang, + isLoading: isLoading, + mutate: refreshProjectFlockKandang, + } = useSWR( + `get-single-project-flock-kandang/${projectFlockKandangId}`, + async () => + ProjectFlockKandangApi.getSingle( + parseInt(projectFlockKandangId as string) + ) + ); + + if (!projectFlockKandangId) { + router.push(`/production/chickin/add?projectFlockId=${projectFlockId}`); + return ( +
+ +
+ ); + } + + if (!isLoading && !projectFlockKandang) { + router.replace('/404'); + return; + } + + const handleAfterSubmit = () => { + refreshProjectFlockKandang(); + }; + + return ( + <> +
+ {isLoading && } + {!isLoading && + isResponseSuccess(projectFlockKandang) && + projectFlockId && ( + + )} +
+ + ); +} diff --git a/src/app/production/project-flock/chickin/add/layout.tsx b/src/app/production/project-flock/chickin/add/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/project-flock/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; diff --git a/src/app/production/project-flock/chickin/add/page.tsx b/src/app/production/project-flock/chickin/add/page.tsx new file mode 100644 index 00000000..3ca09c89 --- /dev/null +++ b/src/app/production/project-flock/chickin/add/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { FormHeader } from '@/components/helper/form/FormHeader'; +import ProjectFlockChickinDetail from '@/components/pages/production/project-flock/chickin/ProjectFlockChickinDetail'; +import { useSearchParams } from 'next/navigation'; + +const AddChickin = () => { + const searchParams = useSearchParams(); + const projectFlockId = searchParams.get('projectFlockId'); + + return ( + <> +
+ + +
+ + ); +}; + +export default AddChickin; diff --git a/src/app/production/project-flock/chickin/detail/layout.tsx b/src/app/production/project-flock/chickin/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/production/project-flock/chickin/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/detail/page.tsx b/src/app/production/project-flock/chickin/detail/page.tsx similarity index 94% rename from src/app/production/chickin/detail/page.tsx rename to src/app/production/project-flock/chickin/detail/page.tsx index be8c5332..daea0f0a 100644 --- a/src/app/production/chickin/detail/page.tsx +++ b/src/app/production/project-flock/chickin/detail/page.tsx @@ -6,7 +6,7 @@ import Modal, { useModal } from '@/components/Modal'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ChickinForm from '@/components/pages/production/chickin/form/ChickinForm'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { ChickinApi } from '@/services/api/production'; +import { ChickinApi } from '@/services/api/production/chickin'; import { BaseApiResponse } from '@/types/api/api-general'; import { Chickin, @@ -170,8 +170,8 @@ const DetailChickin = () => {
Flock
{ - chickin.data.project_flock_kandang?.project_flock.flock - .name + chickin?.data?.project_flock_kandang?.project_flock?.flock + ?.name }
@@ -225,8 +225,8 @@ const DetailChickin = () => {
Flock Kandang
{ - chickin.data.project_flock_kandang?.project_flock.flock - .name + chickin?.data?.project_flock_kandang?.project_flock?.flock + ?.name }{' '} - {chickin.data.project_flock_kandang?.kandang.name}
@@ -280,7 +280,7 @@ const DetailChickin = () => { { /> - { - refreshChickin(); - chickinModal.closeModal(); - }} - /> { text={`Apakah anda yakin ingin ${ approvalAction == 'APPROVED' ? 'approve' : 'reject' } chickin berikut? (${ - chickin?.data.project_flock_kandang?.project_flock.flock.name + chickin?.data?.project_flock_kandang?.project_flock?.flock?.name } - ${chickin?.data.project_flock_kandang?.kandang.name})?`} secondaryButton={{ text: 'Tidak', diff --git a/src/app/production/chickin/page.tsx b/src/app/production/project-flock/chickin/page.tsx similarity index 100% rename from src/app/production/chickin/page.tsx rename to src/app/production/project-flock/chickin/page.tsx diff --git a/src/app/production/project-flock/detail/edit/page.tsx b/src/app/production/project-flock/detail/edit/page.tsx index 7576cc27..f55ce601 100644 --- a/src/app/production/project-flock/detail/edit/page.tsx +++ b/src/app/production/project-flock/detail/edit/page.tsx @@ -2,7 +2,7 @@ import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { ProjectFlockApi } from '@/services/api/production'; +import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; @@ -12,10 +12,11 @@ const ProjectFlockEdit = () => { const projectFlockId = searchParams.get('projectFlockId'); - const { data: projectFlock, isLoading: isLoadingCostumer } = useSWR( - projectFlockId, - (id: number) => ProjectFlockApi.getSingle(id) - ); + const { + data: projectFlock, + isLoading: isLoadingProjectFlock, + mutate: refreshProjectFlocks, + } = useSWR(projectFlockId, (id: number) => ProjectFlockApi.getSingle(id)); if (!projectFlockId) { router.back(); @@ -27,17 +28,20 @@ const ProjectFlockEdit = () => { ); } - if (!isLoadingCostumer && (!projectFlock || isResponseError(projectFlock))) { + if ( + !isLoadingProjectFlock && + (!projectFlock || isResponseError(projectFlock)) + ) { router.replace('/404'); return; } return ( -
- {isLoadingCostumer && ( +
+ {isLoadingProjectFlock && ( )} - {!isLoadingCostumer && isResponseSuccess(projectFlock) && ( + {!isLoadingProjectFlock && isResponseSuccess(projectFlock) && ( )}
diff --git a/src/app/production/project-flock/detail/page.tsx b/src/app/production/project-flock/detail/page.tsx index 6cf694c0..91d4dfd5 100644 --- a/src/app/production/project-flock/detail/page.tsx +++ b/src/app/production/project-flock/detail/page.tsx @@ -2,7 +2,7 @@ import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import { ProjectFlockApi } from '@/services/api/production'; +import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; @@ -37,11 +37,11 @@ const ProjectFlockDetail = () => { } return ( -
+
{isLoadingProjectFlock && ( )} - {!isLoadingProjectFlock && isResponseSuccess(projectFlock) && ( + {isResponseSuccess(projectFlock) && ( , 'className'> { + tabs: TabItem[]; + variant?: 'bordered' | 'lifted' | 'boxed'; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + placement?: 'top' | 'bottom'; + /** Tab yang aktif secara default (uncontrolled mode) */ + defaultActiveId?: string; + /** Tab yang aktif (controlled mode, dikontrol parent) */ + activeTabId?: string; + className?: + | string + | { + wrapper?: string; + tab?: string; + content?: string; + }; + onTabChange?: (tabId: string) => void; +} + +const Tabs = ({ + tabs, + variant, + size = 'md', + placement = 'top', + defaultActiveId, + activeTabId: controlledActiveId, + className, + onTabChange, + ...props +}: TabsProps) => { + // State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset + const [uncontrolledActiveId, setUncontrolledActiveId] = useState( + defaultActiveId || tabs[0]?.id || '' + ); + + const isControlled = controlledActiveId !== undefined; + const activeTabId = isControlled ? controlledActiveId : uncontrolledActiveId; + + const handleTabChange = (tabId: string) => { + if (tabId === activeTabId) return; + if (!isControlled) setUncontrolledActiveId(tabId); + onTabChange?.(tabId); + }; + + const { wrapper: wrapperClassName, tab: tabClassName } = + typeof className === 'object' + ? className + : { wrapper: className, tab: undefined }; + + const getTabsClasses = () => { + const variantClasses: Record = { + bordered: 'tabs-bordered', + lifted: 'tabs-lift', + boxed: 'tabs-box', + }; + + const sizeClasses: Record = { + xs: 'tabs-xs', + sm: 'tabs-sm', + md: '', + lg: 'tabs-lg', + xl: 'tabs-xl', + }; + + const placementClasses: Record = { + top: '', + bottom: 'tabs-bottom', + }; + + return cn( + 'tabs', + variant && variantClasses[variant], + sizeClasses[size], + placementClasses[placement], + wrapperClassName + ); + }; + + const getTabClasses = (isActive: boolean, isDisabled?: boolean) => + cn( + 'tab', + { + 'tab-active': isActive, + 'tab-disabled': isDisabled, + }, + tabClassName + ); + + const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content; + + return ( +
+
+ {tabs.map(({ id, label, disabled }) => ( + + ))} +
+ + {activeContent &&
{activeContent}
} +
+ ); +}; + +export default Tabs; diff --git a/src/components/helper/form/FormHeader.tsx b/src/components/helper/form/FormHeader.tsx index ebc1d7ae..de7ec882 100644 --- a/src/components/helper/form/FormHeader.tsx +++ b/src/components/helper/form/FormHeader.tsx @@ -2,15 +2,27 @@ import Button from '@/components/Button'; import { Icon } from '@iconify/react'; interface FormHeaderProps { - type: 'add' | 'edit' | 'detail'; + type?: 'add' | 'edit' | 'detail'; title: string; - backUrl: string; + backUrl?: string; + onBackClick?: () => void; } -export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => { +export const FormHeader = ({ + type, + title, + backUrl, + onBackClick, +}: FormHeaderProps) => { return (
- @@ -18,6 +30,7 @@ export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => { {type === 'add' && `Tambah ${title}`} {type === 'edit' && `Edit ${title}`} {type === 'detail' && `Detail ${title}`} + {!type && title}
); diff --git a/src/components/input/PatternInput.tsx b/src/components/input/PatternInput.tsx index b5f4f65d..9af1b68e 100644 --- a/src/components/input/PatternInput.tsx +++ b/src/components/input/PatternInput.tsx @@ -1,58 +1,88 @@ 'use client'; import { ChangeEvent } from 'react'; -import { PatternFormat, OnValueChange } from 'react-number-format'; +import { + PatternFormat, + NumberFormatBase, + NumberFormatBaseProps, + OnValueChange, +} from 'react-number-format'; import TextInput, { TextInputProps } from '@/components/input/TextInput'; interface PatternInputProps extends Omit { - type?: 'password' | 'tel' | 'text' | undefined; - - /** Format pattern, e.g. "##/##/####", "(###) ###-####", "####-####-####" */ + /** + * Format pattern, contoh: "##/##/####", "(###) ###-####", "####-####-####" + */ format: string; - - /** Mask character for empty slots, e.g. "_" */ + /** Mask karakter kosong, misal "_" */ mask?: string; - - /** Allow showing mask even when value is empty */ + /** Menampilkan mask walau value kosong */ allowEmptyFormatting?: boolean; - + /** Placeholder karakter format, default: "#" */ patternChar?: string; + /** Jika true, izinkan huruf (A-Z) selain angka */ + inputVehicleNumber?: boolean; + type?: 'text' | 'password' | 'tel'; } +/** + * PatternInput – tetap backward-compatible dengan Storybook + * tapi bisa menerima huruf jika `allowCharacters={true}` + */ const PatternInput = ({ type = 'text', format, mask = '_', allowEmptyFormatting = false, patternChar = '#', + inputVehicleNumber = false, onChange, ...restProps }: PatternInputProps) => { - const valueChangeHandler: OnValueChange = ( - patternFormatValues, - sourceInfo - ) => { - const newChangeEvent = sourceInfo.event as - | ChangeEvent - | undefined; - - if (newChangeEvent) { - newChangeEvent.target.value = patternFormatValues.value; - - onChange?.(newChangeEvent); + const handleValueChange: OnValueChange = (values, { event }) => { + const newEvent = event as ChangeEvent | undefined; + if (newEvent) { + newEvent.target.value = values.value.toUpperCase(); + onChange?.(newEvent); } }; + if (inputVehicleNumber) { + return ( + { + const clean = value.replace(/[^a-z0-9]/gi, '').toUpperCase(); + + const match = clean.match(/^([A-Z]{0,2})(\d{0,4})([A-Z]{0,3})$/); + if (!match) return clean; + const [, prefix, number, suffix] = match; + return [prefix, number, suffix].filter(Boolean).join(' '); + }} + removeFormatting={(val) => val.replace(/\s+/g, '')} + isValidInputCharacter={(char) => /^[a-z0-9]$/i.test(char)} + getCaretBoundary={(val) => + Array(val.length + 1) + .fill(true) + .map(Boolean) + } + onValueChange={handleValueChange} + /> + ); + } + return ( ); }; diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index b663b923..8fa8b555 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -1,8 +1,6 @@ 'use client'; import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; -import useSWR from 'swr'; - import Select, { OptionProps, GroupBase, @@ -16,9 +14,10 @@ import CreatableSelect from 'react-select/creatable'; import makeAnimated from 'react-select/animated'; import { useDebounce } from 'use-debounce'; import { cn, getByPath } from '@/lib/helper'; +import useSWR from 'swr'; import { httpClientFetcher } from '@/services/http/client'; -import { isResponseSuccess } from '@/lib/api-helper'; import { BaseApiResponse } from '@/types/api/api-general'; +import { isResponseSuccess } from '@/lib/api-helper'; export interface OptionType { value: string | number; @@ -56,6 +55,7 @@ interface SelectInputBaseProps { delay?: number; onInputChange?: (search: string) => void; startAdornment?: ReactNode; + menuPortalTarget?: HTMLElement | null; } interface SelectInputProps extends SelectInputBaseProps { @@ -118,6 +118,7 @@ const SelectInput = (props: SelectInputProps) => { createables = false, onInputChange, startAdornment, + menuPortalTarget, } = props; const [internalInputValue, setInternalInputValue] = useState(''); @@ -187,7 +188,7 @@ const SelectInput = (props: SelectInputProps) => { > instanceId='select' - value={value ?? (isMulti ? [] : undefined)} + value={value ?? (isMulti ? [] : null)} onChange={onChange ? handleChange : undefined} options={options} menuIsOpen={openMenu} @@ -232,7 +233,7 @@ const SelectInput = (props: SelectInputProps) => { cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'), menuList: () => cn('p-2! max-h-60 overflow-auto'), option: ({ isFocused, isSelected }) => - cn('mt-1 px-3 py-2 rounded cursor-pointer!', { + cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', { 'bg-indigo-600 text-white': isFocused, 'bg-blue-500!': isSelected, 'text-gray-700': !isFocused && !isSelected, @@ -258,7 +259,9 @@ const SelectInput = (props: SelectInputProps) => { startAdornment, })} menuPortalTarget={ - typeof document !== 'undefined' ? document.body : undefined + typeof document !== 'undefined' + ? (menuPortalTarget ?? document.body) + : undefined } styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }), @@ -275,8 +278,8 @@ const SelectInput = (props: SelectInputProps) => { const useSelect = ( basePath: string, - valueKey: keyof T, - labelKey: keyof T, + valueKey: keyof T | string, + labelKey: keyof T | string, searchKey: string = 'search', params?: { [key: string]: string } ) => { @@ -287,7 +290,7 @@ const useSelect = ( [searchKey]: inputValue ?? '', ...params, }).toString(); - }, [inputValue, searchKey]); + }, [inputValue, searchKey, params]); const optionsUrl = `${basePath}?${optionsUrlParams}`; diff --git a/src/components/pages/ApprovalSteps.tsx b/src/components/pages/ApprovalSteps.tsx index cb4e6715..7185e31b 100644 --- a/src/components/pages/ApprovalSteps.tsx +++ b/src/components/pages/ApprovalSteps.tsx @@ -4,8 +4,16 @@ import StepItem from '@/components/steps/StepItem'; import Tooltip from '@/components/Tooltip'; import { cn, formatDate } from '@/lib/helper'; -import { BaseApproval, BaseGroupedApproval } from '@/types/api/api-general'; +import { + BaseApiResponse, + BaseApproval, + BaseGroupedApproval, +} from '@/types/api/api-general'; import { ApprovalLine } from '@/types/config/constant'; +import useSWR from 'swr'; +import { httpClientFetcher } from '@/services/http/client'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useCallback, useMemo } from 'react'; export type ApprovalStepStatus = 'APPROVED' | 'REJECTED' | 'WAITING' | 'IDLE'; @@ -120,7 +128,7 @@ export const formatGroupedApprovalsToApprovalSteps = ( const currentStepNumber = approvalLineItem.step_number; const lastStepNumber = - groupedApprovals[groupedApprovals.length - 1].step_number; + groupedApprovals[groupedApprovals.length - 1]?.step_number; if (!approvalGroup && currentStepNumber <= lastStepNumber) { throw new Error( @@ -144,22 +152,24 @@ export const formatGroupedApprovalsToApprovalSteps = ( }; } - let approvalStatus: ApprovalStepStatus; + let approvalStatus: ApprovalStepStatus = 'IDLE'; if (approvalGroup.step_number <= latestApproval.step_number) { - switch (approvalGroup.approvals[0].action) { - case 'CREATED': - case 'APPROVED': - approvalStatus = 'APPROVED'; - break; + if (approvalGroup.approvals) { + switch (approvalGroup?.approvals[0]?.action) { + case 'CREATED': + case 'APPROVED': + approvalStatus = 'APPROVED'; + break; - case 'REJECTED': - approvalStatus = 'REJECTED'; - break; + case 'REJECTED': + approvalStatus = 'REJECTED'; + break; - default: - approvalStatus = 'IDLE'; - break; + default: + approvalStatus = 'IDLE'; + break; + } } } else if (approvalGroup.step_number === latestApproval.step_number + 1) { approvalStatus = 'WAITING'; @@ -167,13 +177,13 @@ export const formatGroupedApprovalsToApprovalSteps = ( approvalStatus = 'IDLE'; } - const approvalLogs: ApprovalStepLog[] = approvalGroup.approvals.map( - (approval) => ({ - action_by: approval.action_by.name, - date: approval.action_at, - notes: approval.notes, - }) - ); + const approvalLogs: ApprovalStepLog[] = approvalGroup.approvals + ? approvalGroup.approvals.map((approval) => ({ + action_by: approval.action_by.name, + date: approval.action_at, + notes: approval.notes, + })) + : []; return { name: approvalGroup.step_name, @@ -186,3 +196,178 @@ export const formatGroupedApprovalsToApprovalSteps = ( }; export default ApprovalSteps; + +/** + * Mengubah array BaseApproval (datar) menjadi BaseGroupedApproval (berkelompok). + */ +const groupApprovalsByStep = ( + approvals: BaseApproval[] +): BaseGroupedApproval[] => { + const groups: Record = {}; + for (const approval of approvals) { + if (!groups[approval.step_number]) { + groups[approval.step_number] = { + step_number: approval.step_number, + step_name: approval.step_name, + approvals: [], + }; + } + groups[approval.step_number].approvals.push(approval); + } + return Object.values(groups); +}; + +/** + * Mengubah array BaseGroupedApproval (berkelompok) kembali menjadi BaseApproval[] (datar). + */ +const flattenGroupedApprovals = ( + groupedApprovals: BaseGroupedApproval[] +): BaseApproval[] => { + return groupedApprovals.flatMap((group) => group.approvals); +}; + +/** + * Type guard untuk memeriksa apakah data adalah BaseGroupedApproval[]. + */ +const isGroupedApprovalData = ( + data: BaseApproval[] | BaseGroupedApproval[] +): data is BaseGroupedApproval[] => { + if (!data || data.length === 0) { + return true; + } + const firstElement = data[0]; + return ( + typeof firstElement === 'object' && + firstElement !== null && + 'approvals' in firstElement && + Array.isArray(firstElement.approvals) + ); +}; + +const useApprovalSteps = ({ + latestApproval, + approvalLines, + moduleName, + moduleId, + params, +}: { + latestApproval: BaseApproval | undefined; + approvalLines: ApprovalLine; + moduleName: string; + moduleId: string; + params?: { + page: number; + limit: number; + search?: string; + group_step_number?: boolean; + }; +}) => { + // Membuat URL Parameters + const paramString = new URLSearchParams({ + page: params?.page?.toString() || '', + limit: params?.limit?.toString() || '', + search: params?.search || '', + }).toString(); + + // fetching data approvals + const SWR_KEY_APPROVALS = + moduleName && moduleId + ? `/approvals?module_name=${moduleName}&module_id=${moduleId}${ + params ? `&${paramString}` : '' + }` + : null; + + const { + data: approvalData, + isLoading: approvalIsLoading, + mutate: mutateApprovals, + } = useSWR(SWR_KEY_APPROVALS, async (url) => { + return await httpClientFetcher< + BaseApiResponse + >(url); + }); + + // Fungsi Refresh + const refresh = useCallback(async () => { + await mutateApprovals(); + }, [mutateApprovals]); + + const { groupedApprovals } = useMemo(() => { + const rawData = isResponseSuccess(approvalData) + ? approvalData.data + : undefined; + + let processedGroupedApprovals: BaseGroupedApproval[] = []; + + if (rawData) { + if (isGroupedApprovalData(rawData)) { + processedGroupedApprovals = rawData; + } else { + processedGroupedApprovals = groupApprovalsByStep( + rawData as BaseApproval[] + ); + } + } + + return { + groupedApprovals: processedGroupedApprovals, + }; + }, [approvalData]); + + const isLoading = approvalIsLoading; + + // Formatting Akhir + const approvals = useMemo(() => { + if (isLoading || !approvalLines.length || !latestApproval) { + return []; + } + try { + return formatGroupedApprovalsToApprovalSteps( + approvalLines, + groupedApprovals, + latestApproval + ); + } catch (error) { + console.warn('Gagal memformat approval steps:', error); + return []; + } + }, [isLoading, approvalLines, groupedApprovals, latestApproval]); + + // Raw Data Approvals + const rawDataApprovals = useMemo(() => { + const rawData = isResponseSuccess(approvalData) + ? approvalData.data + : undefined; + + if (!rawData) { + return undefined; + } + + const isDataCurrentlyGrouped = isGroupedApprovalData(rawData); + const wantsGrouped = params?.group_step_number !== false; + + if (wantsGrouped) { + if (isDataCurrentlyGrouped) { + return rawData as BaseGroupedApproval[]; + } else { + return groupApprovalsByStep(rawData as BaseApproval[]); + } + } else { + if (isDataCurrentlyGrouped) { + return flattenGroupedApprovals(rawData as BaseGroupedApproval[]); + } else { + return rawData as BaseApproval[]; + } + } + }, [approvalData, params?.group_step_number]); + + // Return Hook + return { + approvals, + isLoading, + rawDataApprovals: rawDataApprovals, + refresh, + }; +}; + +export { useApprovalSteps }; diff --git a/src/components/pages/marketing/sales-orders/SalesOrderTable.tsx b/src/components/pages/marketing/sales-orders/SalesOrderTable.tsx new file mode 100644 index 00000000..1e8edaba --- /dev/null +++ b/src/components/pages/marketing/sales-orders/SalesOrderTable.tsx @@ -0,0 +1,406 @@ +'use client'; + +import Button from '@/components/Button'; +import CheckboxInput from '@/components/input/CheckboxInput'; +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 { TableToolbar } from '@/components/table/TableToolbar'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn, formatCurrency, formatVechicleNumber } from '@/lib/helper'; +import { MarketingApi } from '@/services/api/marketing/marketing'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing'; +import { Customer } from '@/types/api/master-data/customer'; +import { Icon } from '@iconify/react'; +import { CellContext } from '@tanstack/react-table'; +import { useCallback, useState } from 'react'; +import useSWR from 'swr'; + +const RowsOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( +
+
+ + + +
+
+ ); +}; + +const SalesOrderTable = () => { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const [approveAction, setApproveAction] = useState< + 'approve' | 'reject' | null + >(null); + const [selectedItem, setSelectedItem] = useState(null); + const [rowSelection, setRowSelection] = useState>({}); + const selectedRowIds = Object.keys(rowSelection).filter( + (id) => rowSelection[id] + ); + + const { + data: marketing, + isLoading: isLoadingMarketing, + mutate: refreshMarketing, + } = useSWR(MarketingApi.basePath, MarketingApi.getAllFetcher); + + const deleteModal = useModal(); + const confirmationModal = useModal(); + const productsModal = useModal(); + + const searchChangeHandler = useCallback( + (e: React.ChangeEvent) => { + setSearch(e.target.value); + setPage(1); + }, + [] + ); + const pageSizeChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + setPage(1); + }, + [] + ); + + const approveClickHandler = () => { + setApproveAction('approve'); + confirmationModal.openModal(); + }; + + const rejectClickHandler = () => { + setApproveAction('reject'); + confirmationModal.openModal(); + }; + + const productsClickHandler = (item: Marketing) => { + setSelectedItem(item); + productsModal.openModal(); + }; + + const { + state: tableFilterState, + updateFilter, + toQueryString: getTableFilterToQueryString, + } = useTableFilter({ + initial: { + search: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + }, + }); + + return ( + <> +
+
+ + +
+ + + +
+
+ ( +
+ +
+ ), + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: 'so_number', + header: 'No. Order', + }, + { + accessorKey: 'so_date', + header: 'Tanggal', + }, + { + accessorKey: 'approval.step_name', + header: 'Status', + }, + { + accessorKey: 'customer.name', + header: 'Customer', + }, + { + accessorKey: 'grand_total', + header: 'Grand Total', + }, + { + accessorKey: 'marketing_products.length', + header: 'Product Details', + cell: (props) => { + if (props?.row?.original?.marketing_products?.length) { + if (props?.row?.original?.marketing_products?.length > 1) { + return ( + + ); + } else { + const product = props?.row?.original?.marketing_products[0]; + return <>{product?.product_warehouse?.product?.name}; + } + } + }, + }, + { + 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 = () => {}; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]} + pageSize={pageSize} + page={page} + onPageChange={setPage} + className={{ + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-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', + }} + /> + + + + + + +
+

Daftar Produk

+ +
+ + data={ + isResponseSuccess(marketing) && selectedItem + ? (selectedItem?.marketing_products ?? []) + : [] + } + columns={[ + { + header: 'Kandang', + accessorFn(row) { + return row.product_warehouse.warehouse.name; + }, + }, + { + header: 'Produk', + accessorFn(row) { + return row.product_warehouse.product.name; + }, + }, + { + header: 'Harga Satuan (Rp)', + accessorFn(row) { + return formatCurrency(row.unit_price); + }, + }, + ]} + className={{ + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-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', + }} + /> +
+ + ); +}; +export default SalesOrderTable; diff --git a/src/components/pages/marketing/sales-orders/detail/SalesOrderDetail.tsx b/src/components/pages/marketing/sales-orders/detail/SalesOrderDetail.tsx new file mode 100644 index 00000000..e67a0644 --- /dev/null +++ b/src/components/pages/marketing/sales-orders/detail/SalesOrderDetail.tsx @@ -0,0 +1,308 @@ +'use client'; + +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import Table from '@/components/Table'; +import { + cn, + formatCurrency, + formatNumber, + formatVechicleNumber, +} from '@/lib/helper'; +import { MarketingApi } from '@/services/api/marketing/marketing'; +import { Marketing, MarketingProduct } from '@/types/api/marketing/marketing'; +import { Icon } from '@iconify/react'; +import { useState } from 'react'; +import toast from 'react-hot-toast'; + +const SalesOrderDetail = ({ + initialValues, + refreshValues, +}: { + initialValues?: Marketing; + refreshValues?: () => void; +}) => { + const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>( + 'approve' + ); + const [isLoading, setIsLoading] = useState(false); + + const deleteModal = useModal(); + const confirmationModal = useModal(); + const deliveryModal = useModal(); + + const approveClickHandler = () => { + setApprovalAction('approve'); + confirmationModal.openModal(); + }; + + const rejectClickHandler = () => { + setApprovalAction('reject'); + confirmationModal.openModal(); + }; + + const deliveryClickHandler = () => { + deliveryModal.openModal(); + }; + + const deleteClickHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteClickHandler = async () => { + setIsLoading(true); + // await MarketingApi.delete(initialValues?.id as number); + setIsLoading(false); + deleteModal.closeModal(); + toast.success('Successfully deleted Sales Order!'); + refreshValues?.(); + }; + + const confirmationModalApproveClickHandler = async () => { + setIsLoading(true); + // await MarketingApi.singleApproval( + // initialValues?.id as number, + // approvalAction + // ); + setIsLoading(false); + confirmationModal.closeModal(); + toast.success('Successfully approved Sales Order!'); + refreshValues?.(); + }; + + const confirmationModalDeliveryClickHandler = async () => { + setIsLoading(true); + // await MarketingApi.delivery(initialValues?.id as number); + setIsLoading(false); + deliveryModal.closeModal(); + toast.success('Successfully delivered Sales Order!'); + refreshValues?.(); + }; + + return ( + <> +
+ +
+ {initialValues?.approval?.step_number != 3 && ( + <> + + + + )} + {initialValues?.approval?.step_number == 2 && ( + + )} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ No. Sales Order + :{initialValues?.so_number}
Nama Pelanggan:{initialValues?.customer?.name}
Status:{initialValues?.approval?.step_name}
Tanggal Penjualan:{initialValues?.so_date}
Total Penjualan: + {formatCurrency(initialValues?.grand_total as number)} +
Catatan:{initialValues?.notes ?? '-'}
+
+ + {initialValues?.marketing_products && ( + + + data={initialValues?.marketing_products} + columns={[ + { + header: 'No. Polisi', + accessorFn(row) { + return formatVechicleNumber( + row.marketing_delivery_products?.vehicle_number as string + ); + }, + }, + { + header: 'Kandang', + accessorFn(row) { + return row.product_warehouse.warehouse.name; + }, + }, + { + header: 'Produk', + accessorFn(row) { + return row.product_warehouse.product.name; + }, + }, + { + header: 'Harga Satuan (Rp)', + accessorFn(row) { + return formatCurrency(row.unit_price); + }, + }, + { + header: 'Total Bobot (Kg)', + accessorFn(row) { + return formatNumber(row.total_weight); + }, + }, + { + header: 'Kuantitas', + accessorFn(row) { + return formatNumber(row.qty); + }, + }, + { + header: 'Avg. Bobot (Kg)', + accessorFn(row) { + return formatNumber(row.avg_weight); + }, + }, + { + header: 'Total Penjualan (Rp)', + accessorFn(row) { + return formatCurrency(row.total_price); + }, + }, + ]} + className={{ + containerClassName: cn({ + 'mb-20': + initialValues?.marketing_products && + initialValues?.marketing_products?.length === 0, + }), + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-6 py-3 last:flex last:flex-row last:justify-end', + paginationClassName: 'hidden', + }} + /> + + )} +
+ + +
+
+ + + + + ); +}; + +export default SalesOrderDetail; diff --git a/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts b/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts new file mode 100644 index 00000000..c8e6ebc2 --- /dev/null +++ b/src/components/pages/marketing/sales-orders/form/SalesForm.schema.ts @@ -0,0 +1,38 @@ +import * as Yup from 'yup'; +import { MarketingProduct } from '@/types/api/marketing/marketing'; +import { + MarketingProductFormValues, + MarketingProductSchema, +} from './repeater/MarketingProduct.schema'; + +type MarketingSchema = { + customer_id: number | undefined; + customer: + | { + value: number; + label: string; + } + | undefined + | null; + so_date: string | undefined; + notes: string | undefined; + marketing_products: MarketingProductFormValues[]; +}; + +export const MarketingSchema: Yup.ObjectSchema = Yup.object({ + customer_id: Yup.number().required('Customer wajib diisi!'), + customer: Yup.object({ + value: Yup.number().required(), + label: Yup.string().required(), + }).nullable(), + so_date: Yup.string().required('Tanggal wajib diisi!'), + notes: Yup.string().required('Catatan wajib diisi!'), + marketing_products: Yup.array() + .of(MarketingProductSchema) + .min(1, 'Minimal harus ada 1 produk!') + .required('Produk wajib diisi!'), +}); + +export const UpdateMarketingSchema = MarketingSchema; + +export type MarketingFormValues = Yup.InferType; diff --git a/src/components/pages/marketing/sales-orders/form/SalesForm.tsx b/src/components/pages/marketing/sales-orders/form/SalesForm.tsx new file mode 100644 index 00000000..2973f6ad --- /dev/null +++ b/src/components/pages/marketing/sales-orders/form/SalesForm.tsx @@ -0,0 +1,514 @@ +'use client'; + +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import DateInput from '@/components/input/DateInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import TextArea from '@/components/input/TextArea'; +import Modal, { useModal } from '@/components/Modal'; +import * as TanStack from '@tanstack/react-table'; +import Table from '@/components/Table'; // Keep this import +import { cn, formatCurrency, formatNumber } from '@/lib/helper'; +import { + CreateMarketingPayload, + CreateMarketingProductPayload, + Marketing, + MarketingProduct, +} from '@/types/api/marketing/marketing'; +import { Icon } from '@iconify/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import MarketingProductForm from './repeater/MarketingProductForm'; +import CheckboxInput from '@/components/input/CheckboxInput'; +import { Customer } from '@/types/api/master-data/customer'; +import { CustomerApi } from '@/services/api/master-data'; +import { useFormik } from 'formik'; +import { MarketingFormValues, MarketingSchema } from './SalesForm.schema'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { MarketingApi } from '@/services/api/marketing/marketing'; +import { MarketingProductFormValues } from './repeater/MarketingProduct.schema'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import toast from 'react-hot-toast'; +import { useRouter } from 'next/navigation'; + +const SalesForm = ({ + formType = 'add', + initialValues, +}: { + formType?: 'add' | 'edit'; + initialValues?: Marketing; +}) => { + const router = useRouter(); + const addProductModal = useModal(); + const deleteModal = useModal(); + + const [isLoading, setIsLoading] = useState(false); + const [selectedMarketingProduct, setSelectedMarketingProduct] = + useState(null); + const [rawMarketingProducts, setRawMarketingProducts] = useState< + MarketingProduct[] + >(initialValues?.marketing_products || []); + const [selectedCustomer, setSelectedCustomer] = useState( + initialValues?.customer + ? { value: initialValues.customer.id, label: initialValues.customer.name } + : null + ); + const [rowSelection, setRowSelection] = useState>({}); + const selectedRowIds = Object.keys(rowSelection).map((item) => + parseInt(item) + ); + const [grandTotal, setGrandTotal] = useState( + initialValues?.grand_total ?? 0 + ); + const marketingProducts = useMemo( + () => rawMarketingProducts, + [rawMarketingProducts] + ); + + const { + options: customerOptions, + rawData: customerRawData, + isLoadingOptions: isLoadingCustomerOptions, + } = useSelect(CustomerApi.basePath, 'id', 'name'); + + const handleAddProduct = useCallback(() => { + addProductModal.openModal(); + }, [addProductModal]); + const handleDeleteProduct = useCallback((id: number) => { + setRawMarketingProducts((prev) => prev.filter((p) => p.id !== id)); + }, []); + const handleBulkDeleteProduct = () => { + setRawMarketingProducts((prev) => + prev.filter((product) => !selectedRowIds.includes(product.id)) + ); + }; + const handleDelete = () => { + deleteModal.openModal(); + }; + + const handleAddSubmitProduct = useCallback( + async ( + tableValue: CreateMarketingProductPayload, + fieldValues: MarketingProductFormValues + ) => { + const newMarketingProduct: MarketingProduct = { + id: rawMarketingProducts.length + 1, + product_warehouse: tableValue.product_warehouse!, + unit_price: tableValue.unit_price as number, + total_weight: tableValue.total_weight as number, + qty: tableValue.qty as number, + avg_weight: tableValue.avg_weight as number, + total_price: tableValue.total_price as number, + marketing_delivery_products: { + id: rawMarketingProducts.length + 1, + vehicle_number: tableValue.vehicle_number as string, + delivery_date: tableValue.delivery_date as string, + unit_price: tableValue.unit_price as number, + total_weight: tableValue.total_weight as number, + qty: tableValue.qty as number, + avg_weight: tableValue.avg_weight as number, + total_price: tableValue.total_price as number, + }, + }; + + setRawMarketingProducts((prev) => [...prev, newMarketingProduct]); + formik.setValues({ + ...formik.values, + marketing_products: [...formik.values.marketing_products, fieldValues], + }); + setGrandTotal((prev) => prev + (tableValue.total_price as number)); + addProductModal.closeModal(); + }, + [rawMarketingProducts.length, addProductModal] + ); + const handleChangeCustomer = useCallback( + (val: OptionType | OptionType[] | null) => { + setSelectedCustomer(val as OptionType); + formik.setFieldValue('customer_id', (val as OptionType)?.value); + formik.setFieldValue('customer', val as OptionType); + }, + [selectedCustomer, setSelectedCustomer] + ); + + const createMarketingHandler = async (values: CreateMarketingPayload) => { + console.log(values); + const createMarketingRes = await MarketingApi.create(values); + if (isResponseSuccess(createMarketingRes)) { + console.log(createMarketingRes); + } + if (isResponseError(createMarketingRes)) { + console.log(createMarketingRes); + } + toast.success('Successfully created Sales Order!'); + router.push('/marketing/sales-orders'); + }; + const updateMarketingHandler = async (values: CreateMarketingPayload) => { + console.log(values); + const createMarketingRes = await MarketingApi.update( + initialValues?.id as number, + values + ); + if (isResponseSuccess(createMarketingRes)) { + console.log(createMarketingRes); + } + if (isResponseError(createMarketingRes)) { + console.log(createMarketingRes); + } + toast.success('Successfully updated Sales Order!'); + router.push('/marketing/sales-orders'); + }; + const deleteMarketingHandler = async () => { + setIsLoading(true); + console.log(initialValues?.id); + const deleteMarketingRes = await MarketingApi.delete( + initialValues?.id as number + ); + if (isResponseSuccess(deleteMarketingRes)) { + console.log(deleteMarketingRes); + } + if (isResponseError(deleteMarketingRes)) { + console.log(deleteMarketingRes); + } + toast.success('Successfully deleted Sales Order!'); + setIsLoading(false); + deleteModal.closeModal(); + router.push('/marketing/sales-orders'); + }; + + const MarketingProductToFieldValues = ( + product: MarketingProduct + ): MarketingProductFormValues => { + return { + vehicle_number: product.marketing_delivery_products?.vehicle_number, + kandang_id: product.product_warehouse.warehouse.id, + kandang: { + value: product.product_warehouse.warehouse.id, + label: product.product_warehouse.warehouse.name, + }, + product_warehouse: { + value: product.product_warehouse.product.id, + label: product.product_warehouse.product.name, + }, + product_warehouse_id: product.product_warehouse.product.id, + unit_price: product.unit_price, + total_weight: product.total_weight, + qty: product.qty, + uom: product.product_warehouse?.product?.uom?.name, + avg_weight: product.avg_weight, + total_price: product.total_price, + delivery_date: product.marketing_delivery_products?.delivery_date, + }; + }; + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + so_date: initialValues?.so_date || undefined, + notes: initialValues?.notes || undefined, + customer_id: initialValues?.customer?.id || undefined, + customer: { + value: initialValues?.customer?.id as number, + label: initialValues?.customer?.name as string, + }, + marketing_products: + initialValues?.marketing_products?.map((product) => + MarketingProductToFieldValues(product) + ) ?? [], + }, + validationSchema: MarketingSchema, + onSubmit: async (values) => { + const payload = { + customer_id: values.customer_id as number, + date: values.so_date as string, + notes: values.notes as string, + marketing_products: values.marketing_products, + } as CreateMarketingPayload; + switch (formType) { + case 'add': + createMarketingHandler(payload); + break; + case 'edit': + updateMarketingHandler(payload); + break; + default: + break; + } + }, + }); + + const { setValues: formikSetValues } = formik; + + useEffect(() => { + formikSetValues(formik.initialValues); + }, [formikSetValues, formik.initialValues]); + + const columns = useMemo( + () => [ + { + id: 'select', + header: ({ table }: { table: TanStack.Table }) => ( +
+ +
+ ), + cell: ({ row }: { row: TanStack.Row }) => ( +
+ +
+ ), + }, + { + accessorFn: (row: MarketingProduct) => + row.marketing_delivery_products?.vehicle_number, + header: 'No. Polisi', + }, + { + accessorFn: (row: MarketingProduct) => + row.product_warehouse.warehouse.name, + header: 'Kandang', + }, + { + accessorFn: (row: MarketingProduct) => + row.product_warehouse.product.name, + header: 'Produk', + }, + { + accessorFn: (row: MarketingProduct) => formatCurrency(row.unit_price), + header: 'Harga Satuan (Rp)', + }, + { + accessorFn: (row: MarketingProduct) => formatNumber(row.total_weight), + header: 'Total Bobot (Kg)', + }, + { + accessorFn: (row: MarketingProduct) => formatNumber(row.qty), + header: 'Kuantitas', + }, + { + accessorFn: (row: MarketingProduct) => formatNumber(row.avg_weight), + header: 'Avg. Bobot (Kg)', + }, + { + accessorFn: (row: MarketingProduct) => formatCurrency(row.total_price), + header: 'Total Penjualan (Rp)', + }, + { + header: 'Aksi', + cell: (props: TanStack.CellContext) => ( +
+ +
+ ), + }, + ], + [handleDeleteProduct] // dependensi tunggal + ); + + return ( + <> +
+ + +
+ + +
+
+ + + rowSelection={rowSelection} + setRowSelection={setRowSelection} + data={marketingProducts} + columns={columns} + className={{ + tableWrapperClassName: 'overflow-x-auto min-h-full!', + tableClassName: 'font-inter w-full table-auto min-h-full!', + headerRowClassName: 'border-b border-b-gray-200', + headerColumnClassName: + 'px-2 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-end', + bodyRowClassName: 'border-b border-b-gray-200', + bodyColumnClassName: + 'px-2 py-2 last:flex last:flex-row last:justify-end first:flex first:flex-row first:justify-start', + paginationClassName: 'hidden', + }} + emptyContent={ +
+ Belum ada data penjualan +
+ } + /> +
+ + {selectedRowIds.length > 0 && ( + + )} +
+
+
+