diff --git a/src/app/master-data/product/add/page.tsx b/src/app/master-data/product/add/page.tsx
new file mode 100644
index 00000000..7cc995b6
--- /dev/null
+++ b/src/app/master-data/product/add/page.tsx
@@ -0,0 +1,11 @@
+import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
+
+const AddProduct = () => {
+ return (
+
+ );
+};
+
+export default AddProduct;
\ No newline at end of file
diff --git a/src/app/master-data/product/detail/edit/page.tsx b/src/app/master-data/product/detail/edit/page.tsx
new file mode 100644
index 00000000..96cfdc42
--- /dev/null
+++ b/src/app/master-data/product/detail/edit/page.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+import useSWR from 'swr';
+
+import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
+import { ProductApi } from '@/services/api/master-data';
+import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
+
+const ProductEdit = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const productId = searchParams.get('productId');
+
+ const { data: product, isLoading } = useSWR(
+ productId,
+ (id: number) => ProductApi.getSingle(id)
+ );
+
+ if (!productId) {
+ router.back();
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoading && (!product || isResponseError(product))) {
+ router.replace('/404');
+ return;
+ }
+
+ return (
+
+ {isLoading &&
}
+ {!isLoading && isResponseSuccess(product) && (
+
+ )}
+
+ );
+};
+
+export default ProductEdit;
\ No newline at end of file
diff --git a/src/app/master-data/product/detail/page.tsx b/src/app/master-data/product/detail/page.tsx
new file mode 100644
index 00000000..916a44d0
--- /dev/null
+++ b/src/app/master-data/product/detail/page.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+import useSWR from 'swr';
+
+import ProductForm from '@/components/pages/master-data/product/form/ProductForm';
+import { ProductApi } from '@/services/api/master-data';
+import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
+
+const ProductDetail = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const productId = searchParams.get('productId');
+
+ const { data: product, isLoading } = useSWR(
+ productId,
+ (id: number) => ProductApi.getSingle(id)
+ );
+
+ if (!productId) {
+ router.back();
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoading && (!product || isResponseError(product))) {
+ router.replace('/404');
+ return;
+ }
+
+ return (
+
+ {isLoading &&
}
+ {!isLoading && isResponseSuccess(product) && (
+
+ )}
+
+ );
+};
+
+export default ProductDetail;
\ No newline at end of file
diff --git a/src/app/master-data/product/page.tsx b/src/app/master-data/product/page.tsx
new file mode 100644
index 00000000..6014aeb9
--- /dev/null
+++ b/src/app/master-data/product/page.tsx
@@ -0,0 +1,11 @@
+import ProductsTable from "@/components/pages/master-data/product/ProductTable";
+
+const Product = () => {
+ return (
+
+ );
+};
+
+export default Product;
\ No newline at end of file
diff --git a/src/components/pages/master-data/product/form/ProductForm.tsx b/src/components/pages/master-data/product/form/ProductForm.tsx
new file mode 100644
index 00000000..02afbfc9
--- /dev/null
+++ b/src/components/pages/master-data/product/form/ProductForm.tsx
@@ -0,0 +1,438 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useFormik } from 'formik';
+import { toast } from 'react-hot-toast';
+import useSWR from 'swr';
+
+import { Icon } from '@iconify/react';
+import Button from '@/components/Button';
+import TextInput from '@/components/input/TextInput';
+import SelectInput, { OptionType } from '@/components/input/SelectInput';
+import { useModal } from '@/components/Modal';
+import ConfirmationModal from '@/components/modal/ConfirmationModal';
+
+import {
+ ProductFormSchema,
+ ProductFormValues,
+ UpdateProductFormSchema,
+} from '@/components/pages/master-data/product/form/ProductForm.schema';
+import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
+import {
+ Product,
+ CreateProductPayload,
+ UpdateProductPayload,
+} from '@/types/api/master-data/product';
+import { UomApi, ProductCategoryApi, SupplierApi, ProductApi } from '@/services/api/master-data';
+import { cn } from '@/lib/helper';
+import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
+
+interface ProductFormProps {
+ type?: 'add' | 'edit' | 'detail';
+ initialValues?: Product;
+}
+
+const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
+ const router = useRouter();
+ const deleteModal = useModal();
+
+ const [productFormErrorMessage, setProductFormErrorMessage] = useState('');
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+
+ const createProductHandler = useCallback(
+ async (payload: CreateProductPayload) => {
+ const res = await ProductApi.create(payload);
+ if (isResponseError(res)) {
+ setProductFormErrorMessage(res.message);
+ return;
+ }
+ toast.success(res?.message as string);
+ router.push('/master-data/product');
+ },
+ [router]
+ );
+
+ const updateProductHandler = useCallback(
+ async (productId: number, payload: UpdateProductPayload) => {
+ const res = await ProductApi.update(productId, payload);
+ if (res?.status === 'error') {
+ setProductFormErrorMessage(res.message);
+ return;
+ }
+ toast.success(res?.message as string);
+ router.refresh();
+ router.push('/master-data/product');
+ },
+ [router]
+ );
+
+ const formikInitialValues = useMemo(() => ({
+ name: initialValues?.name ?? '',
+ brand: initialValues?.brand ?? '',
+ sku: initialValues?.sku ?? '',
+ uom: initialValues?.uom
+ ? { value: initialValues.uom.id, label: initialValues.uom.name }
+ : null,
+ uom_id: initialValues?.uom?.id ?? 0,
+ product_category: initialValues?.product_category
+ ? { value: initialValues.product_category.id, label: initialValues.product_category.name }
+ : null,
+ product_category_id: initialValues?.product_category?.id ?? 0,
+ product_price: initialValues?.product_price ?? 0,
+ selling_price: initialValues?.selling_price ?? 0,
+ tax: initialValues?.tax ?? 0,
+ expiry_period: initialValues?.expiry_period ?? 0,
+ supplier: null, // not used for payload, just for UI
+ supplier_ids: initialValues?.suppliers?.map(s => s.id) ?? [],
+ flags: initialValues?.flags ?? [],
+ }), [initialValues]);
+
+ const formik = useFormik({
+ initialValues: formikInitialValues,
+ validationSchema: type === 'edit' ? UpdateProductFormSchema : ProductFormSchema,
+ onSubmit: async (values) => {
+ setProductFormErrorMessage('');
+ const payload: CreateProductPayload = {
+ name: values.name,
+ brand: values.brand,
+ sku: values.sku,
+ uom_id: values.uom_id,
+ product_category_id: values.product_category_id,
+ product_price: values.product_price,
+ selling_price: values.selling_price,
+ tax: values.tax,
+ expiry_period: values.expiry_period,
+ supplier_ids: (values.supplier_ids ?? []).filter((id): id is number => typeof id === 'number'),
+ flags: (values.flags ?? []).filter((f): f is string => typeof f === 'string'),
+ };
+ switch (type) {
+ case 'add':
+ await createProductHandler(payload);
+ break;
+ case 'edit':
+ await updateProductHandler(initialValues?.id as number, payload);
+ break;
+ }
+ },
+ });
+
+ const { setValues: formikSetValues } = formik;
+
+ // UOM
+ const [uomSelectInputValue, setUomSelectInputValue] = useState('');
+ const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ search: uomSelectInputValue ?? '' }).toString()}`;
+ const { data: uoms, isLoading: isLoadingUoms } = useSWR(uomsUrl, UomApi.getAllFetcher);
+ const uomOptions = isResponseSuccess(uoms)
+ ? uoms?.data.map((uom) => ({ value: uom.id, label: uom.name }))
+ : [];
+ const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
+ formik.setFieldTouched('uom', true);
+ formik.setFieldValue('uom', val);
+ formik.setFieldTouched('uom_id', true);
+ formik.setFieldValue('uom_id', (val as OptionType)?.value);
+ };
+
+ // Product Category
+ const [categorySelectInputValue, setCategorySelectInputValue] = useState('');
+ const categoriesUrl = `${ProductCategoryApi.basePath}?${new URLSearchParams({ search: categorySelectInputValue ?? '' }).toString()}`;
+ const { data: categories, isLoading: isLoadingCategories } = useSWR(categoriesUrl, ProductCategoryApi.getAllFetcher);
+ const categoryOptions = isResponseSuccess(categories)
+ ? categories?.data.map((cat) => ({ value: cat.id, label: cat.name }))
+ : [];
+ const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
+ formik.setFieldTouched('product_category', true);
+ formik.setFieldValue('product_category', val);
+ formik.setFieldTouched('product_category_id', true);
+ formik.setFieldValue('product_category_id', (val as OptionType)?.value);
+ };
+
+ // Supplier (multi select)
+ const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
+ const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
+ const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(suppliersUrl, SupplierApi.getAllFetcher);
+ const supplierOptions = isResponseSuccess(suppliers)
+ ? suppliers?.data
+ .filter((sup) => sup.category === 'SAPRONAK')
+ .map((sup) => ({ value: sup.id, label: sup.name }))
+ : [];
+ const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
+ const arr = Array.isArray(val) ? val : val ? [val] : [];
+ formik.setFieldTouched('supplier_ids', true);
+ formik.setFieldValue('supplier_ids', arr.map((v) => (v as OptionType).value));
+ };
+
+ const deleteProductClickHandler = () => {
+ deleteModal.openModal();
+ };
+
+ const confirmationModalDeleteClickHandler = async () => {
+ setIsDeleteLoading(true);
+ await ProductApi.delete(initialValues?.id as number);
+ deleteModal.closeModal();
+ toast.success('Successfully delete Product!');
+ setIsDeleteLoading(false);
+ router.push('/master-data/product');
+ };
+
+ useEffect(() => {
+ formikSetValues(formikInitialValues);
+ }, [formikSetValues, formikInitialValues]);
+
+ return (
+ <>
+
+
+
+
+ {type === 'add' && 'Tambah Produk'}
+ {type === 'edit' && 'Edit Produk'}
+ {type === 'detail' && 'Detail Produk'}
+
+
+
+
+ {type !== 'add' && (
+
+ )}
+ >
+ );
+};
+
+export default ProductForm;
\ No newline at end of file