From 21cc01fe681bb218654ea02322e1f7cf8cee1127 Mon Sep 17 00:00:00 2001 From: randy-ar Date: Thu, 9 Oct 2025 04:34:56 +0700 Subject: [PATCH] feat(FE-33): create customers table and details --- src/app/master-data/customer/add/page.tsx | 2 +- .../master-data/customer/detail/edit/page.tsx | 47 ++ src/app/master-data/customer/detail/page.tsx | 45 ++ .../master-data/customer/CustomersTable.tsx | 117 +++-- .../customer/form/CustomerForm.schema.ts | 25 +- .../customer/form/CustomerForm.tsx | 421 ++++++++++-------- src/types/api/master-data/customer.d.ts | 4 +- 7 files changed, 418 insertions(+), 243 deletions(-) diff --git a/src/app/master-data/customer/add/page.tsx b/src/app/master-data/customer/add/page.tsx index 274b7d90..f303bb32 100644 --- a/src/app/master-data/customer/add/page.tsx +++ b/src/app/master-data/customer/add/page.tsx @@ -2,7 +2,7 @@ import CustomerForm from "@/components/pages/master-data/customer/form/CustomerF const AddNonstock = () => { return ( -
+
); diff --git a/src/app/master-data/customer/detail/edit/page.tsx b/src/app/master-data/customer/detail/edit/page.tsx index e69de29b..3fe8de52 100644 --- a/src/app/master-data/customer/detail/edit/page.tsx +++ b/src/app/master-data/customer/detail/edit/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import { CustomerApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import CustomerForm from '@/components/pages/master-data/customer/form/CustomerForm'; + +const CustomerEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const costumerId = searchParams.get('customerId'); + + const { data: costumer, isLoading: isLoadingCostumer } = useSWR( + costumerId, + (id: number) => CustomerApi.getSingle(id) + ); + + if (!costumerId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingCostumer && (!costumer || isResponseError(costumer))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingCostumer && ( + + )} + {!isLoadingCostumer && isResponseSuccess(costumer) && ( + + )} +
+ ); +}; + +export default CustomerEdit; diff --git a/src/app/master-data/customer/detail/page.tsx b/src/app/master-data/customer/detail/page.tsx index e69de29b..263458c2 100644 --- a/src/app/master-data/customer/detail/page.tsx +++ b/src/app/master-data/customer/detail/page.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useRouter, useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { CustomerApi } from '@/services/api/master-data'; +import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; +import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm"; + +const CustomerDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const costumerId = searchParams.get("customerId"); + + const { data: costumer, isLoading: isLoadingCostumer } = useSWR( + costumerId, + (id: number) => CustomerApi.getSingle(id) + ); + + if(!costumerId){ + router.back(); + + return ( +
+ +
+ ); + } + + if(!isLoadingCostumer && (!costumer || isResponseError(costumer))){ + router.replace("/404"); + return; + } + + return ( +
+ {isLoadingCostumer && } + {!isLoadingCostumer && isResponseSuccess(costumer) && ( + + )} +
+ ) +}; + +export default CustomerDetail; diff --git a/src/components/pages/master-data/customer/CustomersTable.tsx b/src/components/pages/master-data/customer/CustomersTable.tsx index b2b210df..e1811368 100644 --- a/src/components/pages/master-data/customer/CustomersTable.tsx +++ b/src/components/pages/master-data/customer/CustomersTable.tsx @@ -1,33 +1,36 @@ -'use client' +'use client'; -import Button from "@/components/Button"; -import DebouncedTextInput from "@/components/input/DebouncedTextInput"; -import { useModal } from "@/components/Modal"; -import ConfirmationModal from "@/components/modal/ConfirmationModal"; -import Table from "@/components/Table"; -import RowCollapseOptions from "@/components/table/RowCollapseOptions"; -import RowDropdownOptions from "@/components/table/RowDropdownOptions"; -import { isResponseSuccess } from "@/lib/api-helper"; -import { cn } from "@/lib/helper"; -import { CustomerApi } from "@/services/api/master-data"; -import { useTableFilter } from "@/services/hooks/useTableFilter"; -import { Customer } from "@/types/api/master-data/customer"; -import { Icon } from "@iconify/react"; -import { - CellContext, - ColumnDef, +import Button from '@/components/Button'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import Table from '@/components/Table'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import { ROWS_OPTIONS } from '@/config/constant'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn } from '@/lib/helper'; +import { CustomerApi } from '@/services/api/master-data'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { Customer } from '@/types/api/master-data/customer'; +import { Icon } from '@iconify/react'; +import { + CellContext, + ColumnDef, ColumnSort, SortingState, - } from "@tanstack/react-table"; -import { useState } from "react"; -import useSWR from "swr"; +} from '@tanstack/react-table'; +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import useSWR from 'swr'; const RowOptionsMenu = ({ type = 'dropdown', props, deleteClickHandler, -} : { - type: 'dropdown' | 'collapse', +}: { + type: 'dropdown' | 'collapse'; props: CellContext; deleteClickHandler: () => void; }) => { @@ -37,25 +40,33 @@ const RowOptionsMenu = ({ className={cn( { 'dropdown-content': type === 'dropdown', - 'mt-2': type === 'collapse' + 'mt-2': type === 'collapse', }, 'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow' - ) - } - > + )} + > ); -} +}; const CustomersTable = () => { const { @@ -76,31 +88,34 @@ const CustomersTable = () => { setPage, setPageSize, toQueryString: getTableFilterQueryString, - } = useTableFilter ({ - initial: { search: '', nameSort: '', picSort: ''}, + } = useTableFilter({ + initial: { search: '', nameSort: '', picSort: '' }, paramMap: { page: 'page', pageSize: 'limit', nameSort: 'sort_name', picSort: 'sort_pic', - } + }, }); + // Fetch Data const { data: customers, isLoading, - mutate: refreshCustomers + mutate: refreshCustomers, } = useSWR( `${CustomerApi.basePath}${getTableFilterQueryString()}`, CustomerApi.getAllFetcher ); + // State const deleteModal = useModal(); - - const [selectedCustomer, setSelectedCustomer] = useState(undefined); - + const [selectedCustomer, setSelectedCustomer] = useState< + Customer | undefined + >(undefined); const [isDeleteLoading, setIsDeleteLoading] = useState(false); + // Columns Definition const customersColumns: ColumnDef[] = [ { header: '#', @@ -173,6 +188,24 @@ const CustomersTable = () => { }, ]; + // Handler + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await CustomerApi.delete(selectedCustomer?.id as number); + refreshCustomers(); + + deleteModal.closeModal(); + toast.success('Successfully delete Customer!'); + setIsDeleteLoading(false); + }; + const searchChangeHandler = (e: React.ChangeEvent) => { + updateFilter('search', e.target.value); + }; + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; return ( <> @@ -190,11 +223,22 @@ const CustomersTable = () => { name='search' placeholder='Cari Kandang' value={tableFilterState.search} + onChange={searchChangeHandler} className={{ wrapper: 'sm:max-w-3xs' }} />
+
@@ -236,10 +280,11 @@ const CustomersTable = () => { text: 'Ya', color: 'error', isLoading: isDeleteLoading, + onClick: confirmationModalDeleteClickHandler, }} /> ); -} +}; export default CustomersTable; \ No newline at end of file diff --git a/src/components/pages/master-data/customer/form/CustomerForm.schema.ts b/src/components/pages/master-data/customer/form/CustomerForm.schema.ts index 7aedd73b..b69b8ce4 100644 --- a/src/components/pages/master-data/customer/form/CustomerForm.schema.ts +++ b/src/components/pages/master-data/customer/form/CustomerForm.schema.ts @@ -1,24 +1,29 @@ import * as Yup from 'yup'; export const CustomerFormSchema = Yup.object({ - name: Yup.string() - .required('Nama wajib diisi!'), + name: Yup.string().required('Nama wajib diisi!'), - picId: Yup.number() - .min(1, 'PIC wajib diisi!') - .required('PIC wajib diisi!'), + picId: Yup.number().min(1, 'PIC wajib diisi!').required('PIC wajib diisi!'), pic: Yup.object({ value: Yup.number().min(1).required(), label: Yup.string().required(), - }).nullable(), + }).required('PIC wajib diisi!'), - type: Yup.string() - .oneOf(['INDIVIDUAL', 'BISNIS'], 'Tipe harus INDIVIDUAL atau BISNIS') + type: Yup.object({ + value: Yup.string().required(), + label: Yup.string().required(), + }) + .oneOf( + [ + { value: 'INDIVIDUAL', label: 'INDIVIDUAL' }, + { value: 'BISNIS', label: 'BISNIS' }, + ], + 'Tipe harus INDIVIDUAL atau BISNIS' + ) .required('Tipe wajib diisi!'), - address: Yup.string() - .required('Alamat wajib diisi!'), + address: Yup.string().required('Alamat wajib diisi!'), phone: Yup.string() .matches(/^[0-9]+$/, 'Nomor telepon hanya boleh berisi angka!') diff --git a/src/components/pages/master-data/customer/form/CustomerForm.tsx b/src/components/pages/master-data/customer/form/CustomerForm.tsx index 7b744946..e1d3503f 100644 --- a/src/components/pages/master-data/customer/form/CustomerForm.tsx +++ b/src/components/pages/master-data/customer/form/CustomerForm.tsx @@ -1,190 +1,205 @@ -'use client' +'use client'; -import { useModal } from "@/components/Modal"; -import { isResponseError, isResponseSuccess } from "@/lib/api-helper"; -import { CustomerApi } from "@/services/api/master-data"; -import { CreateCustomerPayload, Customer, UpdateCustomerPayload } from "@/types/api/master-data/customer"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import toast from "react-hot-toast"; -import { CustomerFormValues } from "./CustomerForm.schema"; -import { useFormik } from "formik"; -import Button from "@/components/Button"; -import { Icon } from "@iconify/react"; -import TextInput from "@/components/input/TextInput"; -import { cn } from "@/lib/helper"; -import ConfirmationModal from "@/components/modal/ConfirmationModal"; -import TextArea from "@/components/input/TextArea"; -import SelectInput, { OptionType } from "@/components/input/SelectInput"; -import useSWR from "swr"; -import { UserApi } from "@/services/api/user"; -import { CUSTOMER_TYPE_OPTIONS } from "@/config/constant"; +import { useModal } from '@/components/Modal'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { CustomerApi } from '@/services/api/master-data'; +import { + CreateCustomerPayload, + Customer, + UpdateCustomerPayload, +} from '@/types/api/master-data/customer'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import { CustomerFormValues } from './CustomerForm.schema'; +import { useFormik } from 'formik'; +import Button from '@/components/Button'; +import { Icon } from '@iconify/react'; +import TextInput from '@/components/input/TextInput'; +import { cn } from '@/lib/helper'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import TextArea from '@/components/input/TextArea'; +import SelectInput, { OptionType } from '@/components/input/SelectInput'; +import useSWR from 'swr'; +import { UserApi } from '@/services/api/user'; +import { CUSTOMER_TYPE_OPTIONS } from '@/config/constant'; interface CustomerFormProps { formType?: 'add' | 'edit' | 'detail'; initialValues?: Customer; } -const CustomerForm = ({formType = 'add', initialValues} : CustomerFormProps) => { - // Setup Kebutuhan Form - const router = useRouter(); - const deleteModal = useModal(); +const CustomerForm = ({ + formType = 'add', + initialValues, +}: CustomerFormProps) => { + // Setup Kebutuhan Form + const router = useRouter(); + const deleteModal = useModal(); - // Setup State - const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState(''); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const [picSelectInputValue, setPicSelectInputValue] = useState(''); - const [typeSelectInputValue, setTypeSelectInputValue] = useState(''); + // Setup State + const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState(''); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [picSelectInputValue, setPicSelectInputValue] = useState(''); + const [typeSelectInputValue, setTypeSelectInputValue] = useState(''); - // Fetch Data - const picUrl = `${UserApi.basePath}?${new URLSearchParams({ - search: picSelectInputValue ?? '', - })}`; + // Fetch Data + const picUrl = `${UserApi.basePath}?${new URLSearchParams({ + search: picSelectInputValue ?? '', + })}`; - const { data: pic, isLoading: isLoadingPic } = useSWR( - picUrl, - UserApi.getAllFetcher - ); + const { data: pic, isLoading: isLoadingPic } = useSWR( + picUrl, + UserApi.getAllFetcher + ); - // -- Options data mapping - const picOptions = isResponseSuccess(pic) - ? pic?.data.map((area) => ({ - value: area.id, - label: area.name, - })) - : []; - const typeOptions = CUSTOMER_TYPE_OPTIONS; - + // -- Options data mapping + const picOptions = isResponseSuccess(pic) + ? pic?.data.map((area) => ({ + value: area.id, + label: area.name, + })) + : []; + const typeOptions = CUSTOMER_TYPE_OPTIONS; - // Handler Event - const createCustomerHandler = useCallback( - async (payload : CreateCustomerPayload) => { - const createCustomerRes = await CustomerApi.create(payload); + // Handler Event + const createCustomerHandler = useCallback( + async (payload: CreateCustomerPayload) => { + const createCustomerRes = await CustomerApi.create(payload); - if(isResponseError(createCustomerRes)){ - setCustomerFormErrorMessage(createCustomerRes.message); - return; - } + if (isResponseError(createCustomerRes)) { + setCustomerFormErrorMessage(createCustomerRes.message); + return; + } - toast.success(createCustomerRes?.message as string); - router.push('/master-data/customer'); - }, - [router] - ); - const updateCustomerHandler = useCallback( - async (customerId : number, payload : UpdateCustomerPayload) => { - const updateCustomerRes = await CustomerApi.update(customerId, payload); - - if(isResponseError(updateCustomerRes)){ - setCustomerFormErrorMessage(updateCustomerRes.message); - return; - } - - toast.success(updateCustomerRes?.message as string); - router.push('/master-data/customer'); - }, - [router] - ); - - const deleteCustomerHandler = () => { - deleteModal.openModal(); - } - - const confirmationModalDeleteclickHandler = async () => { - setIsDeleteLoading(true); - - await CustomerApi.delete(initialValues?.id as number); - - deleteModal.closeModal(); - setIsDeleteLoading(false); + toast.success(createCustomerRes?.message as string); router.push('/master-data/customer'); + }, + [router] + ); + const updateCustomerHandler = useCallback( + async (customerId: number, payload: UpdateCustomerPayload) => { + const updateCustomerRes = await CustomerApi.update(customerId, payload); + + if (isResponseError(updateCustomerRes)) { + setCustomerFormErrorMessage(updateCustomerRes.message); + return; + } + + toast.success(updateCustomerRes?.message as string); + router.push('/master-data/customer'); + }, + [router] + ); + + const deleteCustomerHandler = () => { + deleteModal.openModal(); + }; + + const confirmationModalDeleteclickHandler = async () => { + setIsDeleteLoading(true); + + await CustomerApi.delete(initialValues?.id as number); + + deleteModal.closeModal(); + setIsDeleteLoading(false); + router.push('/master-data/customer'); + }; + + // -- Option Handler + const picChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('pic', true); + formik.setFieldValue('pic', val); + + formik.setFieldTouched('picId', true); + formik.setFieldValue('picId', (val as OptionType)?.value); + }; + const typeChangeHandler = (val: OptionType | OptionType[] | null) => { + formik.setFieldTouched('type', true); + formik.setFieldValue('type', val); + }; + + // Utils Functions + const normalizeType = (type?: string | { value: string; label: string }) => { + if (!type) return CUSTOMER_TYPE_OPTIONS[0]; + return typeof type === 'string' ? { value: type, label: type } : type; + }; + + // Memo untuk simpan input sebelumnya + const formikInitialValues = useMemo(() => { + return { + name: initialValues?.name ?? '', + email: initialValues?.email ?? '', + phone: initialValues?.phone ?? '', + picId: initialValues?.pic?.id ?? 0, + pic: initialValues?.pic + ? { + value: initialValues.pic.id, + label: initialValues.pic.name, + } + : { + value: 0, + label: '', + }, + type: normalizeType(initialValues?.type), + address: initialValues?.address ?? '', + account_number: initialValues?.account_number ?? '', }; + }, [initialValues]); - // -- Option Handler - const picChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('pic', true); - formik.setFieldValue('pic', val); + // Formik + const formik = useFormik({ + initialValues: formikInitialValues, + enableReinitialize: true, + onSubmit: async (values) => { + // reset error message + setCustomerFormErrorMessage(''); - formik.setFieldTouched('picId', true); - formik.setFieldValue('picId', (val as OptionType)?.value); - } - const typeChangeHandler = (val: OptionType | OptionType[] | null) => { - formik.setFieldTouched('type', true); - formik.setFieldValue('type', val); - } + // create payload + const payload: CreateCustomerPayload = { + name: values.name, + email: values.email, + phone: values.phone, + pic_id: values.picId, + type: (values.type as OptionType).value as string, + address: values.address, + account_number: values.account_number, + }; - // Memo untuk simpan input sebelumnya - const formikInitialValues = useMemo(() => { - return { - name: initialValues?.name as string ?? '', - email: initialValues?.email ?? '', - phone: initialValues?.phone ?? '', - picId: initialValues?.pic?.id ?? 0, - pic: initialValues?.pic ? { - value: initialValues.pic.id, - label: initialValues.pic.name, - } : null, - type: initialValues?.type ?? 'INDIVIDUAL', - address: initialValues?.address ?? '', - account_number: initialValues?.account_number ?? '', - }; - }, [initialValues]); + // cek type form yang disubmit + switch (formType) { + case 'add': + await createCustomerHandler(payload); + break; + case 'edit': + await updateCustomerHandler(initialValues?.id as number, payload); + break; + } + }, + }); - // Formik - const formik = useFormik({ - initialValues: formikInitialValues, - enableReinitialize: true, - onSubmit: async (values) => { - // reset error message - setCustomerFormErrorMessage(''); + const { setValues: formikSetValues } = formik; - // create payload - const payload: CreateCustomerPayload = { - name: values.name, - email: values.email, - phone: values.phone, - pic_id: values.picId, - type: values.type, - address: values.address, - account_number: values.account_number, - }; + // Initialize Formik + useEffect(() => { + formikSetValues(formikInitialValues); + }, [formikSetValues, formikInitialValues]); - // cek type form yang disubmit - switch (formType) { - case 'add': - await createCustomerHandler(payload); - break; - case 'edit': - await updateCustomerHandler(initialValues?.id as number, payload); - break; - } - } - - }); - - const {setValues: formikSetValues} = formik; - - // Initialize Formik - useEffect(() => { - formikSetValues(formikInitialValues); - }, [formikSetValues, formikInitialValues]); - - - // Render - return ( - <> -
-
+ // Render + return ( + <> +
+
-

+

{formType === 'add' && 'Tambah Customer'} {formType === 'edit' && 'Ubah Customer'} {formType === 'detail' && 'Detail Customer'} @@ -194,16 +209,16 @@ const CustomerForm = ({formType = 'add', initialValues} : CustomerFormProps) =>
- + className='w-full mt-8 flex flex-col gap-6' + > {/* Fields Form */} -
- + {formik.values.picId} + /> /> item.value === formik.values.type) ?? undefined} + value={ + typeOptions.find( + (item) => item.value === formik.values.type.value + ) ?? undefined + } onChange={typeChangeHandler} options={typeOptions} onInputChange={setTypeSelectInputValue} @@ -240,11 +259,11 @@ const CustomerForm = ({formType = 'add', initialValues} : CustomerFormProps) => isClearable isSearchable={true} /> - errorMessage={formik.errors.email} readOnly={formType === 'detail'} /> - - + -