diff --git a/src/app/master-data/customer/add/page.tsx b/src/app/master-data/customer/add/page.tsx
new file mode 100644
index 00000000..274b7d90
--- /dev/null
+++ b/src/app/master-data/customer/add/page.tsx
@@ -0,0 +1,11 @@
+import CustomerForm from "@/components/pages/master-data/customer/form/CustomerForm";
+
+const AddNonstock = () => {
+ return (
+
+ );
+}
+
+export default AddNonstock;
\ No newline at end of file
diff --git a/src/app/master-data/customer/detail/edit/page.tsx b/src/app/master-data/customer/detail/edit/page.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/master-data/customer/detail/page.tsx b/src/app/master-data/customer/detail/page.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/src/app/master-data/customer/page.tsx b/src/app/master-data/customer/page.tsx
new file mode 100644
index 00000000..56281702
--- /dev/null
+++ b/src/app/master-data/customer/page.tsx
@@ -0,0 +1,11 @@
+import CustomersTable from "@/components/pages/master-data/customer/CustomersTable";
+
+const Nonstock = () => {
+ return (
+
+ )
+};
+
+export default Nonstock;
\ No newline at end of file
diff --git a/src/components/helper/RequireAuth.tsx b/src/components/helper/RequireAuth.tsx
index 9bc199f9..1d9d86b4 100644
--- a/src/components/helper/RequireAuth.tsx
+++ b/src/components/helper/RequireAuth.tsx
@@ -9,6 +9,145 @@ import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { GetMeResponse } from '@/types/api/api-general';
+// TODO: delete this later, DONT HARDCODE USER DATA
+const DUMMY_USER = {
+ id: 1,
+ email: 'admin@mbugroup.id',
+ npk: '0001',
+ name: 'Super Admin',
+ image: null,
+ created_at: '2025-09-30T03:24:20.899229Z',
+ updated_at: '2025-09-30T03:24:20.899229Z',
+ roles: [
+ {
+ id: 1,
+ key: 'mbu.super_admin',
+ name: 'MBU Administrator',
+ client: {
+ id: 1,
+ name: 'PT Mitra Berlian Unggas',
+ alias: 'MBU',
+ },
+ permissions: [
+ {
+ id: 1,
+ name: 'mbu:purchase:read',
+ action: 'read',
+ client: {
+ id: 1,
+ name: 'PT Mitra Berlian Unggas',
+ alias: 'MBU',
+ },
+ },
+ {
+ id: 2,
+ name: 'mbu:purchase:create',
+ action: 'create',
+ client: {
+ id: 1,
+ name: 'PT Mitra Berlian Unggas',
+ alias: 'MBU',
+ },
+ },
+ {
+ id: 3,
+ name: 'mbu:purchase:approve',
+ action: 'approve',
+ client: {
+ id: 1,
+ name: 'PT Mitra Berlian Unggas',
+ alias: 'MBU',
+ },
+ },
+ ],
+ },
+ {
+ id: 2,
+ key: 'lti.super_admin',
+ name: 'LTI Administrator',
+ client: {
+ id: 2,
+ name: 'PT Lumbung Telur Indonesia',
+ alias: 'LTI',
+ },
+ permissions: [
+ {
+ id: 4,
+ name: 'lti:purchase:read',
+ action: 'read',
+ client: {
+ id: 2,
+ name: 'PT Lumbung Telur Indonesia',
+ alias: 'LTI',
+ },
+ },
+ {
+ id: 5,
+ name: 'lti:purchase:create',
+ action: 'create',
+ client: {
+ id: 2,
+ name: 'PT Lumbung Telur Indonesia',
+ alias: 'LTI',
+ },
+ },
+ {
+ id: 6,
+ name: 'lti:purchase:approve',
+ action: 'approve',
+ client: {
+ id: 2,
+ name: 'PT Lumbung Telur Indonesia',
+ alias: 'LTI',
+ },
+ },
+ ],
+ },
+ {
+ id: 3,
+ key: 'manbu.super_admin',
+ name: 'MANBU Administrator',
+ client: {
+ id: 3,
+ name: 'PT Mandiri Berlian Unggas',
+ alias: 'MANBU',
+ },
+ permissions: [
+ {
+ id: 7,
+ name: 'manbu:purchase:read',
+ action: 'read',
+ client: {
+ id: 3,
+ name: 'PT Mandiri Berlian Unggas',
+ alias: 'MANBU',
+ },
+ },
+ {
+ id: 8,
+ name: 'manbu:purchase:create',
+ action: 'create',
+ client: {
+ id: 3,
+ name: 'PT Mandiri Berlian Unggas',
+ alias: 'MANBU',
+ },
+ },
+ {
+ id: 9,
+ name: 'manbu:purchase:approve',
+ action: 'approve',
+ client: {
+ id: 3,
+ name: 'PT Mandiri Berlian Unggas',
+ alias: 'MANBU',
+ },
+ },
+ ],
+ },
+ ],
+};
+
interface RequireAuthProps {
children?: ReactNode;
}
@@ -37,19 +176,22 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
if (isResponseSuccess(userResponse)) {
setUser(userResponse.data);
} else {
- router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
+ // router.replace(process.env.NEXT_PUBLIC_SSO_LOGIN_URL as string);
+ // TODO: remove this later, DONT HARDCODE USER DATA
+ setUser(DUMMY_USER);
}
}, [userResponse, setIsLoadingUser, setUser]);
- if (isLoadingUserResponse && !userResponse) {
- return (
-
-
-
- );
- }
+ // TODO: uncomment this later
+ // if (isLoadingUserResponse && !userResponse) {
+ // return (
+ //
+ //
+ //
+ // );
+ // }
return <>{children}>;
};
-export default RequireAuth;
+export default RequireAuth;
\ No newline at end of file
diff --git a/src/components/input/TextArea.tsx b/src/components/input/TextArea.tsx
new file mode 100644
index 00000000..b4a6c9f5
--- /dev/null
+++ b/src/components/input/TextArea.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import {
+ ChangeEventHandler,
+ FocusEventHandler,
+ ReactNode,
+} from 'react';
+
+import { cn } from '@/lib/helper';
+
+export interface TextAreaProps {
+ label?: string;
+ bottomLabel?: string;
+ name: string;
+ value?: string | number;
+ placeholder?: string;
+ className?: {
+ wrapper?: string;
+ label?: string;
+ inputWrapper?: string;
+ input?: string;
+ };
+ isError?: boolean;
+ isValid?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ required?: boolean;
+ isLoading?: boolean;
+ errorMessage?: string;
+ startAdornment?: ReactNode;
+ endAdornment?: ReactNode;
+ onChange?: ChangeEventHandler;
+ onBlur?: FocusEventHandler;
+ cols?: number;
+}
+
+const TextArea = ({
+ label,
+ bottomLabel,
+ name,
+ value,
+ placeholder,
+ className,
+ isError,
+ isValid,
+ errorMessage,
+ startAdornment,
+ endAdornment,
+ disabled = false,
+ required = false,
+ onChange,
+ onBlur,
+ readOnly = false,
+ isLoading = false,
+ cols = 3
+}: TextAreaProps) => {
+ return (
+
+ {label && (
+
+ )}
+ {startAdornment && startAdornment}
+
+
+
+ {(isLoading || endAdornment) && (
+
+ {isLoading && }
+
+ {endAdornment && endAdornment}
+
+ )}
+
+ {!isError && bottomLabel && (
+
{bottomLabel}
+ )}
+ {isError &&
{errorMessage}
}
+
+ );
+};
+
+export default TextArea;
diff --git a/src/components/pages/master-data/customer/CustomersTable.tsx b/src/components/pages/master-data/customer/CustomersTable.tsx
new file mode 100644
index 00000000..b2b210df
--- /dev/null
+++ b/src/components/pages/master-data/customer/CustomersTable.tsx
@@ -0,0 +1,245 @@
+'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,
+ ColumnSort,
+ SortingState,
+ } from "@tanstack/react-table";
+import { useState } from "react";
+import useSWR from "swr";
+
+const RowOptionsMenu = ({
+ type = 'dropdown',
+ props,
+ deleteClickHandler,
+} : {
+ type: 'dropdown' | 'collapse',
+ props: CellContext;
+ deleteClickHandler: () => void;
+}) => {
+ return (
+
+
+
+
+
+ );
+}
+
+const CustomersTable = () => {
+ const {
+ state: tableFilterState,
+ updateFilter,
+ setPage,
+ setPageSize,
+ toQueryString: getTableFilterQueryString,
+ } = useTableFilter ({
+ initial: { search: '', nameSort: '', picSort: ''},
+ paramMap: {
+ page: 'page',
+ pageSize: 'limit',
+ nameSort: 'sort_name',
+ picSort: 'sort_pic',
+ }
+ });
+
+ const {
+ data: customers,
+ isLoading,
+ mutate: refreshCustomers
+ } = useSWR(
+ `${CustomerApi.basePath}${getTableFilterQueryString()}`,
+ CustomerApi.getAllFetcher
+ );
+
+ const deleteModal = useModal();
+
+ const [selectedCustomer, setSelectedCustomer] = useState(undefined);
+
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+
+ const customersColumns: ColumnDef[] = [
+ {
+ header: '#',
+ cell: (props) =>
+ tableFilterState.pageSize * (tableFilterState.page - 1) +
+ props.row.index +
+ 1,
+ },
+ {
+ accessorKey: 'name',
+ header: 'Nama',
+ },
+ {
+ accessorKey: 'pic',
+ header: 'PIC',
+ cell: (props) => props.row.original.pic.name,
+ },
+ {
+ accessorKey: 'type',
+ header: 'Type',
+ cell: (props) => props.row.original.type,
+ },
+ {
+ accessorKey: 'phone',
+ header: 'Phone',
+ },
+ {
+ accessorKey: 'email',
+ header: 'Email',
+ },
+ {
+ 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 = () => {
+ setSelectedCustomer(props.row.original);
+ deleteModal.openModal();
+ };
+
+ return (
+ <>
+ {currentPageSize > 2 && (
+
+
+
+ )}
+
+ {currentPageSize <= 2 && (
+
+
+
+ )}
+ >
+ );
+ },
+ },
+ ];
+
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ data={isResponseSuccess(customers) ? customers?.data : []}
+ columns={customersColumns}
+ pageSize={tableFilterState.pageSize}
+ page={isResponseSuccess(customers) ? customers?.meta?.page : 0}
+ totalItems={
+ isResponseSuccess(customers) ? customers?.meta?.total_results : 0
+ }
+ onPageChange={setPage}
+ isLoading={isLoading}
+ className={{
+ containerClassName: cn({
+ 'mb-20':
+ isResponseSuccess(customers) && customers?.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 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
new file mode 100644
index 00000000..7aedd73b
--- /dev/null
+++ b/src/components/pages/master-data/customer/form/CustomerForm.schema.ts
@@ -0,0 +1,40 @@
+import * as Yup from 'yup';
+
+export const CustomerFormSchema = Yup.object({
+ name: Yup.string()
+ .required('Nama 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(),
+
+ type: Yup.string()
+ .oneOf(['INDIVIDUAL', 'BISNIS'], 'Tipe harus INDIVIDUAL atau BISNIS')
+ .required('Tipe wajib diisi!'),
+
+ address: Yup.string()
+ .required('Alamat wajib diisi!'),
+
+ phone: Yup.string()
+ .matches(/^[0-9]+$/, 'Nomor telepon hanya boleh berisi angka!')
+ .min(10, 'Nomor telepon minimal 10 digit!')
+ .max(12, 'Nomor telepon maksimal 12 digit!')
+ .required('Nomor telepon wajib diisi!'),
+
+ email: Yup.string()
+ .email('Format email tidak valid!')
+ .required('Email wajib diisi!'),
+
+ account_number: Yup.string()
+ .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
+ .required('Nomor rekening wajib diisi!'),
+});
+
+export const UpdateCustomerFormSchema = CustomerFormSchema;
+
+export type CustomerFormValues = Yup.InferType;
diff --git a/src/components/pages/master-data/customer/form/CustomerForm.tsx b/src/components/pages/master-data/customer/form/CustomerForm.tsx
new file mode 100644
index 00000000..7b744946
--- /dev/null
+++ b/src/components/pages/master-data/customer/form/CustomerForm.tsx
@@ -0,0 +1,377 @@
+'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";
+
+interface CustomerFormProps {
+ formType?: 'add' | 'edit' | 'detail';
+ initialValues?: Customer;
+}
+
+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('');
+
+ // Fetch Data
+ const picUrl = `${UserApi.basePath}?${new URLSearchParams({
+ search: picSelectInputValue ?? '',
+ })}`;
+
+ 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;
+
+
+ // Handler Event
+ const createCustomerHandler = useCallback(
+ async (payload : CreateCustomerPayload) => {
+ const createCustomerRes = await CustomerApi.create(payload);
+
+ 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);
+ 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);
+ }
+
+ // 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]);
+
+ // Formik
+ const formik = useFormik({
+ initialValues: formikInitialValues,
+ enableReinitialize: true,
+ onSubmit: async (values) => {
+ // reset error message
+ setCustomerFormErrorMessage('');
+
+ // 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,
+ };
+
+ // 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 (
+ <>
+
+
+
+
+
+ {formType === 'add' && 'Tambah Customer'}
+ {formType === 'edit' && 'Ubah Customer'}
+ {formType === 'detail' && 'Detail Customer'}
+
+
+
+
+
+
+ {formType !== 'add' && (
+
+ )}
+ >
+ );
+}
+
+export default CustomerForm;
\ No newline at end of file
diff --git a/src/config/constant.ts b/src/config/constant.ts
index 55eed0b3..4ca83946 100644
--- a/src/config/constant.ts
+++ b/src/config/constant.ts
@@ -107,3 +107,14 @@ export const WAREHOUSE_TYPE_OPTIONS = [
value: 'KANDANG',
},
];
+
+export const CUSTOMER_TYPE_OPTIONS = [
+ {
+ label: 'INDIVIDUAL',
+ value: 'INDIVIDUAL',
+ },
+ {
+ label: 'BISNIS',
+ value: 'BISNIS',
+ },
+];
diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts
index 785a1ca1..6cda3814 100644
--- a/src/services/api/master-data.ts
+++ b/src/services/api/master-data.ts
@@ -24,6 +24,11 @@ import {
UpdateWarehousePayload,
Warehouse,
} from '@/types/api/master-data/warehouse';
+import {
+ CreateCustomerPayload,
+ Customer,
+ UpdateCustomerPayload,
+} from '@/types/api/master-data/customer';
export const UomApi = new BaseApiService<
Uom,
@@ -54,3 +59,9 @@ export const WarehouseApi = new BaseApiService<
CreateWarehousePayload,
UpdateWarehousePayload
>('/master-data/warehouses');
+
+export const CustomerApi = new BaseApiService<
+ Customer,
+ CreateCustomerPayload,
+ UpdateCustomerPayload
+>('/master-data/customers');
\ No newline at end of file
diff --git a/src/types/api/master-data/customer.d.ts b/src/types/api/master-data/customer.d.ts
new file mode 100644
index 00000000..7f8026dc
--- /dev/null
+++ b/src/types/api/master-data/customer.d.ts
@@ -0,0 +1,27 @@
+import { BaseMetadata, CreatedUser } from "@/types/api/api-general";
+
+export type BaseCustomer = {
+ id: number;
+ name: string;
+ pic_id: number;
+ pic: CreatedUser;
+ type: 'INDIVIDUAL' | 'BISNIS';
+ address: string;
+ phone: string;
+ email: string;
+ account_number: string;
+}
+
+export type Customer = BaseMetadata & BaseCustomer;
+
+export type CreateCustomerPayload = {
+ name: string;
+ pic_id: number;
+ type: 'INDIVIDUAL' | 'BISNIS';
+ address: string;
+ phone: string;
+ email: string;
+ account_number: string;
+}
+
+export type UpdateCustomerPayload = CreateCustomerPayload;
\ No newline at end of file