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'}
+
+
+
+
+
+
+ {formType !== 'add' && (
+
+ )}
+ >
+ );
+};
+
+export default CustomerForm;
diff --git a/src/components/pages/master-data/kandang/KandangsTable.tsx b/src/components/pages/master-data/kandang/KandangsTable.tsx
index c3571452..c51eeb21 100644
--- a/src/components/pages/master-data/kandang/KandangsTable.tsx
+++ b/src/components/pages/master-data/kandang/KandangsTable.tsx
@@ -231,7 +231,7 @@ const KandangsTable = () => {
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('picSort', picSortFilter);
- }, [sorting]);
+ }, [sorting, updateSortingFilter]);
return (
<>
diff --git a/src/components/pages/master-data/location/LocationsTable.tsx b/src/components/pages/master-data/location/LocationsTable.tsx
index ebbb798f..2548fb28 100644
--- a/src/components/pages/master-data/location/LocationsTable.tsx
+++ b/src/components/pages/master-data/location/LocationsTable.tsx
@@ -230,7 +230,7 @@ const LocationsTable = () => {
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('addressSort', addressSortFilter);
updateSortingFilter('areaSort', areaSortFilter);
- }, [sorting]);
+ }, [sorting, updateSortingFilter]);
return (
<>
diff --git a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx
index f8413ab6..63b1c919 100644
--- a/src/components/pages/master-data/product-category/ProductCategoryTable.tsx
+++ b/src/components/pages/master-data/product-category/ProductCategoryTable.tsx
@@ -66,7 +66,12 @@ const RowOptionsMenu = ({
color='error'
className='text-error hover:text-inherit'
>
-
+
Delete
@@ -96,7 +101,9 @@ const ProductCategoryTable = () => {
const deleteModal = useModal();
- const [selectedProductCategory, setSelectedProductCategory] = useState(undefined);
+ const [selectedProductCategory, setSelectedProductCategory] = useState<
+ ProductCategory | undefined
+ >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState([]);
@@ -186,7 +193,7 @@ const ProductCategoryTable = () => {
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
- }, [sorting]);
+ }, [sorting, updateFilter]);
return (
<>
@@ -221,18 +228,30 @@ const ProductCategoryTable = () => {
- data={isResponseSuccess(productCategories) ? productCategories?.data : []}
+ 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}
+ 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,
+ '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!',
@@ -263,4 +282,4 @@ const ProductCategoryTable = () => {
);
};
-export default ProductCategoryTable;
\ No newline at end of file
+export default ProductCategoryTable;
diff --git a/src/components/pages/master-data/product/ProductTable.tsx b/src/components/pages/master-data/product/ProductTable.tsx
index ab256548..b38a749a 100644
--- a/src/components/pages/master-data/product/ProductTable.tsx
+++ b/src/components/pages/master-data/product/ProductTable.tsx
@@ -116,7 +116,9 @@ const ProductsTable = () => {
);
const deleteModal = useModal();
- const [selectedProduct, setSelectedProduct] = useState(undefined);
+ const [selectedProduct, setSelectedProduct] = useState(
+ undefined
+ );
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [sorting, setSorting] = useState([]);
@@ -153,12 +155,14 @@ const ProductsTable = () => {
{
accessorKey: 'product_price',
header: 'Harga Produk',
- cell: (props) => props.row.original.product_price?.toLocaleString() ?? '-',
+ cell: (props) =>
+ props.row.original.product_price?.toLocaleString() ?? '-',
},
{
accessorKey: 'selling_price',
header: 'Harga Jual',
- cell: (props) => props.row.original.selling_price?.toLocaleString() ?? '-',
+ cell: (props) =>
+ props.row.original.selling_price?.toLocaleString() ?? '-',
},
{
accessorKey: 'tax',
@@ -261,13 +265,15 @@ const ProductsTable = () => {
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');
+ const categorySortFilter = sorting.find(
+ (sortItem) => sortItem.id === 'product_category'
+ );
updateSortingFilter('nameSort', nameSortFilter);
updateSortingFilter('skuSort', skuSortFilter);
updateSortingFilter('brandSort', brandSortFilter);
updateSortingFilter('categorySort', categorySortFilter);
- }, [sorting]);
+ }, [sorting, updateSortingFilter]);
return (
<>
@@ -347,4 +353,4 @@ const ProductsTable = () => {
);
};
-export default ProductsTable;
\ No newline at end of file
+export default ProductsTable;
diff --git a/src/components/pages/master-data/supplier/SupplierTable.tsx b/src/components/pages/master-data/supplier/SupplierTable.tsx
new file mode 100644
index 00000000..672f70a6
--- /dev/null
+++ b/src/components/pages/master-data/supplier/SupplierTable.tsx
@@ -0,0 +1,303 @@
+'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 { SupplierApi } from '@/services/api/master-data';
+import { useTableFilter } from '@/services/hooks/useTableFilter';
+import { Supplier } from '@/types/api/master-data/supplier';
+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 RowOptions = ({
+ type = 'dropdown',
+ props,
+ deleteClickHandler,
+}: {
+ type: 'dropdown' | 'collapse';
+ props: CellContext;
+ deleteClickHandler: () => void;
+}) => {
+ return (
+
+
+
+
+
+ );
+};
+
+const SuppliersTable = () => {
+ const {
+ state: tableFilterState,
+ updateFilter,
+ setPage,
+ setPageSize,
+ toQueryString: getTableFilterQueryString,
+ } = useTableFilter({
+ initial: { search: '', nameSort: '' },
+ paramMap: {
+ page: 'page',
+ pageSize: 'limit',
+ nameSort: 'sort_name',
+ },
+ });
+
+ // Fetch Data
+ const {
+ data: suppliers,
+ isLoading,
+ mutate: refreshSuppliers,
+ } = useSWR(
+ `${SupplierApi.basePath}${getTableFilterQueryString()}`,
+ SupplierApi.getAllFetcher
+ );
+
+ // State
+ const deleteModal = useModal();
+ const [selectedSupplier, setSelectedSupplier] = useState<
+ Supplier | undefined
+ >(undefined);
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+
+ // Columns Definition
+ const suppliersColumns: ColumnDef[] = [
+ {
+ header: '#',
+ cell: (props) =>
+ tableFilterState.pageSize * (tableFilterState.page - 1) +
+ props.row.index +
+ 1,
+ },
+ {
+ accessorKey: 'name',
+ header: 'Nama',
+ },
+ {
+ accessorKey: 'alias',
+ header: 'Alias',
+ },
+ {
+ accessorKey: 'pic',
+ header: 'Nama PIC',
+ },
+ {
+ accessorKey: 'category',
+ header: 'Kategori',
+ },
+ {
+ accessorKey: 'type',
+ header: 'Tipe',
+ },
+ {
+ accessorKey: 'phone',
+ header: 'No. Telp',
+ },
+ {
+ accessorKey: 'email',
+ header: 'Email',
+ },
+ {
+ accessorKey: 'address',
+ header: 'Alamat',
+ },
+ {
+ 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 = () => {
+ setSelectedSupplier(props.row.original);
+ deleteModal.openModal();
+ };
+
+ return (
+ <>
+ {currentPageSize > 2 && (
+
+
+
+ )}
+
+ {currentPageSize <= 2 && (
+
+
+
+ )}
+ >
+ );
+ },
+ },
+ ];
+
+ // Handler
+ const confirmationModalDeleteClickHandler = async () => {
+ setIsDeleteLoading(true);
+
+ await SupplierApi.delete(selectedSupplier?.id as number);
+ refreshSuppliers();
+
+ deleteModal.closeModal();
+ toast.success('Successfully delete Supplier!');
+ 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(suppliers) ? suppliers?.data : []}
+ columns={suppliersColumns}
+ pageSize={tableFilterState.pageSize}
+ page={isResponseSuccess(suppliers) ? suppliers?.meta?.page : 0}
+ totalItems={
+ isResponseSuccess(suppliers) ? suppliers?.meta?.total_results : 0
+ }
+ onPageChange={setPage}
+ isLoading={isLoading}
+ className={{
+ containerClassName: cn({
+ 'mb-20':
+ isResponseSuccess(suppliers) && suppliers?.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 SuppliersTable;
diff --git a/src/components/pages/master-data/supplier/form/SupplierForm.schema.ts b/src/components/pages/master-data/supplier/form/SupplierForm.schema.ts
new file mode 100644
index 00000000..69f127a3
--- /dev/null
+++ b/src/components/pages/master-data/supplier/form/SupplierForm.schema.ts
@@ -0,0 +1,38 @@
+import * as Yup from 'yup';
+
+export const SupplierFormSchema = Yup.object({
+ name: Yup.string().required('Nama wajib diisi!'),
+ alias: Yup.string().required('Alias wajib diisi!'),
+ pic: Yup.string().required('PIC wajib diisi!'),
+ type: Yup.object({
+ value: Yup.string().required(),
+ label: Yup.string().required(),
+ })
+ .required('Tipe wajib diisi!'),
+ category: Yup.object({
+ value: Yup.string().required(),
+ label: Yup.string().required(),
+ })
+ .required('Tipe wajib diisi!'),
+ hatchery: Yup.string().required('Hatchery 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!'),
+ address: Yup.string().required('Alamat wajib diisi!'),
+ npwp: Yup.string()
+ .matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
+ .required('Nomor NPWP wajib diisi!'),
+ account_number: Yup.string()
+ .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
+ .required('Nomor rekening wajib diisi!'),
+ due_date: Yup.number().min(1, 'Tanggal jatuh tempo wajib diisi!').required('Tanggal jatuh tempo wajib diisi!'),
+});
+
+export const UpdateSupplierFormSchema = SupplierFormSchema;
+
+export type SupplierFormValues = Yup.InferType;
\ No newline at end of file
diff --git a/src/components/pages/master-data/supplier/form/SupplierForm.tsx b/src/components/pages/master-data/supplier/form/SupplierForm.tsx
new file mode 100644
index 00000000..fb4630ae
--- /dev/null
+++ b/src/components/pages/master-data/supplier/form/SupplierForm.tsx
@@ -0,0 +1,480 @@
+'use client';
+
+import { useModal } from '@/components/Modal';
+import { CATEGORY_OPTIONS, TYPE_OPTIONS } from '@/config/constant';
+import { isResponseError } from '@/lib/api-helper';
+import { SupplierApi } from '@/services/api/master-data';
+import {
+ CreateSupplierPayload,
+ Supplier,
+} from '@/types/api/master-data/supplier';
+import { useRouter } from 'next/navigation';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import toast from 'react-hot-toast';
+import {
+ SupplierFormSchema,
+ SupplierFormValues,
+ UpdateSupplierFormSchema,
+} from './SupplierForm.schema';
+import { useFormik } from 'formik';
+import SelectInput, { OptionType } from '@/components/input/SelectInput';
+import { Icon } from '@iconify/react';
+import Button from '@/components/Button';
+import TextInput from '@/components/input/TextInput';
+import TagInput from '@/components/input/TagInput';
+import TextArea from '@/components/input/TextArea';
+import { cn } from '@/lib/helper';
+import ConfirmationModal from '@/components/modal/ConfirmationModal';
+
+interface SupplierCustomProps {
+ formType?: 'add' | 'edit' | 'detail';
+ initialValues?: Supplier;
+}
+
+const SupplierForm = ({
+ formType = 'add',
+ initialValues,
+}: SupplierCustomProps) => {
+ // Setup Kebutuhan Form
+ const router = useRouter();
+ const deleteModal = useModal();
+
+ // Setup State
+ const [supplierFormErrorMessage, setSupplierFormErrorMessage] = useState('');
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+ const [typeSelectInputValue, setTypeSelectInputValue] = useState('');
+ const [categorySelectInputValue, setCategorySelectInputValue] = useState('');
+ const [hatcheryTagInputValue, setHatcheryTagInputValue] = useState('');
+
+ // -- Options data mapping
+ const typeOptions = TYPE_OPTIONS;
+ const categoryOptions = CATEGORY_OPTIONS;
+
+ // Handler Event
+ const createSupplierHandler = useCallback(
+ async (payload: CreateSupplierPayload) => {
+ const createSupplierRes = await SupplierApi.create(payload);
+
+ if (isResponseError(createSupplierRes)) {
+ setSupplierFormErrorMessage(createSupplierRes.message);
+ return;
+ }
+
+ toast.success(createSupplierRes?.message as string);
+ router.push('/master-data/supplier');
+ },
+ [router]
+ );
+
+ const updateSupplierHandler = useCallback(
+ async (supplierId: number, payload: CreateSupplierPayload) => {
+ const updateSupplierRes = await SupplierApi.update(supplierId, payload);
+
+ if (isResponseError(updateSupplierRes)) {
+ setSupplierFormErrorMessage(updateSupplierRes.message);
+ return;
+ }
+
+ toast.success(updateSupplierRes?.message as string);
+ router.push('/master-data/supplier');
+ },
+ [router]
+ );
+
+ const deleteSupplierHandler = () => {
+ deleteModal.openModal();
+ };
+
+ const confirmationModalDeleteclickHandler = async () => {
+ setIsDeleteLoading(true);
+
+ await SupplierApi.delete(initialValues?.id as number);
+
+ deleteModal.closeModal();
+ setIsDeleteLoading(false);
+ router.push('/master-data/supplier');
+ };
+
+ // Utils Functions
+ const normalizeOptionValue = (
+ type?: string | { value: string; label: string },
+ options?: OptionType[]
+ ): { value: string; label: string } => {
+ if (!type && !options) return { value: '', label: '' };
+ if (!type && options && options.length > 0)
+ return options[0] as { value: string; label: string };
+ if (typeof type === 'string') return { value: type, label: type };
+ return type ?? { value: '', label: '' };
+ };
+
+ // Memo
+ console.log('Memo');
+ console.log(initialValues);
+ const formikInitialValues = useMemo(() => {
+ return {
+ name: initialValues?.name ?? '',
+ alias: initialValues?.alias ?? '',
+ pic: initialValues?.pic ?? '',
+ type: normalizeOptionValue(initialValues?.type, typeOptions),
+ category: normalizeOptionValue(initialValues?.category, categoryOptions),
+ hatchery: initialValues?.hatchery ?? '',
+ phone: initialValues?.phone ?? '',
+ email: initialValues?.email ?? '',
+ address: initialValues?.address ?? '',
+ npwp: initialValues?.npwp ?? '',
+ account_number: initialValues?.account_number ?? '',
+ due_date: initialValues?.due_date ?? 1,
+ };
+ }, [initialValues]);
+
+ // Formik
+ const formik = useFormik({
+ initialValues: formikInitialValues,
+ enableReinitialize: true,
+ validationSchema:
+ formType === 'edit' ? UpdateSupplierFormSchema : SupplierFormSchema,
+ onSubmit: async (values) => {
+ // reset error message
+ setSupplierFormErrorMessage('');
+
+ // create payload
+ const payload: CreateSupplierPayload = {
+ name: values.name,
+ alias: values.alias,
+ pic: values.pic,
+ type: values.type.value,
+ category: values.category.value,
+ hatchery: values.hatchery,
+ phone: values.phone,
+ email: values.email,
+ address: values.address,
+ npwp: values.npwp,
+ account_number: values.account_number,
+ due_date: parseInt(values.due_date.toString()),
+ };
+
+ // cek type form yang disubmit
+ switch (formType) {
+ case 'add':
+ await createSupplierHandler(payload);
+ break;
+ case 'edit':
+ await updateSupplierHandler(initialValues?.id as number, payload);
+ break;
+ default:
+ break;
+ }
+ },
+ });
+
+ const { setValues: formikSetValues } = formik;
+
+ // Initialize Formik
+ useEffect(() => {
+ formikSetValues(formikInitialValues);
+ setHatcheryTagInputValue(formikInitialValues.hatchery);
+ }, [formikSetValues, formikInitialValues, hatcheryTagInputValue]);
+
+ // Option Handler
+ const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
+ formik.setFieldTouched('type', true);
+ formik.setFieldValue('type', val);
+ };
+ const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
+ formik.setFieldTouched('category', true);
+ formik.setFieldValue('category', val);
+ };
+
+ // Render
+ return (
+ <>
+
+
+
+
+
+ {formType === 'add' && 'Tambah Supplier'}
+ {formType === 'edit' && 'Ubah Supplier'}
+ {formType === 'detail' && 'Detail Supplier'}
+
+
+
+
+
+
+ {formType !== 'add' && (
+
+ )}
+ >
+ );
+};
+
+export default SupplierForm;
diff --git a/src/components/pages/master-data/uom/UomsTable.tsx b/src/components/pages/master-data/uom/UomsTable.tsx
index 080dfaf8..dcec5fe5 100644
--- a/src/components/pages/master-data/uom/UomsTable.tsx
+++ b/src/components/pages/master-data/uom/UomsTable.tsx
@@ -192,7 +192,7 @@ const UomsTable = () => {
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
- }, [sorting]);
+ }, [sorting, updateFilter]);
return (
<>
diff --git a/src/components/pages/master-data/warehouse/WarehousesTable.tsx b/src/components/pages/master-data/warehouse/WarehousesTable.tsx
index b3eed86b..f6d2d071 100644
--- a/src/components/pages/master-data/warehouse/WarehousesTable.tsx
+++ b/src/components/pages/master-data/warehouse/WarehousesTable.tsx
@@ -270,7 +270,7 @@ const WarehousesTable = () => {
updateSortingFilter('areaSort', areaSortFilter);
updateSortingFilter('locationSort', locationSortFilter);
updateSortingFilter('kandangSort', kandangSortFilter);
- }, [sorting]);
+ }, [sorting, updateSortingFilter]);
return (
<>
diff --git a/src/config/constant.ts b/src/config/constant.ts
index 0c0d7d4d..830953d5 100644
--- a/src/config/constant.ts
+++ b/src/config/constant.ts
@@ -127,6 +127,28 @@ export const WAREHOUSE_TYPE_OPTIONS = [
},
];
+export const TYPE_OPTIONS = [
+ {
+ label: 'INDIVIDUAL',
+ value: 'INDIVIDUAL',
+ },
+ {
+ label: 'BISNIS',
+ value: 'BISNIS',
+ },
+];
+
+export const CATEGORY_OPTIONS = [
+ {
+ label: 'BOP',
+ value: 'BOP',
+ },
+ {
+ label: 'SAPRONAK',
+ value: 'SAPRONAK',
+ },
+];
+
export const PRODUCT_FLAG_OPTIONS = [
{ label: 'DOC', value: 'DOC' },
{ label: 'PAKAN', value: 'PAKAN' },
diff --git a/src/services/api/master-data.ts b/src/services/api/master-data.ts
index 259bff8b..dce528e7 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';
import {
CreateProductCategoryPayload,
ProductCategory,
@@ -85,6 +90,11 @@ export const WarehouseApi = new BaseApiService<
UpdateWarehousePayload
>('/master-data/warehouses');
+export const CustomerApi = new BaseApiService<
+ Customer,
+ CreateCustomerPayload,
+ UpdateCustomerPayload
+>('/master-data/customers');
export const ProductCategoryApi = new BaseApiService<
ProductCategory,
CreateProductCategoryPayload,
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..fa133cab
--- /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: string;
+ address: string;
+ phone: string;
+ email: string;
+ account_number: string;
+}
+
+export type Customer = BaseMetadata & BaseCustomer;
+
+export type CreateCustomerPayload = {
+ name: string;
+ pic_id: number;
+ type: string;
+ address: string;
+ phone: string;
+ email: string;
+ account_number: string;
+}
+
+export type UpdateCustomerPayload = CreateCustomerPayload;
\ 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
index f2cfdb11..e6998a25 100644
--- a/src/types/api/master-data/supplier.d.ts
+++ b/src/types/api/master-data/supplier.d.ts
@@ -4,31 +4,35 @@ export type BaseSupplier = {
id: number;
name: string;
alias: string;
- category: string;
pic: string;
type: string;
+ category: string;
+ hatchery: string;
phone: string;
email: string;
address: string;
+ npwp: string;
account_number: string;
- balance: number;
due_date: number;
-};
+ balance?: number;
+}
export type Supplier = BaseMetadata & BaseSupplier;
export type CreateSupplierPayload = {
name: string;
alias: string;
- category: string;
pic: string;
type: string;
+ category: string;
+ hatchery: string;
phone: string;
email: string;
address: string;
+ npwp: string;
account_number: string;
- balance: number;
due_date: number;
-};
+ balance?: number;
+}
export type UpdateSupplierPayload = CreateSupplierPayload;
\ No newline at end of file