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) && (
- <>
-
-
-
- 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 (
-
);
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 (
+
+
+
+
+ Detail
+
+
+
+ Edit
+
+
+
+ Delete
+
+
+
+ );
+};
+
+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 (
+ <>
+
+
+
+
+
+
+
+ Approve
+
+
+
+
+ Reject
+
+
+
+
(
+
+
+
+ ),
+ 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 (
+ {
+ productsClickHandler(props?.row?.original);
+ }}
+ >
+ Lihat {props?.row?.original?.marketing_products?.length}{' '}
+ Produk
+
+ );
+ } 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 && (
+ <>
+
+
+ Approve
+
+
+
+ Reject
+
+ >
+ )}
+ {initialValues?.approval?.step_number == 2 && (
+
+
+ Delivery Order
+
+ )}
+
+
+
+
+
+
+ |
+ 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',
+ }}
+ />
+
+ )}
+
+
+
+ Edit
+
+
+
+ Hapus
+
+
+
+
+
+
+ >
+ );
+};
+
+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(props.row.original.id)}
+ >
+
+
+
+ ),
+ },
+ ],
+ [handleDeleteProduct] // dependensi tunggal
+ );
+
+ return (
+ <>
+