diff --git a/.gitignore b/.gitignore index 2b4315f8..82965e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,8 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts +# prettier +.prettierrc + # idea -.idea \ No newline at end of file +.idea diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..efda72f0 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,76 @@ +stages: [notify] + +# --- Notify when MR is opened/updated --- +notify_discord_mr: + stage: notify + image: alpine:3.20 + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + variables: + WEBHOOK_URL: $DISCORD_WEBHOOK_URL + before_script: + - apk add --no-cache curl jq + script: | + MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}" + + jq -n \ + --arg repo "$CI_PROJECT_PATH" \ + --arg mr "#${CI_MERGE_REQUEST_IID}" \ + --arg url "$MR_URL" \ + --arg requestor "${GITLAB_USER_LOGIN:-$GITLAB_USER_NAME}" \ + --arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \ + --arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \ + --arg title "$CI_MERGE_REQUEST_TITLE" \ + '{ + username: "CI Bot - FE", + embeds: [{ + title: "📣 [LTI WEB CLIENT] Merge Request Opened/Updated", + description: ($mr + " in " + $repo), + url: $url, + color: 3447003, + fields: [ + {name: "Author", value: $requestor, inline: true}, + {name: "Source → Target", value: ($source + " → " + $target), inline: true}, + {name: "Title", value: $title} + ] + }] + }' \ + | curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL" + +# --- Notify when MR is merged --- +notify_discord_merge: + stage: notify + image: alpine:3.20 + rules: + # Only run for merge request pipelines that are in merged state + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged"' + variables: + WEBHOOK_URL: $DISCORD_WEBHOOK_URL + before_script: + - apk add --no-cache curl jq + script: | + MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}" + + jq -n \ + --arg repo "$CI_PROJECT_PATH" \ + --arg mr "#${CI_MERGE_REQUEST_IID}" \ + --arg url "$MR_URL" \ + --arg requestor "${CI_MERGE_REQUEST_AUTHOR}" \ + --arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \ + --arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \ + --arg title "$CI_MERGE_REQUEST_TITLE" \ + '{ + username: "CI Bot - FE", + embeds: [{ + title: "✅ [LTI WEB CLIENT] Merge Request Merged", + description: ($mr + " has been merged into " + $repo), + url: $url, + color: 3066993, + fields: [ + {name: "Author", value: $requestor, inline: true}, + {name: "Source → Target", value: ($source + " → " + $target), inline: true}, + {name: "Title", value: $title} + ] + }] + }' \ + | curl -sS -H "Content-Type: application/json" -d @- "$WEBHOOK_URL" 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..a1096f02 --- /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 AddCustomer = () => { + return ( +
+ +
+ ); +} + +export default AddCustomer; \ 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..3fe8de52 --- /dev/null +++ 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 new file mode 100644 index 00000000..263458c2 --- /dev/null +++ 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/app/master-data/customer/page.tsx b/src/app/master-data/customer/page.tsx new file mode 100644 index 00000000..b80401f1 --- /dev/null +++ b/src/app/master-data/customer/page.tsx @@ -0,0 +1,11 @@ +import CustomersTable from "@/components/pages/master-data/customer/CustomersTable"; + +const Customer = () => { + return ( +
+ +
+ ) +}; + +export default Customer; \ No newline at end of file diff --git a/src/app/master-data/supplier/add/page.tsx b/src/app/master-data/supplier/add/page.tsx new file mode 100644 index 00000000..8a95c3c6 --- /dev/null +++ b/src/app/master-data/supplier/add/page.tsx @@ -0,0 +1,11 @@ +import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm'; + +const AddSupplier = () => { + return ( +
+ +
+ ); +}; + +export default AddSupplier; \ No newline at end of file diff --git a/src/app/master-data/supplier/detail/edit/page.tsx b/src/app/master-data/supplier/detail/edit/page.tsx new file mode 100644 index 00000000..103db73d --- /dev/null +++ b/src/app/master-data/supplier/detail/edit/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { SupplierApi } from '@/services/api/master-data'; +import { useSearchParams, useRouter } from 'next/navigation'; +import useSWR from 'swr'; + +const SupplierEdit = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const supplierId = searchParams.get('supplierId'); + + // Fetch Data + const { data: supplier, isLoading: isLoadingSupplier } = useSWR( + supplierId, + (id: number) => SupplierApi.getSingle(id) + ); + + if (!supplierId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingSupplier && (!supplier || isResponseError(supplier))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingSupplier && ( + + )} + {!isLoadingSupplier && isResponseSuccess(supplier) && ( + + )} +
+ ); +}; + +export default SupplierEdit; diff --git a/src/app/master-data/supplier/detail/page.tsx b/src/app/master-data/supplier/detail/page.tsx new file mode 100644 index 00000000..433fa043 --- /dev/null +++ b/src/app/master-data/supplier/detail/page.tsx @@ -0,0 +1,49 @@ +'use client'; + +import SupplierForm from '@/components/pages/master-data/supplier/form/SupplierForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { SupplierApi } from '@/services/api/master-data'; +import { useSearchParams, useRouter } from 'next/navigation'; +import useSWR from 'swr'; + +const SupplierDetail = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const supplierId = searchParams.get('supplierId'); + + // Fetch Data + const { data: supplier, isLoading: isLoadingSupplier } = useSWR( + supplierId, + (id: number) => SupplierApi.getSingle(id) + ); + + if (!supplierId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingSupplier && (!supplier || isResponseError(supplier))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingSupplier && ( + + )} + {!isLoadingSupplier && isResponseSuccess(supplier) && ( + + )} +
+ ); +}; + +export default SupplierDetail; \ No newline at end of file diff --git a/src/app/master-data/supplier/page.tsx b/src/app/master-data/supplier/page.tsx new file mode 100644 index 00000000..1f54bd0d --- /dev/null +++ b/src/app/master-data/supplier/page.tsx @@ -0,0 +1,11 @@ +import SuppliersTable from "@/components/pages/master-data/supplier/SupplierTable"; + +const Supplier = () => { + return ( +
+ +
+ ); +}; + +export default Supplier; diff --git a/src/components/MainDrawer.tsx b/src/components/MainDrawer.tsx index 309cddf2..be87f069 100644 --- a/src/components/MainDrawer.tsx +++ b/src/components/MainDrawer.tsx @@ -189,6 +189,8 @@ const MainDrawer = ({ ); const traverseMenuTitle = (menu: typeof activeMenu) => { + if (!menu) return; + const hasSubmenu = menu?.submenu && menu?.submenu.length > 0; if (!title) { @@ -197,7 +199,7 @@ const MainDrawer = ({ title += ' - ' + menu?.title; } - if (!hasSubmenu) return; + if (!hasSubmenu || !menu.submenu) return; const activeSubmenu = menu.submenu?.find((item) => isPathActive(pathname, item.link) 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/TagInput.tsx b/src/components/input/TagInput.tsx new file mode 100644 index 00000000..a14b2f63 --- /dev/null +++ b/src/components/input/TagInput.tsx @@ -0,0 +1,169 @@ +'use client'; + +import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react'; +import { cn } from '@/lib/helper'; + +export interface TagInputProps { + label?: string; + bottomLabel?: string; + name: string; + value?: string; + 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; + onChange?: (value: string) => void; +} + +const TagInput: React.FC = ({ + label, + bottomLabel, + name, + value = '', + placeholder, + className, + isError, + isValid, + errorMessage, + disabled = false, + readOnly = false, + required = false, + onChange, +}) => { + const [tags, setTags] = useState(value ? value.split(',') : []); + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + if (value !== undefined && value !== tags.join(',')) { + setTags(value ? value.split(',') : []); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const newTag = inputValue.trim(); + if (newTag && !tags.includes(newTag)) { + const updatedTags = [...tags, newTag]; + setTags(updatedTags); + onChange?.(updatedTags.join(',')); + } + setInputValue(''); + } + }; + + const handleRemoveTag = (tagToRemove: string) => { + const updatedTags = tags.filter((t) => t !== tagToRemove); + setTags(updatedTags); + onChange?.(updatedTags.join(',')); + }; + + const handleInputChange = (e: ChangeEvent) => { + setInputValue(e.target.value); + }; + + return ( +
+ {/* Label */} + {label && ( + + )} + + {/* Input wrapper */} +
{ + // Fokuskan input saat area diklik + const inputEl = document.getElementById(name); + inputEl?.focus(); + }} + > + {tags.map((tag) => ( +
+ {tag} + {!readOnly && ( + + )} +
+ ))} + + {!readOnly && ( + + )} +
+ + {/* Bottom label or error message */} + {!isError && bottomLabel && ( +

{bottomLabel}

+ )} + {isError &&

{errorMessage}

} +
+ ); +}; + +export default TagInput; diff --git a/src/components/pages/master-data/area/AreasTable.tsx b/src/components/pages/master-data/area/AreasTable.tsx index 4d0bec73..c1ec1ef5 100644 --- a/src/components/pages/master-data/area/AreasTable.tsx +++ b/src/components/pages/master-data/area/AreasTable.tsx @@ -192,7 +192,7 @@ const AreasTable = () => { } else { updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); } - }, [sorting]); + }, [sorting, updateFilter]); return ( <> 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..d3fde60b --- /dev/null +++ b/src/components/pages/master-data/customer/CustomersTable.tsx @@ -0,0 +1,288 @@ +'use client'; + +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, +} 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'; + 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', + }, + }); + + // Fetch Data + const { + data: customers, + isLoading, + mutate: refreshCustomers, + } = useSWR( + `${CustomerApi.basePath}${getTableFilterQueryString()}`, + CustomerApi.getAllFetcher + ); + + // State + const deleteModal = useModal(); + const [selectedCustomer, setSelectedCustomer] = useState< + Customer | undefined + >(undefined); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + // Columns Definition + 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 && ( + + + + )} + + ); + }, + }, + ]; + + // 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 ( + <> +
+
+
+
+ +
+ + +
+ +
+ +
+
+ + + 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..22cd023d --- /dev/null +++ b/src/components/pages/master-data/customer/form/CustomerForm.schema.ts @@ -0,0 +1,37 @@ +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(), + }).required('PIC wajib diisi!'), + + type: Yup.object({ + value: Yup.string().required(), + label: Yup.string().required(), + }).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..ee824866 --- /dev/null +++ b/src/components/pages/master-data/customer/form/CustomerForm.tsx @@ -0,0 +1,410 @@ +'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 { CustomerFormSchema, CustomerFormValues, UpdateCustomerFormSchema } 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 { 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 = 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); + }; + + // Utils Functions + const normalizeType = (type?: string | { value: string; label: string }) => { + if (!type) return 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]); + + // Formik + const formik = useFormik({ + initialValues: formikInitialValues, + enableReinitialize: true, + validationSchema: formType === 'edit' ? UpdateCustomerFormSchema : CustomerFormSchema, + 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 as OptionType).value as string, + 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'} +

+
+ +
+ {/* Fields Form */} +
+ + + item.value === formik.values.type?.value + ) ?? undefined + } + onChange={typeChangeHandler} + options={typeOptions} + onInputChange={setTypeSelectInputValue} + isError={formik.touched.type && Boolean(formik.errors.type)} + errorMessage={formik.errors.type as string} + isDisabled={formType === 'detail'} + isClearable + isSearchable={true} + /> + + + +