From 250c42a04b43c4e3c614792a3795cab063f65a58 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 7 Oct 2025 21:23:12 +0700 Subject: [PATCH] feat(FE-40,41,42): add Product management pages with form handling and table display --- src/app/master-data/product/add/page.tsx | 11 + .../master-data/product/detail/edit/page.tsx | 45 ++ src/app/master-data/product/detail/page.tsx | 45 ++ src/app/master-data/product/page.tsx | 11 + .../master-data/product/form/ProductForm.tsx | 438 ++++++++++++++++++ 5 files changed, 550 insertions(+) create mode 100644 src/app/master-data/product/add/page.tsx create mode 100644 src/app/master-data/product/detail/edit/page.tsx create mode 100644 src/app/master-data/product/detail/page.tsx create mode 100644 src/app/master-data/product/page.tsx create mode 100644 src/components/pages/master-data/product/form/ProductForm.tsx 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'} +

+
+
+
+ + + + + + + + + + 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