diff --git a/.husky/pre-commit b/.husky/pre-commit index 3782914b..ff51d55a 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,3 @@ npm run format npm run lint -npm run build \ No newline at end of file +npx tsc --noEmit \ No newline at end of file diff --git a/src/app/finance/add/adjust/page.tsx b/src/app/finance/add/adjust/page.tsx new file mode 100644 index 00000000..3536892d --- /dev/null +++ b/src/app/finance/add/adjust/page.tsx @@ -0,0 +1,5 @@ +const FinanceAdjust = () => { + return
Finance Adjust
; +}; + +export default FinanceAdjust; diff --git a/src/app/finance/add/initial-balance/page.tsx b/src/app/finance/add/initial-balance/page.tsx new file mode 100644 index 00000000..fb3114ad --- /dev/null +++ b/src/app/finance/add/initial-balance/page.tsx @@ -0,0 +1,7 @@ +import FormFinanceAddInitialBalance from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance'; + +const FinanceAddInitialBalancePage = () => { + return ; +}; + +export default FinanceAddInitialBalancePage; diff --git a/src/app/finance/add/injection/page.tsx b/src/app/finance/add/injection/page.tsx new file mode 100644 index 00000000..502df04b --- /dev/null +++ b/src/app/finance/add/injection/page.tsx @@ -0,0 +1,7 @@ +import FormFinanceInjection from '@/components/pages/finance/add/injection/FormFinanceInjection'; + +const FinanceAddInjectionPage = () => { + return ; +}; + +export default FinanceAddInjectionPage; diff --git a/src/app/finance/add/page.tsx b/src/app/finance/add/page.tsx new file mode 100644 index 00000000..162cd7ec --- /dev/null +++ b/src/app/finance/add/page.tsx @@ -0,0 +1,7 @@ +import FormFinanceAdd from '@/components/pages/finance/add/FormFinanceAdd'; + +const FinanceAddPage = () => { + return ; +}; + +export default FinanceAddPage; diff --git a/src/app/finance/detail/edit/initial-balance/page.tsx b/src/app/finance/detail/edit/initial-balance/page.tsx new file mode 100644 index 00000000..fddb46d9 --- /dev/null +++ b/src/app/finance/detail/edit/initial-balance/page.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import { FinanceApi } from '@/services/api/finance'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import FormFinanceAddInitialBalance from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance'; + +const EditFinanceInitialBalancePage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const financeId = searchParams.get('financeId'); + + const { data: finance, isLoading: isLoadingFinance } = useSWR( + financeId, + (id: number) => FinanceApi.getSingle(id) + ); + + if (!financeId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingFinance && (!finance || isResponseError(finance))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFinance && ( + + )} + + {!isLoadingFinance && ( + + )} +
+ ); +}; + +export default EditFinanceInitialBalancePage; diff --git a/src/app/finance/detail/edit/injection/page.tsx b/src/app/finance/detail/edit/injection/page.tsx new file mode 100644 index 00000000..a538ffd1 --- /dev/null +++ b/src/app/finance/detail/edit/injection/page.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import { FinanceApi } from '@/services/api/finance'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import FormFinanceInjection from '@/components/pages/finance/add/injection/FormFinanceInjection'; + +const EditFinanceInjectionPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const financeId = searchParams.get('financeId'); + + const { data: finance, isLoading: isLoadingFinance } = useSWR( + financeId, + (id: number) => FinanceApi.getSingle(id) + ); + + if (!financeId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingFinance && (!finance || isResponseError(finance))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFinance && ( + + )} + + {!isLoadingFinance && ( + + )} +
+ ); +}; + +export default EditFinanceInjectionPage; diff --git a/src/app/finance/detail/edit/page.tsx b/src/app/finance/detail/edit/page.tsx new file mode 100644 index 00000000..93a0daea --- /dev/null +++ b/src/app/finance/detail/edit/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import { FinanceApi } from '@/services/api/finance'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import FormFinanceAdd from '@/components/pages/finance/add/FormFinanceAdd'; +import FormFinanceAddInitialBalance from '@/components/pages/finance/add/initial-balance/FormFinanceAddInitialBalance'; + +const EditFinanceTransactionPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const financeId = searchParams.get('financeId'); + + const { data: finance, isLoading: isLoadingFinance } = useSWR( + financeId, + (id: number) => FinanceApi.getSingle(id) + ); + + if (!financeId) { + router.back(); + + return ( +
+ +
+ ); + } + + if (!isLoadingFinance && (!finance || isResponseError(finance))) { + router.replace('/404'); + return; + } + + return ( +
+ {isLoadingFinance && ( + + )} + + {!isLoadingFinance && ( + + )} +
+ ); +}; + +export default EditFinanceTransactionPage; diff --git a/src/app/production/project-flock/chickin/add/layout.tsx b/src/app/finance/detail/layout.tsx similarity index 100% rename from src/app/production/project-flock/chickin/add/layout.tsx rename to src/app/finance/detail/layout.tsx diff --git a/src/app/finance/detail/page.tsx b/src/app/finance/detail/page.tsx new file mode 100644 index 00000000..1d20e9f5 --- /dev/null +++ b/src/app/finance/detail/page.tsx @@ -0,0 +1,41 @@ +'use client'; + +import FinanceDetail from '@/components/pages/finance/FinanceDetail'; +import useSWR from 'swr'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { FinanceApi } from '@/services/api/finance'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; + +const FinanceDetailPage = () => { + const router = useRouter(); + const financeId = useSearchParams().get('financeId'); + + const { data: finance } = useSWR(financeId, () => + FinanceApi.getSingle(Number(financeId)) + ); + + if (!financeId) { + router.back(); + + return ( +
+ +
+ ); + } + + console.log(finance); + + // if (!finance || isResponseError(finance)) { + // router.replace('/404'); + // return; + // } + + return ( + <> + {isResponseSuccess(finance) && } + + ); +}; + +export default FinanceDetailPage; diff --git a/src/app/finance/page.tsx b/src/app/finance/page.tsx new file mode 100644 index 00000000..ec78820c --- /dev/null +++ b/src/app/finance/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import FinanceTable from '@/components/pages/finance/FinanceTable'; + +const Finance = () => { + return ( +
+
+ +
+ ); +}; + +export default Finance; diff --git a/src/app/master-data/production-standard/add/page.tsx b/src/app/master-data/production-standard/add/page.tsx new file mode 100644 index 00000000..f25338d6 --- /dev/null +++ b/src/app/master-data/production-standard/add/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import ProductionStandardForm from '@/components/pages/master-data/production-standard/form/ProductionStandardForm'; + +const AddProductionStandardPage = () => { + return ( + <> + + + ); +}; + +export default AddProductionStandardPage; diff --git a/src/app/master-data/production-standard/detail/edit/page.tsx b/src/app/master-data/production-standard/detail/edit/page.tsx new file mode 100644 index 00000000..d048b411 --- /dev/null +++ b/src/app/master-data/production-standard/detail/edit/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import ProductionStandardForm from '@/components/pages/master-data/production-standard/form/ProductionStandardForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { ProductionStandardApi } from '@/services/api/master-data'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const EditProductionStandardPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const productionStandardId = searchParams.get('productionStandardId'); + + // Fetch Data + const { data: productionStandard, isLoading: isLoadingProductionStandard } = + useSWR(productionStandardId, (id: number) => + ProductionStandardApi.getSingle(id) + ); + + if (!productionStandardId) { + router.back(); + + return ( +
+ +
+ ); + } + + if ( + !isLoadingProductionStandard && + (!productionStandard || isResponseError(productionStandard)) + ) { + router.replace('/404'); + return; + } + + return ( + <> + {isLoadingProductionStandard && ( + + )} + {!isLoadingProductionStandard && + isResponseSuccess(productionStandard) && ( + + )} + + ); +}; + +export default EditProductionStandardPage; diff --git a/src/app/master-data/production-standard/detail/layout.tsx b/src/app/master-data/production-standard/detail/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/master-data/production-standard/detail/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/master-data/production-standard/detail/page.tsx b/src/app/master-data/production-standard/detail/page.tsx new file mode 100644 index 00000000..99806dcd --- /dev/null +++ b/src/app/master-data/production-standard/detail/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import ProductionStandardForm from '@/components/pages/master-data/production-standard/form/ProductionStandardForm'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { ProductionStandardApi } from '@/services/api/master-data'; +import { useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +const DetailProductionStandardPage = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get Query Params + const productionStandardId = searchParams.get('productionStandardId'); + + // Fetch Data + const { data: productionStandard, isLoading: isLoadingProductionStandard } = + useSWR(productionStandardId, (id: number) => + ProductionStandardApi.getSingle(id) + ); + + if (!productionStandardId) { + router.back(); + + return ( +
+ +
+ ); + } + + if ( + !isLoadingProductionStandard && + (!productionStandard || isResponseError(productionStandard)) + ) { + router.replace('/404'); + return; + } + + return ( + <> + {isLoadingProductionStandard && ( + + )} + {!isLoadingProductionStandard && + isResponseSuccess(productionStandard) && ( + + )} + + ); +}; + +export default DetailProductionStandardPage; diff --git a/src/app/master-data/production-standard/page.tsx b/src/app/master-data/production-standard/page.tsx new file mode 100644 index 00000000..ed1107cd --- /dev/null +++ b/src/app/master-data/production-standard/page.tsx @@ -0,0 +1,11 @@ +import ProductionStandardTable from '@/components/pages/master-data/production-standard/ProductionStandardTable'; + +const ProductionStandardPage = () => { + return ( +
+ +
+ ); +}; + +export default ProductionStandardPage; diff --git a/src/app/production/project-flock/chickin/add/page.tsx b/src/app/production/project-flock/chickin/add/page.tsx deleted file mode 100644 index 831979cb..00000000 --- a/src/app/production/project-flock/chickin/add/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client'; - -import { FormHeader } from '@/components/helper/form/FormHeader'; -import ProjectFlockChickinDetail from '@/components/pages/production/project-flock/chickin/ProjectFlockChickinDetail'; -import { useSearchParams } from 'next/navigation'; - -const AddChickin = () => { - const searchParams = useSearchParams(); - const projectFlockId = searchParams.get('projectFlockId'); - - return ( - <> -
- -
- - ); -}; - -export default AddChickin; diff --git a/src/app/production/project-flock/chickin/page.tsx b/src/app/production/project-flock/chickin/page.tsx deleted file mode 100644 index d40c39a3..00000000 --- a/src/app/production/project-flock/chickin/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import ChickinTable from '@/components/pages/production/chickin/ChickinTable'; - -const Chickin = () => { - return ( -
- -
- ); -}; -export default Chickin; diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index 17b8a56f..4cb59cdb 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -64,7 +64,7 @@ const Drawer = ({ ), drawerSidebarContent: cn( baseClassNames.drawerSidebarContent, - 'w-full min-w-120 sm:w-fit' + 'w-full sm:min-w-120 sm:w-fit' ), }; } else if (variant === 'left') { @@ -76,7 +76,7 @@ const Drawer = ({ ), drawerSidebarContent: cn( baseClassNames.drawerSidebarContent, - 'w-full min-w-120 sm:w-fit' + 'w-full sm:min-w-120 sm:w-fit' ), }; } diff --git a/src/components/pages/finance/FinanceDetail.tsx b/src/components/pages/finance/FinanceDetail.tsx new file mode 100644 index 00000000..c7057efa --- /dev/null +++ b/src/components/pages/finance/FinanceDetail.tsx @@ -0,0 +1,194 @@ +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import RequirePermission from '@/components/helper/RequirePermission'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import Table from '@/components/Table'; +import { + FINANCE_INITIAL_BALANCE_STATUS, + FINANCE_TRANSACTION_STATUS, +} from '@/config/constant'; +import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; +import { FinanceApi } from '@/services/api/finance'; +import { Finance } from '@/types/api/finance/finance'; +import { Icon } from '@iconify/react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import toast from 'react-hot-toast'; + +const FinanceDetail = ({ finance }: { finance: Finance }) => { + const router = useRouter(); + const deleteModal = useModal(); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const informasiUmum = [ + { + label: 'ID', + value: finance.payment_code, + }, + { + label: 'Jenis Transaksi', + value: finance.transaction_type, + }, + { + label: 'Pihak', + value: finance.party.name, + }, + { + label: 'Tanggal', + value: formatDate(finance.payment_date, 'DD MMM yyyy'), + }, + { + label: 'Metode Pembayaran', + value: finance.payment_method, + }, + { + label: 'Catatan', + value: finance.notes || '-', + }, + ]; + const informasiTransfer = [ + { + label: 'No. Referensi', + value: finance.reference_number, + }, + { + label: 'Nomor Rekening', + value: `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`, + }, + { + label: `Rekening ${formatTitleCase(finance.party.type)}`, + value: finance.party.account_number, + }, + { + label: 'Nominal', + value: formatCurrency(finance.expense_amount), + }, + { + label: 'Sisa', + value: formatCurrency(finance.income_amount), + }, + ]; + + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await FinanceApi.delete(finance.id as number); + router.back(); + + deleteModal.closeModal(); + toast.success('Successfully delete Finance!'); + setIsDeleteLoading(false); + }; + + return ( +
+ + + +
+ +
+ + + +
+ {FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && ( + + + + )} + {FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && ( + + + + )} + + + +
+ + + ); +}; + +export default FinanceDetail; diff --git a/src/components/pages/finance/FinanceTable.tsx b/src/components/pages/finance/FinanceTable.tsx new file mode 100644 index 00000000..71ed6c84 --- /dev/null +++ b/src/components/pages/finance/FinanceTable.tsx @@ -0,0 +1,564 @@ +import { ChangeEventHandler, useMemo, useState } from 'react'; +import { CellContext, Row } from '@tanstack/react-table'; +import { useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; + +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import Dropdown from '@/components/dropdown/Dropdown'; +import DateInput from '@/components/input/DateInput'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import Menu from '@/components/menu/Menu'; +import MenuItem from '@/components/menu/MenuItem'; +import Table from '@/components/Table'; +import Tooltip from '@/components/Tooltip'; +import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { Finance } from '@/types/api/finance/finance'; +import { + FINANCE_INITIAL_BALANCE_STATUS, + FINANCE_INJECTION_STATUS, + FINANCE_TRANSACTION_STATUS, + ROWS_OPTIONS, +} from '@/config/constant'; +import { FinanceApi } from '@/services/api/finance'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; +import { Bank } from '@/types/api/master-data/bank'; +import { useModal } from '@/components/Modal'; +import ConfirmationModal from '@/components/modal/ConfirmationModal'; +import toast from 'react-hot-toast'; +import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; +import RequirePermission from '@/components/helper/RequirePermission'; +import { Icon } from '@iconify/react'; +import RowDropdownOptions from '@/components/table/RowDropdownOptions'; +import RowCollapseOptions from '@/components/table/RowCollapseOptions'; + +const RowOptionsMenu = ({ + type = 'dropdown', + props, + deleteClickHandler, +}: { + type: 'dropdown' | 'collapse'; + props: CellContext; + deleteClickHandler: () => void; +}) => { + return ( + + + + + + {FINANCE_TRANSACTION_STATUS.includes( + props.row.original.transaction_type + ) && ( + + + + )} + + {FINANCE_INITIAL_BALANCE_STATUS.includes( + props.row.original.transaction_type + ) && ( + + + + )} + + {FINANCE_INJECTION_STATUS.includes( + props.row.original.transaction_type + ) && ( + + + + )} + + + + + + ); +}; + +const FinanceTable = () => { + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + search: '', + transactionType: '', + bankId: '', + partyType: '', + sortBy: '', + startDate: '', + endDate: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + transactionType: 'transaction_type', + bankId: 'bank_id', + partyType: 'party_type', + sortBy: 'sort_date', + startDate: 'start_date', + endDate: 'end_date', + }, + }); + + // ===== State ===== + const [searchParams, setSearchParams] = useSearchParams(); + const deleteModal = useModal(); + const [pendingFilters, setPendingFilters] = useState({ + search: '', + transactionType: '', + bankId: '', + partyType: '', + sortBy: '', + startDate: '', + endDate: '', + }); + const [selectedTransactionType, setSelectedTransactionType] = + useState(null); + const [selectedBank, setSelectedBank] = useState(null); + const [selectedPartyType, setSelectedPartyType] = useState( + null + ); + const [selectedSortBy, setSelectedSortBy] = useState(null); + const [selectedFinance, setSelectedFinance] = useState(null); + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + // ===== Fetch Data ===== + const { + data: finances, + isLoading, + mutate: refreshFinances, + } = useSWR( + `${FinanceApi.basePath}/transactions${getTableFilterQueryString()}`, + FinanceApi.getAllFetcher + ); + + // ===== Options ===== + const transactionTypeOptions = useMemo(() => { + return [ + { label: 'Transfer', value: 'TRANSFER' }, + { label: 'Cash', value: 'CASH' }, + { label: 'Card', value: 'CARD' }, + { label: 'Cheque', value: 'CHEQUE' }, + { label: 'Saldo', value: 'SALDO' }, + ]; + }, []); + const partyTypeOptions = useMemo(() => { + return [ + { label: 'Customer', value: 'CUSTOMER' }, + { label: 'Supplier', value: 'SUPPLIER' }, + ]; + }, []); + const sortByOptions = useMemo(() => { + return [ + { label: 'Tanggal Pembayaran', value: 'payment_date' }, + { label: 'Tanggal Dibuat', value: 'created_at' }, + ]; + }, []); + const { options: bankOptions, rawData: bankRawData } = useSelect( + BankApi.basePath, + 'id', + 'alias', + '', + { + limit: 'limit', + } + ); + + // ===== Handler ===== + const searchChangeHandler: ChangeEventHandler = (e) => { + setPendingFilters((prev) => ({ ...prev, search: e.target.value })); + }; + const transactionTypeChangeHandler = ( + val: OptionType | OptionType[] | null + ) => { + setSelectedTransactionType(val as OptionType); + setPendingFilters((prev) => ({ + ...prev, + transactionType: val ? ((val as OptionType).value as string) : '', + })); + }; + const bankChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedBank(val as OptionType); + setPendingFilters((prev) => ({ + ...prev, + bankId: val ? ((val as OptionType).value as string) : '', + })); + }; + const partyTypeChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedPartyType(val as OptionType); + setPendingFilters((prev) => ({ + ...prev, + partyType: val ? ((val as OptionType).value as string) : '', + })); + }; + const sortByChangeHandler = (val: OptionType | OptionType[] | null) => { + setSelectedSortBy(val as OptionType); + setPendingFilters((prev) => ({ + ...prev, + sortBy: val ? ((val as OptionType).value as string) : '', + })); + }; + const startDateChangeHandler: ChangeEventHandler = (e) => { + setPendingFilters((prev) => ({ ...prev, startDate: e.target.value })); + }; + const endDateChangeHandler: ChangeEventHandler = (e) => { + setPendingFilters((prev) => ({ ...prev, endDate: e.target.value })); + }; + const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + setPageSize(newVal.value as number); + }; + const submitFilterHandler = () => { + updateFilter('search', pendingFilters.search); + updateFilter('transactionType', pendingFilters.transactionType); + updateFilter('bankId', pendingFilters.bankId); + updateFilter('partyType', pendingFilters.partyType); + updateFilter('sortBy', pendingFilters.sortBy); + updateFilter('startDate', pendingFilters.startDate); + updateFilter('endDate', pendingFilters.endDate); + }; + const resetFilterHandler = () => { + setSelectedTransactionType(null); + setSelectedBank(null); + setSelectedPartyType(null); + setSelectedSortBy(null); + + const emptyFilters = { + search: '', + transactionType: '', + bankId: '', + partyType: '', + sortBy: '', + startDate: '', + endDate: '', + }; + setPendingFilters(emptyFilters); + + updateFilter('search', ''); + updateFilter('transactionType', ''); + updateFilter('bankId', ''); + updateFilter('partyType', ''); + updateFilter('sortBy', ''); + updateFilter('startDate', ''); + updateFilter('endDate', ''); + }; + const confirmationModalDeleteClickHandler = async () => { + setIsDeleteLoading(true); + + await FinanceApi.delete(selectedFinance?.id as number); + refreshFinances(); + + deleteModal.closeModal(); + toast.success('Successfully delete Finance!'); + setIsDeleteLoading(false); + }; + + const columns = useMemo(() => { + return [ + { + header: 'ID', + accessorKey: 'payment_code', + }, + { + header: 'References Number', + accessorKey: 'reference_number', + cell: (props: CellContext) => { + const value = props.row.original.reference_number; + return {value ?? '-'}; + }, + }, + { + header: 'Jenis Transaksi', + accessorKey: 'transaction_type', + cell: (props: CellContext) => { + const value = props.row.original.transaction_type + .split('_') + .join(' '); + return {formatTitleCase(value)}; + }, + }, + { + header: 'Pihak', + accessorFn: (finance: Finance) => finance.party.name, + cell: (props: CellContext) => { + if (props.row.original.party.id) { + return {props.row.original.party.name}; + } + return {'-'}; + }, + }, + { + header: 'Tanggal', + accessorFn: (finance: Finance) => + formatDate(finance.payment_date, 'DD MMM YYYY'), + }, + { + header: 'Metode Pembayaran', + accessorKey: 'payment_method', + cell: (props: CellContext) => { + const value = props.row.original.payment_method.split('_').join(' '); + return {formatTitleCase(value)}; + }, + }, + { + header: 'Bank', + accessorFn: (finance: Finance) => + `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`, + }, + { + header: 'Pengeluaran (Rp)', + accessorFn: (finance: Finance) => + formatCurrency(finance.expense_amount), + }, + { + header: 'Pemasukan (Rp)', + accessorFn: (finance: Finance) => formatCurrency(finance.income_amount), + }, + { + header: 'Aksi', + cell: (props: CellContext) => { + 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 = () => { + setSelectedFinance(props.row.original); + deleteModal.openModal(); + }; + + return ( + <> + {currentPageSize > 2 && ( + + + + )} + + {currentPageSize <= 2 && ( + + + + )} + + ); + }, + }, + ]; + }, []); + return ( +
+
+ + + + + + + + + +
+ + + + + } + > +
+ + ({ + label: + bankRawData.data.find((data) => data.id === bank.value) + ?.alias + + ' - ' + + bankRawData.data.find((data) => data.id === bank.value) + ?.account_number + + ' - ' + + bankRawData.data.find((data) => data.id === bank.value) + ?.owner, + value: bank.value, + })) + : [] + } + label='Bank' + value={selectedBank} + onChange={bankChangeHandler} + isClearable + /> + + + + + +
+
+ + data={isResponseSuccess(finances) ? finances.data : []} + columns={columns} + pageSize={tableFilterState.pageSize} + page={tableFilterState.page} + onPageChange={setPage} + onPageSizeChange={setPageSize} + totalItems={ + isResponseSuccess(finances) ? finances.meta?.total_results : 0 + } + isLoading={isLoading} + /> + +
+ ); +}; + +export default FinanceTable; diff --git a/src/components/pages/finance/add/FormFinanceAdd.schema.ts b/src/components/pages/finance/add/FormFinanceAdd.schema.ts new file mode 100644 index 00000000..9aff81b9 --- /dev/null +++ b/src/components/pages/finance/add/FormFinanceAdd.schema.ts @@ -0,0 +1,67 @@ +import * as Yup from 'yup'; +import { OptionType } from '@/components/input/SelectInput'; + +/** + * API Payload format: + * { + "party_id": 1, + "party_type": "CUSTOMER", + "payment_date": "2025-11-21", + "payment_method": "Transfer", + "bank_id": 1, + "reference_number": "DO.MBU.123", + "nominal": 25000000, + "notes": "Pembayaran piutang penjualan telur" + } + */ + +// Type for form values (includes option objects for SelectInput) +export type FinanceFormValues = { + party_type_option: OptionType | null; + party_id_option: OptionType | null; + party_account_number: string; + payment_date: string; + payment_method_option: OptionType | null; + bank_id_option: OptionType | null; + reference_number: string; + nominal: string; + notes: string; +}; + +export const FinanceFormSchema = Yup.object().shape({ + party_type_option: Yup.mixed() + .nullable() + .test( + 'is-valid-option', + 'Jenis transaksi wajib diisi', + (value) => value !== null && value !== undefined + ), + party_id_option: Yup.mixed() + .nullable() + .test( + 'is-valid-option', + 'Pihak wajib diisi', + (value) => value !== null && value !== undefined + ), + party_account_number: Yup.string().required('Nomor rekening wajib diisi'), + payment_date: Yup.string().required('Tanggal pembayaran wajib diisi'), + payment_method_option: Yup.mixed() + .nullable() + .test( + 'is-valid-option', + 'Metode pembayaran wajib diisi', + (value) => value !== null && value !== undefined + ), + bank_id_option: Yup.mixed() + .nullable() + .test( + 'is-valid-option', + 'Bank wajib diisi', + (value) => value !== null && value !== undefined + ), + reference_number: Yup.string().required('Nomor referensi wajib diisi'), + nominal: Yup.string().required('Nominal wajib diisi'), + notes: Yup.string().required('Catatan wajib diisi'), +}); + +export const UpdateFinanceFormSchema = FinanceFormSchema; diff --git a/src/components/pages/finance/add/FormFinanceAdd.tsx b/src/components/pages/finance/add/FormFinanceAdd.tsx new file mode 100644 index 00000000..9b8259be --- /dev/null +++ b/src/components/pages/finance/add/FormFinanceAdd.tsx @@ -0,0 +1,390 @@ +'use client'; + +import Button from '@/components/Button'; +import Card from '@/components/Card'; +import { FormHeader } from '@/components/helper/form/FormHeader'; +import DateInput from '@/components/input/DateInput'; +import NumberInput from '@/components/input/NumberInput'; +import SelectInput, { + OptionType, + useSelect, +} from '@/components/input/SelectInput'; +import TextArea from '@/components/input/TextArea'; +import TextInput from '@/components/input/TextInput'; +import { + FinanceFormSchema, + FinanceFormValues, +} from '@/components/pages/finance/add/FormFinanceAdd.schema'; +import { + FINANCE_PARTY_TYPE_OPTIONS, + FINANCE_PAYMENT_METHOD_OPTIONS, +} from '@/config/constant'; +import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; +import { formatDate, formatTitleCase } from '@/lib/helper'; +import { FinanceApi } from '@/services/api/finance'; +import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; +import { + CreateFinancePayment, + Finance, + UpdateFinancePayment, +} from '@/types/api/finance/finance'; +import { Bank } from '@/types/api/master-data/bank'; +import { useFormik } from 'formik'; +import { useRouter } from 'next/navigation'; +import { useCallback, useMemo } from 'react'; +import toast from 'react-hot-toast'; + +interface FormFinanceAddProps { + type?: 'add' | 'edit'; + initialValues?: Finance; +} + +const FormFinanceAdd = ({ + type = 'add', + initialValues, +}: FormFinanceAddProps) => { + const router = useRouter(); + + // ===== Formik ===== + const formikInitialValues = useMemo((): FinanceFormValues => { + return { + party_type_option: + FINANCE_PARTY_TYPE_OPTIONS.find( + (option) => option.value === initialValues?.party.type + ) || null, + party_id_option: { + label: initialValues?.party.name || '', + value: initialValues?.party.id || 0, + }, + payment_date: initialValues?.payment_date || '', + payment_method_option: + FINANCE_PAYMENT_METHOD_OPTIONS.find( + (option) => option.value === initialValues?.payment_method + ) || null, + bank_id_option: initialValues?.bank + ? { + label: initialValues.bank.name, + value: initialValues.bank.id, + } + : null, + party_account_number: initialValues?.party.account_number || '', + reference_number: initialValues?.reference_number || '', + nominal: initialValues?.nominal.toString() || '', + notes: initialValues?.notes || '', + }; + }, [initialValues]); + + const formik = useFormik({ + initialValues: formikInitialValues, + validationSchema: FinanceFormSchema, + validateOnChange: true, + validateOnBlur: true, + onSubmit: async (values) => { + const payload = transformFormValuesToPayload(values); + + switch (type) { + case 'add': + await createFinance(payload); + break; + + case 'edit': + if (initialValues?.id) { + await updateFinance(initialValues.id, payload); + } + break; + } + }, + }); + + // ===== Options ===== + const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } = + useSelect( + formik.values.party_type_option?.value === 'CUSTOMER' + ? CustomerApi.basePath + : SupplierApi.basePath, + 'id', + 'name', + '', + { limit: 'limit' } + ); + const { + options: bankOptions, + rawData: bankRawData, + isLoadingOptions: isLoadingBankOptions, + } = useSelect(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); + + // ===== Helper Functions ===== + const transformFormValuesToPayload = ( + values: FinanceFormValues + ): CreateFinancePayment => { + return { + party_id: Number(values.party_id_option?.value) || 0, + party_type: (values.party_type_option?.value as string) || '', + payment_date: formatDate(values.payment_date, 'YYYY-MM-DD'), + payment_method: (values.payment_method_option?.value as string) || '', + bank_id: Number(values.bank_id_option?.value) || 0, + reference_number: values.reference_number, + nominal: Number(values.nominal.replace(/\D/g, '')) || 0, + notes: values.notes, + }; + }; + + // ===== Handler ===== + const createFinance = useCallback( + async (payload: CreateFinancePayment) => { + const response = await FinanceApi.create(payload); + + if (isResponseError(response)) { + toast.error(response.message); + return; + } + + toast.success('Data berhasil ditambahkan'); + router.refresh(); + router.push('/finance'); + }, + [router] + ); + const updateFinance = useCallback( + async (financeId: number, payload: UpdateFinancePayment) => { + const response = await FinanceApi.update(financeId, payload); + + if (isResponseError(response)) { + toast.error(response.message); + return; + } + + toast.success('Data berhasil diperbarui'); + router.refresh(); + router.push('/finance'); + }, + [router] + ); + + return ( + <> +
+
+ +
+ { + formik.setFieldValue('party_type_option', value); + }} + isError={Boolean( + formik.touched.party_type_option && + formik.errors.party_type_option + )} + errorMessage={ + formik.touched.party_type_option && + formik.errors.party_type_option + ? formik.errors.party_type_option + : '' + } + required + isClearable + /> + { + formik.setFieldValue('party_id_option', value); + }} + isLoading={isLoadingPartyOptions} + isError={Boolean( + formik.touched.party_id_option && formik.errors.party_id_option + )} + errorMessage={ + formik.touched.party_id_option && formik.errors.party_id_option + ? formik.errors.party_id_option + : '' + } + required + isClearable + isDisabled={!formik.values.party_type_option?.value} + /> + + { + formik.setFieldValue('payment_method_option', value); + }} + isError={Boolean( + formik.touched.payment_method_option && + formik.errors.payment_method_option + )} + errorMessage={ + formik.touched.payment_method_option && + formik.errors.payment_method_option + ? formik.errors.payment_method_option + : '' + } + required + isClearable + /> + ({ + label: + bankRawData.data?.find( + (item) => item.id === option.value + )?.alias + + ' - ' + + bankRawData.data?.find( + (item) => item.id === option.value + )?.account_number + + ' - ' + + bankRawData.data?.find( + (item) => item.id === option.value + )?.owner, + value: option.value, + })) + : [] + } + value={formik.values.bank_id_option} + onChange={(value) => { + formik.setFieldValue('bank_id_option', value); + }} + isLoading={isLoadingBankOptions} + isError={Boolean( + formik.touched.bank_id_option && formik.errors.bank_id_option + )} + errorMessage={ + formik.touched.bank_id_option && formik.errors.bank_id_option + ? formik.errors.bank_id_option + : '' + } + required + isClearable + /> + + + +