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' && ( +
+ + + {type !== 'edit' && ( + + )} +
+ )} + + {type !== 'detail' && ( +
+ + + +
+ )} +
+ + {formErrorMessage && ( +
+ + {formErrorMessage} +
+ )} +
+
+ + {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'} +

+
+
+
+ + + + + + + + + + formik.values.supplier_ids.includes(opt.value))} + onChange={supplierChangeHandler} + options={supplierOptions} + onInputChange={setSupplierSelectInputValue} + isLoading={isLoadingSuppliers} + isError={formik.touched.supplier_ids && Boolean(formik.errors.supplier_ids)} + errorMessage={formik.errors.supplier_ids as string} + isDisabled={type === 'detail'} + isClearable + /> + formik.values.flags.includes(opt.value))} + onChange={val => { + const arr = Array.isArray(val) ? val : val ? [val] : []; + formik.setFieldValue('flags', arr.map((v) => (v as OptionType).value)); + }} + options={PRODUCT_FLAG_OPTIONS} + isError={formik.touched.flags && Boolean(formik.errors.flags)} + errorMessage={formik.errors.flags as string} + isDisabled={type === 'detail'} + isClearable + /> +
+
+ {type !== 'add' && ( +
+ + {type !== 'edit' && ( + + )} +
+ )} + {type !== 'detail' && ( +
+ + +
+ )} +
+ {productFormErrorMessage && ( +
+ + {productFormErrorMessage} +
+ )} +
+
+ {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