diff --git a/.gitignore b/.gitignore
index 5ef6a520..2b4315f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# idea
+.idea
\ No newline at end of file
diff --git a/src/app/master-data/product-category/add/page.tsx b/src/app/master-data/product-category/add/page.tsx
new file mode 100644
index 00000000..0993ba7a
--- /dev/null
+++ b/src/app/master-data/product-category/add/page.tsx
@@ -0,0 +1,11 @@
+import ProductCategoryForm from "@/components/pages/master-data/product-category/form/ProductCategoryForm";
+
+const AddProductCategory = () => {
+ return (
+
+ );
+};
+
+export default AddProductCategory;
\ No newline at end of file
diff --git a/src/app/master-data/product-category/detail/edit/page.tsx b/src/app/master-data/product-category/detail/edit/page.tsx
new file mode 100644
index 00000000..6bc10644
--- /dev/null
+++ b/src/app/master-data/product-category/detail/edit/page.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+import useSWR from 'swr';
+
+import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
+
+import { ProductCategoryApi } from '@/services/api/master-data';
+import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
+
+const ProductCategoryEdit = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const productCategoryId = searchParams.get('productCategoryId');
+
+ const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
+ productCategoryId,
+ (id: number) => ProductCategoryApi.getSingle(id)
+ );
+
+ if (!productCategoryId) {
+ router.back();
+
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
+ router.replace('/404');
+ return;
+ }
+
+ return (
+
+ {isLoadingProductCategory &&
}
+ {!isLoadingProductCategory && isResponseSuccess(productCategory) && (
+
+ )}
+
+ );
+}
+
+export default ProductCategoryEdit;
\ No newline at end of file
diff --git a/src/app/master-data/product-category/detail/page.tsx b/src/app/master-data/product-category/detail/page.tsx
new file mode 100644
index 00000000..cba06fdb
--- /dev/null
+++ b/src/app/master-data/product-category/detail/page.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+import useSWR from 'swr';
+
+import ProductCategoryForm from '@/components/pages/master-data/product-category/form/ProductCategoryForm';
+
+import { ProductCategoryApi } from '@/services/api/master-data';
+import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
+
+const ProductCategoryDetail = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const productCategoryId = searchParams.get('productCategoryId');
+
+ const { data: productCategory, isLoading: isLoadingProductCategory } = useSWR(
+ productCategoryId,
+ (id: number) => ProductCategoryApi.getSingle(id)
+ );
+
+ if (!productCategoryId) {
+ router.back();
+
+ return (
+
+
+
+ );
+ }
+
+ if (!isLoadingProductCategory && (!productCategory || isResponseError(productCategory))) {
+ router.replace('/404');
+ return;
+ }
+
+ return (
+
+ {isLoadingProductCategory &&
}
+ {!isLoadingProductCategory && isResponseSuccess(productCategory) && (
+
+ )}
+
+ );
+};
+
+export default ProductCategoryDetail;
diff --git a/src/app/master-data/product-category/page.tsx b/src/app/master-data/product-category/page.tsx
new file mode 100644
index 00000000..5ec6d555
--- /dev/null
+++ b/src/app/master-data/product-category/page.tsx
@@ -0,0 +1,11 @@
+import ProductCategoryTable from "@/components/pages/master-data/product-category/ProductCategoryTable";
+
+const ProductCategory = () => {
+ return (
+
+ );
+};
+
+export default ProductCategory;
\ No newline at end of file
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-category/ProductCategoryTable.tsx b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx
new file mode 100644
index 00000000..f8413ab6
--- /dev/null
+++ b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx
@@ -0,0 +1,266 @@
+'use client';
+
+import { ChangeEventHandler, useEffect, useState } from 'react';
+import useSWR from 'swr';
+import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
+import toast from 'react-hot-toast';
+
+import { Icon } from '@iconify/react';
+import Table from '@/components/Table';
+import DebouncedTextInput from '@/components/input/DebouncedTextInput';
+import Button from '@/components/Button';
+import { useModal } from '@/components/Modal';
+import ConfirmationModal from '@/components/modal/ConfirmationModal';
+import SelectInput, { OptionType } from '@/components/input/SelectInput';
+import RowDropdownOptions from '@/components/table/RowDropdownOptions';
+import RowCollapseOptions from '@/components/table/RowCollapseOptions';
+
+import { ProductCategory } from '@/types/api/master-data/product-category';
+import { ProductCategoryApi } from '@/services/api/master-data';
+import { cn } from '@/lib/helper';
+import { isResponseSuccess } from '@/lib/api-helper';
+import { useTableFilter } from '@/services/hooks/useTableFilter';
+import { ROWS_OPTIONS } from '@/config/constant';
+
+const RowOptionsMenu = ({
+ type = 'dropdown',
+ props,
+ deleteClickHandler,
+}: {
+ type: 'dropdown' | 'collapse';
+ props: CellContext;
+ deleteClickHandler: () => void;
+}) => {
+ return (
+
+
+
+
+
+ );
+};
+
+const ProductCategoryTable = () => {
+ const {
+ state: tableFilterState,
+ updateFilter,
+ setPage,
+ setPageSize,
+ toQueryString: getTableFilterQueryString,
+ } = useTableFilter({
+ initial: { search: '', nameSort: '' },
+ paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name' },
+ });
+
+ const {
+ data: productCategories,
+ isLoading,
+ mutate: refreshProductCategories,
+ } = useSWR(
+ `${ProductCategoryApi.basePath}${getTableFilterQueryString()}`,
+ ProductCategoryApi.getAllFetcher
+ );
+
+ const deleteModal = useModal();
+
+ const [selectedProductCategory, setSelectedProductCategory] = useState(undefined);
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+
+ const [sorting, setSorting] = useState([]);
+
+ const productCategoryColumns: ColumnDef[] = [
+ {
+ header: '#',
+ cell: (props) =>
+ tableFilterState.pageSize * (tableFilterState.page - 1) +
+ props.row.index +
+ 1,
+ },
+ {
+ accessorKey: 'code',
+ header: 'Code',
+ },
+ {
+ accessorKey: 'name',
+ header: 'Nama',
+ },
+ {
+ 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 = () => {
+ setSelectedProductCategory(props.row.original);
+ deleteModal.openModal();
+ };
+
+ return (
+ <>
+ {currentPageSize > 2 && (
+
+
+
+ )}
+
+ {currentPageSize <= 2 && (
+
+
+
+ )}
+ >
+ );
+ },
+ },
+ ];
+
+ const confirmationModalDeleteClickHandler = async () => {
+ setIsDeleteLoading(true);
+
+ await ProductCategoryApi.delete(selectedProductCategory?.id as number);
+ refreshProductCategories();
+
+ deleteModal.closeModal();
+ toast.success('Successfully delete Product Category!');
+ setIsDeleteLoading(false);
+ };
+
+ const searchChangeHandler: ChangeEventHandler = (e) => {
+ updateFilter('search', e.target.value);
+ };
+
+ const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
+ const newVal = val as OptionType;
+ setPageSize(newVal.value as number);
+ };
+
+ useEffect(() => {
+ const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
+ if (!isNameSorted) {
+ updateFilter('nameSort', '');
+ } else {
+ updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
+ }
+ }, [sorting]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ data={isResponseSuccess(productCategories) ? productCategories?.data : []}
+ columns={productCategoryColumns}
+ pageSize={tableFilterState.pageSize}
+ page={isResponseSuccess(productCategories) ? productCategories?.meta?.page : 0}
+ totalItems={isResponseSuccess(productCategories) ? productCategories?.meta?.total_results : 0}
+ onPageChange={setPage}
+ isLoading={isLoading}
+ sorting={sorting}
+ setSorting={setSorting}
+ className={{
+ containerClassName: cn({
+ 'mb-20': isResponseSuccess(productCategories) && productCategories?.data?.length === 0,
+ }),
+ tableWrapperClassName: 'overflow-x-auto min-h-full!',
+ tableClassName: 'font-inter w-full table-auto min-h-full!',
+ headerRowClassName: 'border-b border-b-gray-200',
+ headerColumnClassName:
+ 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
+ bodyRowClassName: 'border-b border-b-gray-200',
+ bodyColumnClassName:
+ 'px-6 py-3 last:flex last:flex-row last:justify-end',
+ }}
+ />
+
+
+ >
+ );
+};
+
+export default ProductCategoryTable;
\ No newline at end of file
diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts
new file mode 100644
index 00000000..102bb812
--- /dev/null
+++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.schema.ts
@@ -0,0 +1,10 @@
+import * as Yup from 'yup';
+
+export const ProductCategoryFormSchema = Yup.object({
+ code: Yup.string().required('Kode wajib diisi!').max(3, 'Kode kategori produk melebihi 3 karakter!'),
+ name: Yup.string().required('Nama wajib diisi!'),
+});
+
+export const UpdateProductCategoryFormSchema = ProductCategoryFormSchema;
+
+export type ProductCategoryFormValues = Yup.InferType;
\ No newline at end of file
diff --git a/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx
new file mode 100644
index 00000000..453670f3
--- /dev/null
+++ b/src/components/pages/master-data/product-category/form/ProductCategoryForm.tsx
@@ -0,0 +1,266 @@
+'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 { Icon } from '@iconify/react';
+import Button from '@/components/Button';
+import TextInput from '@/components/input/TextInput';
+import { useModal } from '@/components/Modal';
+import ConfirmationModal from '@/components/modal/ConfirmationModal';
+
+import {
+ ProductCategoryFormSchema,
+ ProductCategoryFormValues,
+ UpdateProductCategoryFormSchema,
+} from '@/components/pages/master-data/product-category/form/ProductCategoryForm.schema';
+import { isResponseError } from '@/lib/api-helper';
+import {
+ ProductCategory,
+ CreateProductCategoryPayload,
+ UpdateProductCategoryPayload,
+} from '@/types/api/master-data/product-category';
+import { ProductCategoryApi } from '@/services/api/master-data';
+import { cn } from '@/lib/helper';
+
+interface ProductCategoryFormProps {
+ type?: 'add' | 'edit' | 'detail';
+ initialValues?: ProductCategory;
+}
+
+const ProductCategoryForm = ({ type = 'add', initialValues }: ProductCategoryFormProps) => {
+ const router = useRouter();
+ const deleteModal = useModal();
+
+ const [formErrorMessage, setFormErrorMessage] = useState('');
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+
+ const createProductCategoryHandler = useCallback(
+ async (payload: CreateProductCategoryPayload) => {
+ const res = await ProductCategoryApi.create(payload);
+
+ if (isResponseError(res)) {
+ setFormErrorMessage(res.message);
+ return;
+ }
+
+ toast.success(res?.message as string);
+ router.push('/master-data/product-category');
+ },
+ [router]
+ );
+
+ const updateProductCategoryHandler = useCallback(
+ async (id: number, payload: UpdateProductCategoryPayload) => {
+ const res = await ProductCategoryApi.update(id, payload);
+
+ if (res?.status === 'error') {
+ setFormErrorMessage(res.message);
+ return;
+ }
+
+ toast.success(res?.message as string);
+ router.refresh();
+ router.push('/master-data/product-category');
+ },
+ [router]
+ );
+
+ const formikInitialValues = useMemo(() => {
+ return {
+ code: initialValues?.code ?? '',
+ name: initialValues?.name ?? '',
+ };
+ }, [initialValues]);
+
+ const formik = useFormik({
+ initialValues: formikInitialValues,
+ validationSchema: type === 'edit' ? UpdateProductCategoryFormSchema : ProductCategoryFormSchema,
+ onSubmit: async (values) => {
+ setFormErrorMessage('');
+
+ const payload: CreateProductCategoryPayload = {
+ code: values.code,
+ name: values.name,
+ };
+
+ switch (type) {
+ case 'add':
+ await createProductCategoryHandler(payload);
+ break;
+ case 'edit':
+ await updateProductCategoryHandler(initialValues?.id as number, payload);
+ break;
+ }
+ },
+ });
+
+ const { setValues: formikSetValues } = formik;
+
+ const deleteProductCategoryClickHandler = () => {
+ deleteModal.openModal();
+ };
+
+ const confirmationModalDeleteClickHandler = async () => {
+ setIsDeleteLoading(true);
+
+ await ProductCategoryApi.delete(initialValues?.id as number);
+
+ deleteModal.closeModal();
+ toast.success('Successfully delete Product Category!');
+ setIsDeleteLoading(false);
+ router.push('/master-data/product-category');
+ };
+
+ useEffect(() => {
+ formikSetValues(formikInitialValues);
+ }, [formikSetValues, formikInitialValues]);
+
+ return (
+ <>
+
+
+
+
+
+ {type === 'add' && 'Tambah Product Category'}
+ {type === 'edit' && 'Edit Product Category'}
+ {type === 'detail' && 'Detail Product Category'}
+
+
+
+
+
+
+ {type !== 'add' && (
+
+ )}
+ >
+ );
+};
+
+export default ProductCategoryForm;
\ No newline at end of file
diff --git a/src/components/pages/master-data/product/ProductTable.tsx b/src/components/pages/master-data/product/ProductTable.tsx
new file mode 100644
index 00000000..ab256548
--- /dev/null
+++ b/src/components/pages/master-data/product/ProductTable.tsx
@@ -0,0 +1,350 @@
+'use client';
+
+import { ChangeEventHandler, useCallback, useEffect, useState } from 'react';
+import useSWR from 'swr';
+import {
+ CellContext,
+ ColumnDef,
+ ColumnSort,
+ SortingState,
+} from '@tanstack/react-table';
+import toast from 'react-hot-toast';
+
+import { Icon } from '@iconify/react';
+import Table from '@/components/Table';
+import DebouncedTextInput from '@/components/input/DebouncedTextInput';
+import Button from '@/components/Button';
+import { useModal } from '@/components/Modal';
+import ConfirmationModal from '@/components/modal/ConfirmationModal';
+import SelectInput, { OptionType } from '@/components/input/SelectInput';
+import RowDropdownOptions from '@/components/table/RowDropdownOptions';
+import RowCollapseOptions from '@/components/table/RowCollapseOptions';
+
+import { Product } from '@/types/api/master-data/product';
+import { ProductApi } from '@/services/api/master-data';
+import { cn } from '@/lib/helper';
+import { isResponseSuccess } from '@/lib/api-helper';
+import { useTableFilter } from '@/services/hooks/useTableFilter';
+import { ROWS_OPTIONS } from '@/config/constant';
+
+const RowOptionsMenu = ({
+ type = 'dropdown',
+ props,
+ deleteClickHandler,
+}: {
+ type: 'dropdown' | 'collapse';
+ props: CellContext;
+ deleteClickHandler: () => void;
+}) => (
+
+
+
+
+
+);
+
+const ProductsTable = () => {
+ const {
+ state: tableFilterState,
+ updateFilter,
+ setPage,
+ setPageSize,
+ toQueryString: getTableFilterQueryString,
+ } = useTableFilter({
+ initial: {
+ search: '',
+ nameSort: '',
+ skuSort: '',
+ brandSort: '',
+ categorySort: '',
+ },
+ paramMap: {
+ page: 'page',
+ pageSize: 'limit',
+ nameSort: 'sort_name',
+ skuSort: 'sort_sku',
+ brandSort: 'sort_brand',
+ categorySort: 'sort_category',
+ },
+ });
+
+ const {
+ data: products,
+ isLoading,
+ mutate: refreshProducts,
+ } = useSWR(
+ `${ProductApi.basePath}${getTableFilterQueryString()}`,
+ ProductApi.getAllFetcher
+ );
+
+ const deleteModal = useModal();
+ const [selectedProduct, setSelectedProduct] = useState(undefined);
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+ const [sorting, setSorting] = useState([]);
+
+ const productsColumns: ColumnDef[] = [
+ {
+ header: '#',
+ cell: (props) =>
+ tableFilterState.pageSize * (tableFilterState.page - 1) +
+ props.row.index +
+ 1,
+ },
+ {
+ accessorKey: 'name',
+ header: 'Nama',
+ },
+ {
+ accessorKey: 'sku',
+ header: 'SKU',
+ },
+ {
+ accessorKey: 'brand',
+ header: 'Merek',
+ },
+ {
+ accessorKey: 'product_category',
+ header: 'Kategori',
+ cell: (props) => props.row.original.product_category?.name ?? '-',
+ },
+ {
+ accessorKey: 'uom',
+ header: 'Satuan',
+ cell: (props) => props.row.original.uom?.name ?? '-',
+ },
+ {
+ accessorKey: 'product_price',
+ header: 'Harga Produk',
+ cell: (props) => props.row.original.product_price?.toLocaleString() ?? '-',
+ },
+ {
+ accessorKey: 'selling_price',
+ header: 'Harga Jual',
+ cell: (props) => props.row.original.selling_price?.toLocaleString() ?? '-',
+ },
+ {
+ accessorKey: 'tax',
+ header: 'Pajak (%)',
+ cell: (props) => props.row.original.tax ?? '-',
+ },
+ {
+ accessorKey: 'expiry_period',
+ header: 'Kadaluarsa (hari)',
+ cell: (props) => props.row.original.expiry_period ?? '-',
+ },
+ {
+ accessorKey: 'suppliers',
+ header: 'Supplier',
+ cell: (props) =>
+ props.row.original.suppliers?.map((s) => s.name).join(', ') || '-',
+ },
+ {
+ accessorKey: 'flags',
+ header: 'Flags',
+ cell: (props) =>
+ props.row.original.flags?.length
+ ? props.row.original.flags.join(', ')
+ : '-',
+ },
+ {
+ 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 = () => {
+ setSelectedProduct(props.row.original);
+ deleteModal.openModal();
+ };
+
+ return (
+ <>
+ {currentPageSize > 2 && (
+
+
+
+ )}
+ {currentPageSize <= 2 && (
+
+
+
+ )}
+ >
+ );
+ },
+ },
+ ];
+
+ const confirmationModalDeleteClickHandler = async () => {
+ setIsDeleteLoading(true);
+ await ProductApi.delete(selectedProduct?.id as number);
+ refreshProducts();
+ deleteModal.closeModal();
+ toast.success('Successfully delete Product!');
+ setIsDeleteLoading(false);
+ };
+
+ const searchChangeHandler: ChangeEventHandler = (e) => {
+ updateFilter('search', e.target.value);
+ };
+
+ const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
+ const newVal = val as OptionType;
+ setPageSize(newVal.value as number);
+ };
+
+ const updateSortingFilter = useCallback(
+ (
+ sortName: Exclude,
+ sortFilter: ColumnSort | undefined
+ ) => {
+ if (!sortFilter) {
+ updateFilter(sortName, '');
+ } else {
+ updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
+ }
+ },
+ [updateFilter]
+ );
+
+ useEffect(() => {
+ const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
+ const skuSortFilter = sorting.find((sortItem) => sortItem.id === 'sku');
+ const brandSortFilter = sorting.find((sortItem) => sortItem.id === 'brand');
+ const categorySortFilter = sorting.find((sortItem) => sortItem.id === 'product_category');
+
+ updateSortingFilter('nameSort', nameSortFilter);
+ updateSortingFilter('skuSort', skuSortFilter);
+ updateSortingFilter('brandSort', brandSortFilter);
+ updateSortingFilter('categorySort', categorySortFilter);
+ }, [sorting]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ data={isResponseSuccess(products) ? products?.data : []}
+ columns={productsColumns}
+ pageSize={tableFilterState.pageSize}
+ page={isResponseSuccess(products) ? products?.meta?.page : 0}
+ totalItems={
+ isResponseSuccess(products) ? products?.meta?.total_results : 0
+ }
+ onPageChange={setPage}
+ isLoading={isLoading}
+ sorting={sorting}
+ setSorting={setSorting}
+ className={{
+ containerClassName: cn({
+ 'mb-20':
+ isResponseSuccess(products) && products?.data?.length === 0,
+ }),
+ tableWrapperClassName: 'overflow-x-auto min-h-full!',
+ tableClassName: 'font-inter w-full table-auto min-h-full!',
+ headerRowClassName: 'border-b border-b-gray-200',
+ headerColumnClassName:
+ 'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
+ bodyRowClassName: 'border-b border-b-gray-200',
+ bodyColumnClassName:
+ 'px-6 py-3 last:flex last:flex-row last:justify-end',
+ }}
+ />
+
+
+ >
+ );
+};
+
+export default ProductsTable;
\ No newline at end of file
diff --git a/src/components/pages/master-data/product/form/ProductForm.schema.ts b/src/components/pages/master-data/product/form/ProductForm.schema.ts
new file mode 100644
index 00000000..eea9abf7
--- /dev/null
+++ b/src/components/pages/master-data/product/form/ProductForm.schema.ts
@@ -0,0 +1,53 @@
+import * as Yup from 'yup';
+
+export const ProductFormSchema = Yup.object({
+ name: Yup.string().required('Nama wajib diisi!'),
+ brand: Yup.string().required('Merek wajib diisi!'),
+ sku: Yup.string().required('SKU wajib diisi!'),
+ uom: Yup.object({
+ value: Yup.number().min(1).required(),
+ label: Yup.string().required(),
+ }).nullable(),
+ uom_id: Yup.number().required('Satuan wajib diisi!').typeError('Satuan wajib diisi!'),
+ product_category: Yup.object({
+ value: Yup.number().min(1).required(),
+ label: Yup.string().required(),
+ }).nullable(),
+ product_category_id: Yup.number()
+ .required('Kategori produk wajib diisi!')
+ .typeError('Kategori produk wajib diisi!'),
+ product_price: Yup.number()
+ .required('Harga produk wajib diisi!')
+ .typeError('Harga produk wajib diisi!')
+ .min(0, 'Harga produk tidak boleh kurang dari 0!'),
+ selling_price: Yup.number()
+ .required('Harga jual wajib diisi!')
+ .typeError('Harga jual wajib diisi!')
+ .min(0, 'Harga jual tidak boleh kurang dari 0!'),
+ tax: Yup.number()
+ .required('Pajak wajib diisi!')
+ .typeError('Pajak wajib diisi!')
+ .min(0, 'Pajak tidak boleh kurang dari 0!')
+ .max(100, 'Pajak tidak boleh lebih dari 100%!'),
+ expiry_period: Yup.number()
+ .required('Periode kadaluarsa wajib diisi!')
+ .typeError('Periode kadaluarsa wajib diisi!')
+ .min(0, 'Periode kadaluarsa tidak boleh kurang dari 0!'),
+ supplier: Yup.object({
+ value: Yup.number().min(1).required(),
+ label: Yup.string().required(),
+ }).nullable(),
+ supplier_ids: Yup.array()
+ .of(Yup.number().typeError('Supplier tidak valid!'))
+ .min(1, 'Minimal harus ada 1 supplier!')
+ .required('Supplier wajib diisi!'),
+ flags: Yup.array()
+ .of(Yup.string())
+ .min(1, 'Minimal harus ada 1 flag!')
+ .required('Flag wajib diisi!'),
+});
+
+export const UpdateProductFormSchema = ProductFormSchema;
+
+export type ProductFormValues = Yup.InferType;
+
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
diff --git a/src/config/constant.ts b/src/config/constant.ts
index 55eed0b3..1fbef81f 100644
--- a/src/config/constant.ts
+++ b/src/config/constant.ts
@@ -107,3 +107,16 @@ export const WAREHOUSE_TYPE_OPTIONS = [
value: 'KANDANG',
},
];
+
+export const PRODUCT_FLAG_OPTIONS = [
+ { label: 'DOC', value: 'DOC' },
+ { label: 'PAKAN', value: 'PAKAN' },
+ { label: 'PRE-STARTER', value: 'PRE-STARTER' },
+ { label: 'STARTER', value: 'STARTER' },
+ { label: 'FINISHER', value: 'FINISHER' },
+ { label: 'OVK', value: 'OVK' },
+ { label: 'OBAT', value: 'OBAT' },
+ { label: 'VITAMIN', value: 'VITAMIN' },
+ { label: 'KIMIA', value: 'KIMIA' },
+];
+
diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts
index 785a1ca1..7429b8ef 100644
--- a/src/services/api/master-data.ts
+++ b/src/services/api/master-data.ts
@@ -24,6 +24,21 @@ import {
UpdateWarehousePayload,
Warehouse,
} from '@/types/api/master-data/warehouse';
+import {
+ CreateProductCategoryPayload,
+ ProductCategory,
+ UpdateProductCategoryPayload,
+} from '@/types/api/master-data/product-category';
+import {
+ CreateProductPayload,
+ Product,
+ UpdateProductPayload,
+} from '@/types/api/master-data/product';
+import {
+ CreateSupplierPayload,
+ Supplier,
+ UpdateSupplierPayload,
+} from '@/types/api/master-data/supplier';
export const UomApi = new BaseApiService<
Uom,
@@ -54,3 +69,21 @@ export const WarehouseApi = new BaseApiService<
CreateWarehousePayload,
UpdateWarehousePayload
>('/master-data/warehouses');
+
+export const ProductCategoryApi = new BaseApiService<
+ ProductCategory,
+ CreateProductCategoryPayload,
+ UpdateProductCategoryPayload
+>('/master-data/product-categories');
+
+export const ProductApi = new BaseApiService<
+ Product,
+ CreateProductPayload,
+ UpdateProductPayload
+>('/master-data/products');
+
+export const SupplierApi = new BaseApiService<
+ Supplier,
+ CreateSupplierPayload,
+ UpdateSupplierPayload
+>('/master-data/suppliers');
\ No newline at end of file
diff --git a/src/types/api/master-data/product-category.d.ts b/src/types/api/master-data/product-category.d.ts
new file mode 100644
index 00000000..3dd6203e
--- /dev/null
+++ b/src/types/api/master-data/product-category.d.ts
@@ -0,0 +1,16 @@
+import { BaseMetadata } from '@/types/api/api-general';
+
+export type BaseProductCategory = {
+ id: number;
+ code: string;
+ name: string;
+};
+
+export type ProductCategory = BaseMetadata & BaseProductCategory;
+
+export type CreateProductCategoryPayload = {
+ code: string;
+ name: string;
+};
+
+export type UpdateProductCategoryPayload = CreateProductCategoryPayload;
diff --git a/src/types/api/master-data/product.d.ts b/src/types/api/master-data/product.d.ts
new file mode 100644
index 00000000..d4039750
--- /dev/null
+++ b/src/types/api/master-data/product.d.ts
@@ -0,0 +1,37 @@
+import { BaseMetadata } from '@/types/api/api-general';
+import { Uom } from '@/types/api/master-data/uom';
+import { ProductCategory } from '@/types/api/master-data/product-category';
+import { Supplier } from '@/types/api/master-data/supplier';
+
+export type BaseProduct = {
+ id: number;
+ name: string;
+ brand: string;
+ sku: string;
+ product_price: number;
+ selling_price?: number;
+ tax?: number;
+ expiry_period: number;
+ uom: Uom;
+ product_category: ProductCategory;
+ suppliers: Supplier[];
+ flags: string[];
+};
+
+export type Product = BaseMetadata & BaseProduct;
+
+export type CreateProductPayload = {
+ name: string;
+ brand: string;
+ sku: string;
+ uom_id: number;
+ product_category_id: number;
+ product_price: number;
+ selling_price: number;
+ tax: number;
+ expiry_period: number;
+ supplier_ids: number[];
+ flags: string[];
+};
+
+export type UpdateProductPayload = CreateProductPayload;
\ No newline at end of file
diff --git a/src/types/api/master-data/supplier.d.ts b/src/types/api/master-data/supplier.d.ts
new file mode 100644
index 00000000..f2cfdb11
--- /dev/null
+++ b/src/types/api/master-data/supplier.d.ts
@@ -0,0 +1,34 @@
+import { BaseMetadata } from '@/types/api/api-general';
+
+export type BaseSupplier = {
+ id: number;
+ name: string;
+ alias: string;
+ category: string;
+ pic: string;
+ type: string;
+ phone: string;
+ email: string;
+ address: string;
+ account_number: string;
+ balance: number;
+ due_date: number;
+};
+
+export type Supplier = BaseMetadata & BaseSupplier;
+
+export type CreateSupplierPayload = {
+ name: string;
+ alias: string;
+ category: string;
+ pic: string;
+ type: string;
+ phone: string;
+ email: string;
+ address: string;
+ account_number: string;
+ balance: number;
+ due_date: number;
+};
+
+export type UpdateSupplierPayload = CreateSupplierPayload;
\ No newline at end of file