From 630d9c0e65ce6077c49f550cc4b7ec0f65dacf3f Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 11 Sep 2025 00:16:00 +0700 Subject: [PATCH] Account Page --- .../(private)/apps/account/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 8 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/types/apps/accountTypes.ts | 7 + src/views/apps/account/AccountFormDrawer.tsx | 308 ++++++++++ src/views/apps/account/AccountListTable.tsx | 576 ++++++++++++++++++ src/views/apps/account/index.tsx | 19 + 8 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/account/page.tsx create mode 100644 src/types/apps/accountTypes.ts create mode 100644 src/views/apps/account/AccountFormDrawer.tsx create mode 100644 src/views/apps/account/AccountListTable.tsx create mode 100644 src/views/apps/account/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/account/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/account/page.tsx new file mode 100644 index 0000000..1d47a3e --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/account/page.tsx @@ -0,0 +1,7 @@ +import AccountList from '@/views/apps/account' + +const AccountPage = () => { + return +} + +export default AccountPage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 66e8c22..880cd61 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -129,6 +129,14 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { > {dictionary['navigation'].cash_and_bank} + } + exactMatch={false} + activeUrl='/apps/account' + > + {dictionary['navigation'].account} + }> {dictionary['navigation'].list} diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index df083d2..f5948c4 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -124,6 +124,7 @@ "sales_orders": "Orders", "quotes": "Quotes", "expenses": "Expenses", - "cash_and_bank": "Cash & Bank" + "cash_and_bank": "Cash & Bank", + "account": "Account" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 53ddf6d..d080b8c 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -124,6 +124,7 @@ "sales_orders": "Pemesanan", "quotes": "Penawaran", "expenses": "Biaya", - "cash_and_bank": "Kas & Bank" + "cash_and_bank": "Kas & Bank", + "account": "Akun" } } diff --git a/src/types/apps/accountTypes.ts b/src/types/apps/accountTypes.ts new file mode 100644 index 0000000..b4b151e --- /dev/null +++ b/src/types/apps/accountTypes.ts @@ -0,0 +1,7 @@ +export type AccountType = { + id: number + code: string + name: string + category: string + balance: string +} diff --git a/src/views/apps/account/AccountFormDrawer.tsx b/src/views/apps/account/AccountFormDrawer.tsx new file mode 100644 index 0000000..b6f7512 --- /dev/null +++ b/src/views/apps/account/AccountFormDrawer.tsx @@ -0,0 +1,308 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Box from '@mui/material/Box' + +// Third-party Imports +import { useForm, Controller } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' +import CustomAutocomplete from '@/@core/components/mui/Autocomplete' + +// Account Type +export type AccountType = { + id: number + code: string + name: string + category: string + balance: string +} + +type Props = { + open: boolean + handleClose: () => void + accountData?: AccountType[] + setData: (data: AccountType[]) => void + editingAccount?: AccountType | null +} + +type FormValidateType = { + name: string + code: string + category: string + parentAccount?: string +} + +// Categories available for accounts +const accountCategories = [ + 'Kas & Bank', + 'Piutang', + 'Persediaan', + 'Aset Tetap', + 'Hutang', + 'Ekuitas', + 'Pendapatan', + 'Beban' +] + +// Parent accounts (dummy data for dropdown) +const parentAccounts = [ + { id: 1, code: '1-10001', name: 'Kas' }, + { id: 2, code: '1-10002', name: 'Bank BCA' }, + { id: 3, code: '1-10003', name: 'Bank Mandiri' }, + { id: 4, code: '1-10101', name: 'Piutang Usaha' }, + { id: 5, code: '1-10201', name: 'Persediaan Barang' }, + { id: 6, code: '2-20001', name: 'Hutang Usaha' }, + { id: 7, code: '3-30001', name: 'Modal Pemilik' }, + { id: 8, code: '4-40001', name: 'Penjualan' }, + { id: 9, code: '5-50001', name: 'Beban Gaji' } +] + +// Vars +const initialData = { + name: '', + code: '', + category: '', + parentAccount: '' +} + +const AccountFormDrawer = (props: Props) => { + // Props + const { open, handleClose, accountData, setData, editingAccount } = props + + // Determine if we're editing + const isEdit = !!editingAccount + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + // Reset form when editingAccount changes or drawer opens + useEffect(() => { + if (open) { + if (editingAccount) { + // Populate form with existing data + resetForm({ + name: editingAccount.name, + code: editingAccount.code, + category: editingAccount.category, + parentAccount: '' + }) + } else { + // Reset to initial data for new account + resetForm(initialData) + } + } + }, [editingAccount, open, resetForm]) + + const onSubmit = (data: FormValidateType) => { + if (isEdit && editingAccount) { + // Update existing account + const updatedAccounts = + accountData?.map(account => + account.id === editingAccount.id + ? { + ...account, + code: data.code, + name: data.name, + category: data.category + } + : account + ) || [] + + setData(updatedAccounts) + } else { + // Create new account + const newAccount: AccountType = { + id: accountData?.length ? Math.max(...accountData.map(a => a.id)) + 1 : 1, + code: data.code, + name: data.name, + category: data.category, + balance: '0' + } + + setData([...(accountData ?? []), newAccount]) + } + + handleClose() + resetForm(initialData) + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + } + + return ( + + {/* Sticky Header */} + +
+ {isEdit ? 'Edit Akun' : 'Tambah Akun Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
onSubmit(data))}> +
+ {/* Nama */} +
+ + Nama * + + ( + + )} + /> +
+ + {/* Kode */} +
+ + Kode * + + ( + + )} + /> +
+ + {/* Kategori */} +
+ + Kategori * + + ( + onChange(newValue || '')} + renderInput={params => ( + + )} + isOptionEqualToValue={(option, value) => option === value} + /> + )} + /> +
+ + {/* Sub Akun dari */} +
+ + Sub Akun dari + + ( + `${account.code} ${account.name}` === value) || null} + onChange={(_, newValue) => onChange(newValue ? `${newValue.code} ${newValue.name}` : '')} + getOptionLabel={option => `${option.code} ${option.name}`} + renderInput={params => } + isOptionEqualToValue={(option, value) => + `${option.code} ${option.name}` === `${value.code} ${value.name}` + } + /> + )} + /> +
+
+
+
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AccountFormDrawer diff --git a/src/views/apps/account/AccountListTable.tsx b/src/views/apps/account/AccountListTable.tsx new file mode 100644 index 0000000..5746c94 --- /dev/null +++ b/src/views/apps/account/AccountListTable.tsx @@ -0,0 +1,576 @@ +'use client' + +// React Imports +import { useCallback, useEffect, useMemo, useState } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Button from '@mui/material/Button' +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Checkbox from '@mui/material/Checkbox' +import Chip from '@mui/material/Chip' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import { styled } from '@mui/material/styles' +import type { TextFieldProps } from '@mui/material/TextField' +import Typography from '@mui/material/Typography' + +// Third-party Imports +import type { RankingInfo } from '@tanstack/match-sorter-utils' +import { rankItem } from '@tanstack/match-sorter-utils' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import classnames from 'classnames' + +// Type Imports +import type { Locale } from '@configs/i18n' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' +import OptionMenu from '@core/components/option-menu' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import { Box, CircularProgress, TablePagination } from '@mui/material' +import { useDispatch } from 'react-redux' +import TablePaginationComponent from '@/components/TablePaginationComponent' +import Loading from '@/components/layout/shared/Loading' +import { getLocalizedUrl } from '@/utils/i18n' +import AccountFormDrawer from './AccountFormDrawer' + +// Account Type +export type AccountType = { + id: number + code: string + name: string + category: string + balance: string +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type AccountTypeWithAction = AccountType & { + actions?: string +} + +// Dummy Account Data +const accountsData: AccountType[] = [ + { + id: 1, + code: '1-10001', + name: 'Kas', + category: 'Kas & Bank', + balance: '20000000' + }, + { + id: 2, + code: '1-10002', + name: 'Bank BCA', + category: 'Kas & Bank', + balance: '150000000' + }, + { + id: 3, + code: '1-10003', + name: 'Bank Mandiri', + category: 'Kas & Bank', + balance: '75000000' + }, + { + id: 4, + code: '1-10101', + name: 'Piutang Usaha', + category: 'Piutang', + balance: '50000000' + }, + { + id: 5, + code: '1-10102', + name: 'Piutang Karyawan', + category: 'Piutang', + balance: '5000000' + }, + { + id: 6, + code: '1-10201', + name: 'Persediaan Barang', + category: 'Persediaan', + balance: '100000000' + }, + { + id: 7, + code: '1-10301', + name: 'Peralatan Kantor', + category: 'Aset Tetap', + balance: '25000000' + }, + { + id: 8, + code: '1-10302', + name: 'Kendaraan', + category: 'Aset Tetap', + balance: '200000000' + }, + { + id: 9, + code: '2-20001', + name: 'Hutang Usaha', + category: 'Hutang', + balance: '-30000000' + }, + { + id: 10, + code: '2-20002', + name: 'Hutang Gaji', + category: 'Hutang', + balance: '-15000000' + }, + { + id: 11, + code: '3-30001', + name: 'Modal Pemilik', + category: 'Ekuitas', + balance: '500000000' + }, + { + id: 12, + code: '4-40001', + name: 'Penjualan', + category: 'Pendapatan', + balance: '250000000' + }, + { + id: 13, + code: '5-50001', + name: 'Beban Gaji', + category: 'Beban', + balance: '-80000000' + }, + { + id: 14, + code: '5-50002', + name: 'Beban Listrik', + category: 'Beban', + balance: '-5000000' + }, + { + id: 15, + code: '5-50003', + name: 'Beban Telepon', + category: 'Beban', + balance: '-2000000' + } +] + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Category color mapping for Accounts +const getCategoryColor = (category: string) => { + switch (category) { + case 'Kas & Bank': + return 'success' + case 'Piutang': + return 'info' + case 'Persediaan': + return 'warning' + case 'Aset Tetap': + return 'primary' + case 'Hutang': + return 'error' + case 'Ekuitas': + return 'secondary' + case 'Pendapatan': + return 'success' + case 'Beban': + return 'error' + default: + return 'default' + } +} + +// Format currency +const formatCurrency = (amount: string) => { + const numAmount = parseInt(amount) + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(Math.abs(numAmount)) +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const AccountListTable = () => { + const dispatch = useDispatch() + + // States + const [addAccountOpen, setAddAccountOpen] = useState(false) + const [rowSelection, setRowSelection] = useState({}) + const [currentPage, setCurrentPage] = useState(0) + const [pageSize, setPageSize] = useState(10) + const [openConfirm, setOpenConfirm] = useState(false) + const [accountId, setAccountId] = useState('') + const [search, setSearch] = useState('') + const [categoryFilter, setCategoryFilter] = useState('Semua') + const [filteredData, setFilteredData] = useState(accountsData) + const [data, setData] = useState(accountsData) + const [editingAccount, setEditingAccount] = useState(null) + + // Hooks + const { lang: locale } = useParams() + + // Get unique categories for filter + const categories = useMemo(() => { + const uniqueCategories = [...new Set(data.map(account => account.category))] + return ['Semua', ...uniqueCategories] + }, [data]) + + // Filter data based on search and category + useEffect(() => { + let filtered = data + + // Filter by search + if (search) { + filtered = filtered.filter( + account => + account.code.toLowerCase().includes(search.toLowerCase()) || + account.name.toLowerCase().includes(search.toLowerCase()) || + account.category.toLowerCase().includes(search.toLowerCase()) + ) + } + + // Filter by category + if (categoryFilter !== 'Semua') { + filtered = filtered.filter(account => account.category === categoryFilter) + } + + setFilteredData(filtered) + setCurrentPage(0) + }, [search, categoryFilter, data]) + + const totalCount = filteredData.length + const paginatedData = useMemo(() => { + const startIndex = currentPage * pageSize + return filteredData.slice(startIndex, startIndex + pageSize) + }, [filteredData, currentPage, pageSize]) + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(0) + }, []) + + const handleDelete = () => { + setOpenConfirm(false) + } + + // Handle row click for edit + const handleRowClick = (account: AccountType, event: React.MouseEvent) => { + // Don't trigger row click if clicking on checkbox or link + const target = event.target as HTMLElement + if (target.closest('input[type="checkbox"]') || target.closest('a') || target.closest('button')) { + return + } + + setEditingAccount(account) + setAddAccountOpen(true) + } + + // Handle closing drawer and reset editing state + const handleCloseDrawer = () => { + setAddAccountOpen(false) + setEditingAccount(null) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('code', { + header: 'Kode Akun', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('name', { + header: 'Nama Akun', + cell: ({ row }) => ( + + {row.original.name} + + ) + }), + columnHelper.accessor('category', { + header: 'Kategori', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('balance', { + header: 'Saldo', + cell: ({ row }) => { + const balance = parseInt(row.original.balance) + return ( + + {balance < 0 ? '-' : ''} + {formatCurrency(row.original.balance)} + + ) + } + }) + ], + [locale] + ) + + const table = useReactTable({ + data: paginatedData as AccountType[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ setSearch(value as string)} + placeholder='Cari Akun' + className='max-sm:is-full' + /> +
+ + 10 + 25 + 50 + + + +
+
+
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {filteredData.length === 0 ? ( + + + + + + ) : ( + + {table.getRowModel().rows.map(row => { + return ( + handleRowClick(row.original, e)} + > + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ + ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + /> +
+ + + ) +} + +export default AccountListTable diff --git a/src/views/apps/account/index.tsx b/src/views/apps/account/index.tsx new file mode 100644 index 0000000..783a875 --- /dev/null +++ b/src/views/apps/account/index.tsx @@ -0,0 +1,19 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' + +// Type Imports + +// Component Imports +import AccountListTable from './AccountListTable' + +const AccountList = () => { + return ( + + + + + + ) +} + +export default AccountList