diff --git a/src/app/[lang]/(dashboard)/(private)/apps/expense/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/expense/page.tsx new file mode 100644 index 0000000..08dd739 --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/expense/page.tsx @@ -0,0 +1,7 @@ +import ExpenseList from '@/views/apps/expense/list' + +const ExpensePage = () => { + return +} + +export default ExpensePage diff --git a/src/components/RangeDatePicker.tsx b/src/components/RangeDatePicker.tsx new file mode 100644 index 0000000..774a0de --- /dev/null +++ b/src/components/RangeDatePicker.tsx @@ -0,0 +1,276 @@ +// React Imports +import React from 'react' + +// MUI Imports +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useTheme } from '@mui/material/styles' +import type { TextFieldProps } from '@mui/material/TextField' + +interface DateRangePickerProps { + /** + * Start date value (Date object or date string) + */ + startDate: Date | string | null + /** + * End date value (Date object or date string) + */ + endDate: Date | string | null + /** + * Callback when start date changes + */ + onStartDateChange: (date: Date | null) => void + /** + * Callback when end date changes + */ + onEndDateChange: (date: Date | null) => void + /** + * Label for start date field + */ + startLabel?: string + /** + * Label for end date field + */ + endLabel?: string + /** + * Placeholder for start date field + */ + startPlaceholder?: string + /** + * Placeholder for end date field + */ + endPlaceholder?: string + /** + * Size of the text fields + */ + size?: 'small' | 'medium' + /** + * Whether the fields are disabled + */ + disabled?: boolean + /** + * Whether the fields are required + */ + required?: boolean + /** + * Custom className for the container + */ + className?: string + /** + * Custom styles for the container + */ + containerStyle?: React.CSSProperties + /** + * Separator between date fields + */ + separator?: string + /** + * Custom props for start date TextField + */ + startTextFieldProps?: Omit + /** + * Custom props for end date TextField + */ + endTextFieldProps?: Omit + /** + * Error state for start date + */ + startError?: boolean + /** + * Error state for end date + */ + endError?: boolean + /** + * Helper text for start date + */ + startHelperText?: string + /** + * Helper text for end date + */ + endHelperText?: string +} + +// Utility functions +const formatDateForInput = (date: Date | string | null): string => { + if (!date) return '' + + const dateObj = typeof date === 'string' ? new Date(date) : date + + if (isNaN(dateObj.getTime())) return '' + + return dateObj.toISOString().split('T')[0] +} + +const parseDateFromInput = (dateString: string): Date | null => { + if (!dateString) return null + + const date = new Date(dateString) + return isNaN(date.getTime()) ? null : date +} + +const DateRangePicker: React.FC = ({ + startDate, + endDate, + onStartDateChange, + onEndDateChange, + startLabel, + endLabel, + startPlaceholder, + endPlaceholder, + size = 'small', + disabled = false, + required = false, + className = '', + containerStyle = {}, + separator = '-', + startTextFieldProps = {}, + endTextFieldProps = {}, + startError = false, + endError = false, + startHelperText, + endHelperText +}) => { + const theme = useTheme() + + const defaultTextFieldSx = { + '& .MuiOutlinedInput-root': { + '&.Mui-focused fieldset': { + borderColor: 'primary.main' + }, + '& fieldset': { + borderColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.22)' : theme.palette.divider + } + } + } + + const handleStartDateChange = (event: React.ChangeEvent) => { + const date = parseDateFromInput(event.target.value) + onStartDateChange(date) + } + + const handleEndDateChange = (event: React.ChangeEvent) => { + const date = parseDateFromInput(event.target.value) + onEndDateChange(date) + } + + return ( +
+ + + + {separator} + + + +
+ ) +} + +export default DateRangePicker + +// Export utility functions for external use +export { formatDateForInput, parseDateFromInput } + +// Example usage: +/* +import DateRangePicker from '@/components/DateRangePicker' + +// In your component: +const [startDate, setStartDate] = useState(new Date()) +const [endDate, setEndDate] = useState(new Date()) + +// Basic usage + + +// With labels and validation + endDate} + endError={startDate && endDate && startDate > endDate} + startHelperText={startDate && endDate && startDate > endDate ? "Tanggal mulai tidak boleh lebih besar dari tanggal selesai" : ""} +/> + +// Custom styling + + +// Integration with your existing filter logic +const handleDateRangeChange = (start: Date | null, end: Date | null) => { + setFilter({ + ...filter, + date_from: start ? formatDateDDMMYYYY(start) : null, + date_to: end ? formatDateDDMMYYYY(end) : null + }) +} + + handleDateRangeChange(date, filter.date_to ? new Date(filter.date_to) : null)} + onEndDateChange={(date) => handleDateRangeChange(filter.date_from ? new Date(filter.date_from) : null, date)} +/> +*/ diff --git a/src/components/StatusFilterTab.tsx b/src/components/StatusFilterTab.tsx new file mode 100644 index 0000000..861d2ae --- /dev/null +++ b/src/components/StatusFilterTab.tsx @@ -0,0 +1,249 @@ +// React Imports +import React, { useState } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import { styled } from '@mui/material/styles' + +const DropdownButton = styled(Button)(({ theme }) => ({ + textTransform: 'none', + fontWeight: 400, + borderRadius: '8px', + borderColor: '#e0e0e0', + color: '#666', + '&:hover': { + borderColor: '#ccc', + backgroundColor: 'rgba(0, 0, 0, 0.04)' + } +})) + +interface StatusFilterTabsProps { + /** + * Array of status options to display as filter tabs + */ + statusOptions: string[] + /** + * Currently selected status filter + */ + selectedStatus: string + /** + * Callback function when a status is selected + */ + onStatusChange: (status: string) => void + /** + * Custom className for the container + */ + className?: string + /** + * Custom styles for the container + */ + containerStyle?: React.CSSProperties + /** + * Size of the buttons + */ + buttonSize?: 'small' | 'medium' | 'large' + /** + * Maximum number of status options to show as buttons before switching to dropdown + */ + maxButtonsBeforeDropdown?: number + /** + * Label for the dropdown when there are many options + */ + dropdownLabel?: string +} + +const StatusFilterTabs: React.FC = ({ + statusOptions, + selectedStatus, + onStatusChange, + className = '', + containerStyle = {}, + buttonSize = 'small', + maxButtonsBeforeDropdown = 5, + dropdownLabel = 'Lainnya' +}) => { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + + const handleDropdownClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleDropdownClose = () => { + setAnchorEl(null) + } + + const handleDropdownItemClick = (status: string) => { + onStatusChange(status) + handleDropdownClose() + } + + // If status options are <= maxButtonsBeforeDropdown, show all as buttons + if (statusOptions.length <= maxButtonsBeforeDropdown) { + return ( +
+ {statusOptions.map(status => ( + + ))} +
+ ) + } + + // If more than maxButtonsBeforeDropdown, show first few as buttons and rest in dropdown + const buttonStatuses = statusOptions.slice(0, maxButtonsBeforeDropdown - 1) + const dropdownStatuses = statusOptions.slice(maxButtonsBeforeDropdown - 1) + const isDropdownItemSelected = dropdownStatuses.includes(selectedStatus) + + return ( +
+ {/* Regular buttons for first few statuses */} + {buttonStatuses.map(status => ( + + ))} + + {/* Dropdown button for remaining statuses */} + } + sx={{ + ...(isDropdownItemSelected && { + backgroundColor: 'primary.main', + color: 'primary.contrastText', + borderColor: 'primary.main', + fontWeight: 600, + '&:hover': { + backgroundColor: 'primary.dark', + borderColor: 'primary.dark' + } + }) + }} + > + {isDropdownItemSelected ? selectedStatus : dropdownLabel} + + + + {dropdownStatuses.map(status => ( + handleDropdownItemClick(status)} + selected={selectedStatus === status} + sx={{ + fontSize: '14px', + fontWeight: selectedStatus === status ? 600 : 400, + color: selectedStatus === status ? 'primary.main' : 'text.primary' + }} + > + {status} + + ))} + +
+ ) +} + +export default StatusFilterTabs + +// Example usage: +/* +import StatusFilterTabs from '@/components/StatusFilterTabs' + +// In your component: +const [statusFilter, setStatusFilter] = useState('Semua') + +// For few statuses (will show all as buttons) +const expenseStatusOptions = ['Semua', 'Belum Dibayar', 'Dibayar Sebagian', 'Lunas'] + +// For many statuses (will show some buttons + dropdown) +const manyStatusOptions = [ + 'Semua', + 'Belum Dibayar', + 'Dibayar Sebagian', + 'Lunas', + 'Void', + 'Retur', + 'Jatuh Tempo', + 'Transaksi Berulang', + 'Ditangguhkan', + 'Dibatalkan' +] + + + +// With many options (will automatically use dropdown) + + +// Custom configuration + +*/ diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 56c1e2d..007da5d 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -91,14 +91,14 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].dailyReport} - }> + }> {dictionary['navigation'].overview} {dictionary['navigation'].invoices} {dictionary['navigation'].deliveries} {dictionary['navigation'].sales_orders} {dictionary['navigation'].quotes} - }> + }> {dictionary['navigation'].overview} {dictionary['navigation'].purchase_bills} @@ -113,6 +113,9 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].purchase_quotes} + }> + {dictionary['navigation'].list} + }> {dictionary['navigation'].list} @@ -160,7 +163,7 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].list} {/* {dictionary['navigation'].view} */} - }> + }> {dictionary['navigation'].list} {/* {dictionary['navigation'].view} */} diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 7c25d03..c9ab714 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -122,6 +122,7 @@ "invoices": "Invoices", "deliveries": "Deliveries", "sales_orders": "Orders", - "quotes": "Quotes" + "quotes": "Quotes", + "expenses": "Expenses" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index 486f051..5c4742b 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -122,6 +122,7 @@ "invoices": "Tagihan", "deliveries": "Pengiriman", "sales_orders": "Pemesanan", - "quotes": "Penawaran" + "quotes": "Penawaran", + "expenses": "Biaya" } } diff --git a/src/data/dummy/expense.ts b/src/data/dummy/expense.ts new file mode 100644 index 0000000..85c5ec1 --- /dev/null +++ b/src/data/dummy/expense.ts @@ -0,0 +1,224 @@ +import { ExpenseType } from '@/types/apps/expenseType' + +export const expenseData: ExpenseType[] = [ + { + id: 1, + date: '2025-01-05', + number: 'EXP/2025/001', + reference: 'REF-EXP-001', + benefeciaryName: 'Budi Santoso', + benefeciaryCompany: 'PT Maju Jaya', + status: 'Belum Dibayar', + balanceDue: 5000000, + total: 5000000 + }, + { + id: 2, + date: '2025-01-06', + number: 'EXP/2025/002', + reference: 'REF-EXP-002', + benefeciaryName: 'Siti Aminah', + benefeciaryCompany: 'CV Sentosa Abadi', + status: 'Dibayar Sebagian', + balanceDue: 2500000, + total: 6000000 + }, + { + id: 3, + date: '2025-01-07', + number: 'EXP/2025/003', + reference: 'REF-EXP-003', + benefeciaryName: 'Agus Prasetyo', + benefeciaryCompany: 'UD Makmur', + status: 'Lunas', + balanceDue: 0, + total: 4200000 + }, + { + id: 4, + date: '2025-01-08', + number: 'EXP/2025/004', + reference: 'REF-EXP-004', + benefeciaryName: 'Rina Wulandari', + benefeciaryCompany: 'PT Sejahtera Bersama', + status: 'Belum Dibayar', + balanceDue: 3200000, + total: 3200000 + }, + { + id: 5, + date: '2025-01-09', + number: 'EXP/2025/005', + reference: 'REF-EXP-005', + benefeciaryName: 'Dedi Kurniawan', + benefeciaryCompany: 'CV Bintang Terang', + status: 'Dibayar Sebagian', + balanceDue: 1500000, + total: 7000000 + }, + { + id: 6, + date: '2025-01-10', + number: 'EXP/2025/006', + reference: 'REF-EXP-006', + benefeciaryName: 'Sri Lestari', + benefeciaryCompany: 'PT Barokah Jaya', + status: 'Lunas', + balanceDue: 0, + total: 2800000 + }, + { + id: 7, + date: '2025-01-11', + number: 'EXP/2025/007', + reference: 'REF-EXP-007', + benefeciaryName: 'Joko Widodo', + benefeciaryCompany: 'UD Sumber Rejeki', + status: 'Belum Dibayar', + balanceDue: 8000000, + total: 8000000 + }, + { + id: 8, + date: '2025-01-12', + number: 'EXP/2025/008', + reference: 'REF-EXP-008', + benefeciaryName: 'Maya Kartika', + benefeciaryCompany: 'PT Bumi Lestari', + status: 'Dibayar Sebagian', + balanceDue: 2000000, + total: 9000000 + }, + { + id: 9, + date: '2025-01-13', + number: 'EXP/2025/009', + reference: 'REF-EXP-009', + benefeciaryName: 'Eko Yulianto', + benefeciaryCompany: 'CV Cahaya Baru', + status: 'Lunas', + balanceDue: 0, + total: 3500000 + }, + { + id: 10, + date: '2025-01-14', + number: 'EXP/2025/010', + reference: 'REF-EXP-010', + benefeciaryName: 'Nina Rahmawati', + benefeciaryCompany: 'PT Gemilang', + status: 'Belum Dibayar', + balanceDue: 4700000, + total: 4700000 + }, + { + id: 11, + date: '2025-01-15', + number: 'EXP/2025/011', + reference: 'REF-EXP-011', + benefeciaryName: 'Andi Saputra', + benefeciaryCompany: 'CV Harmoni', + status: 'Dibayar Sebagian', + balanceDue: 1200000, + total: 6000000 + }, + { + id: 12, + date: '2025-01-16', + number: 'EXP/2025/012', + reference: 'REF-EXP-012', + benefeciaryName: 'Yuni Astuti', + benefeciaryCompany: 'PT Surya Abadi', + status: 'Lunas', + balanceDue: 0, + total: 5200000 + }, + { + id: 13, + date: '2025-01-17', + number: 'EXP/2025/013', + reference: 'REF-EXP-013', + benefeciaryName: 'Ridwan Hidayat', + benefeciaryCompany: 'UD Berkah', + status: 'Belum Dibayar', + balanceDue: 2900000, + total: 2900000 + }, + { + id: 14, + date: '2025-01-18', + number: 'EXP/2025/014', + reference: 'REF-EXP-014', + benefeciaryName: 'Ratna Sari', + benefeciaryCompany: 'PT Amanah Sentosa', + status: 'Dibayar Sebagian', + balanceDue: 1000000, + total: 4000000 + }, + { + id: 15, + date: '2025-01-19', + number: 'EXP/2025/015', + reference: 'REF-EXP-015', + benefeciaryName: 'Hendra Gunawan', + benefeciaryCompany: 'CV Murni', + status: 'Lunas', + balanceDue: 0, + total: 2500000 + }, + { + id: 16, + date: '2025-01-20', + number: 'EXP/2025/016', + reference: 'REF-EXP-016', + benefeciaryName: 'Mega Putri', + benefeciaryCompany: 'PT Citra Mandiri', + status: 'Belum Dibayar', + balanceDue: 6100000, + total: 6100000 + }, + { + id: 17, + date: '2025-01-21', + number: 'EXP/2025/017', + reference: 'REF-EXP-017', + benefeciaryName: 'Bayu Saputra', + benefeciaryCompany: 'UD Lancar Jaya', + status: 'Dibayar Sebagian', + balanceDue: 1700000, + total: 5000000 + }, + { + id: 18, + date: '2025-01-22', + number: 'EXP/2025/018', + reference: 'REF-EXP-018', + benefeciaryName: 'Dian Anggraini', + benefeciaryCompany: 'CV Sumber Cahaya', + status: 'Lunas', + balanceDue: 0, + total: 3300000 + }, + { + id: 19, + date: '2025-01-23', + number: 'EXP/2025/019', + reference: 'REF-EXP-019', + benefeciaryName: 'Rizky Aditya', + benefeciaryCompany: 'PT Mandiri Abadi', + status: 'Belum Dibayar', + balanceDue: 7000000, + total: 7000000 + }, + { + id: 20, + date: '2025-01-24', + number: 'EXP/2025/020', + reference: 'REF-EXP-020', + benefeciaryName: 'Lina Marlina', + benefeciaryCompany: 'CV Anugerah', + status: 'Dibayar Sebagian', + balanceDue: 2000000, + total: 6500000 + } +] diff --git a/src/services/api.ts b/src/services/api.ts index 7dc7a13..beec3a6 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -33,6 +33,8 @@ api.interceptors.response.use( const currentPath = window.location.pathname if (status === 401 && !currentPath.endsWith('/login')) { + localStorage.removeItem('user') + localStorage.removeItem('authToken') window.location.href = '/login' } @@ -47,4 +49,3 @@ api.interceptors.response.use( return Promise.reject(error) } ) - diff --git a/src/types/apps/expenseType.ts b/src/types/apps/expenseType.ts new file mode 100644 index 0000000..0c7ef2f --- /dev/null +++ b/src/types/apps/expenseType.ts @@ -0,0 +1,11 @@ +export type ExpenseType = { + id: number + date: string + number: string + reference: string + benefeciaryName: string + benefeciaryCompany: string + status: string + balanceDue: number + total: number +} diff --git a/src/views/apps/expense/list/ExpenseListCard.tsx b/src/views/apps/expense/list/ExpenseListCard.tsx new file mode 100644 index 0000000..e828dcc --- /dev/null +++ b/src/views/apps/expense/list/ExpenseListCard.tsx @@ -0,0 +1,51 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' + +// Types Imports +import type { CardStatsHorizontalWithAvatarProps } from '@/types/pages/widgetTypes' + +// Component Imports +import CardStatsHorizontalWithAvatar from '@components/card-statistics/HorizontalWithAvatar' + +const data: CardStatsHorizontalWithAvatarProps[] = [ + { + stats: '10.495.100', + title: 'Bulan Ini', + avatarIcon: 'tabler-calendar-month', + avatarColor: 'warning' + }, + { + stats: '25.868.800', + title: '30 Hari Lalu', + avatarIcon: 'tabler-calendar-time', + avatarColor: 'error' + }, + { + stats: '17.903.400', + title: 'Belum Dibayar', + avatarIcon: 'tabler-clock-exclamation', + avatarColor: 'warning' + }, + { + stats: '13.467.200', + title: 'Jatuh Tempo', + avatarIcon: 'tabler-calendar-due', + avatarColor: 'success' + } +] + +const ExpenseListCard = () => { + return ( + data && ( + + {data.map((item, index) => ( + + + + ))} + + ) + ) +} + +export default ExpenseListCard diff --git a/src/views/apps/expense/list/ExpenseListTable.tsx b/src/views/apps/expense/list/ExpenseListTable.tsx new file mode 100644 index 0000000..54a2495 --- /dev/null +++ b/src/views/apps/expense/list/ExpenseListTable.tsx @@ -0,0 +1,519 @@ +'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 { ExpenseType } from '@/types/apps/expenseType' +import { expenseData } from '@/data/dummy/expense' +import StatusFilterTabs from '@/components/StatusFilterTab' +import DateRangePicker from '@/components/RangeDatePicker' + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type ExpenseTypeWithAction = ExpenseType & { + actions?: string +} + +// 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)} /> +} + +// Status color mapping for Expense +const getStatusColor = (status: string) => { + switch (status) { + case 'Belum Dibayar': + return 'error' + case 'Dibayar Sebagian': + return 'warning' + case 'Lunas': + return 'success' + default: + return 'default' + } +} + +// Format currency +const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(amount) +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const ExpenseListTable = () => { + const dispatch = useDispatch() + + // States + const [addExpenseOpen, setAddExpenseOpen] = useState(false) + const [rowSelection, setRowSelection] = useState({}) + const [currentPage, setCurrentPage] = useState(0) + const [pageSize, setPageSize] = useState(10) + const [openConfirm, setOpenConfirm] = useState(false) + const [expenseId, setExpenseId] = useState('') + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('Semua') + const [filteredData, setFilteredData] = useState(expenseData) + const [startDate, setStartDate] = useState(new Date()) + const [endDate, setEndDate] = useState(new Date()) + + // Hooks + const { lang: locale } = useParams() + + // Filter data based on search and status + useEffect(() => { + let filtered = expenseData + + // Filter by search + if (search) { + filtered = filtered.filter( + expense => + expense.number.toLowerCase().includes(search.toLowerCase()) || + expense.benefeciaryName.toLowerCase().includes(search.toLowerCase()) || + expense.benefeciaryCompany.toLowerCase().includes(search.toLowerCase()) || + expense.status.toLowerCase().includes(search.toLowerCase()) || + expense.reference.toLowerCase().includes(search.toLowerCase()) + ) + } + + // Filter by status + if (statusFilter !== 'Semua') { + filtered = filtered.filter(expense => expense.status === statusFilter) + } + + setFilteredData(filtered) + setCurrentPage(0) + }, [search, statusFilter]) + + const totalCount = filteredData.length + const paginatedData = useMemo(() => { + const startIndex = currentPage * pageSize + return filteredData.slice(startIndex, startIndex + pageSize) + }, [filteredData, currentPage, pageSize]) + + // Calculate subtotal and total from filtered data + const subtotalBalanceDue = useMemo(() => { + return filteredData.reduce((sum, expense) => sum + expense.balanceDue, 0) + }, [filteredData]) + + const subtotalTotal = useMemo(() => { + return filteredData.reduce((sum, expense) => sum + expense.total, 0) + }, [filteredData]) + + // For demonstration, adding tax/additional fees to show difference between subtotal and total + const taxAmount = subtotalTotal * 0.1 // 10% tax example + const finalTotal = subtotalTotal + taxAmount + + 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) + } + + const handleExpenseClick = (expenseId: string) => { + console.log('Navigasi ke detail Expense:', expenseId) + } + + const handleStatusFilter = (status: string) => { + setStatusFilter(status) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('number', { + header: 'Nomor Expense', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('benefeciaryName', { + header: 'Beneficiary', + cell: ({ row }) => ( +
+ + {row.original.benefeciaryName} + + + {row.original.benefeciaryCompany} + +
+ ) + }), + columnHelper.accessor('reference', { + header: 'Referensi', + cell: ({ row }) => {row.original.reference || '-'} + }), + columnHelper.accessor('date', { + header: 'Tanggal', + cell: ({ row }) => {row.original.date} + }), + columnHelper.accessor('status', { + header: 'Status', + cell: ({ row }) => ( +
+ +
+ ) + }), + columnHelper.accessor('balanceDue', { + header: 'Sisa Tagihan', + cell: ({ row }) => ( + 0 ? 'text-red-600' : 'text-green-600'}`}> + {formatCurrency(row.original.balanceDue)} + + ) + }), + columnHelper.accessor('total', { + header: 'Total', + cell: ({ row }) => {formatCurrency(row.original.total)} + }) + ], + [] + ) + + const table = useReactTable({ + data: paginatedData as ExpenseType[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + + {/* Filter Status Tabs and Range Date */} +
+
+ + +
+
+ +
+ setSearch(value as string)} + placeholder='Cari Expense' + 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 ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + {/* Subtotal Row */} + + + + + + + + + + + + {/* Total Row */} + + + + + + + + + + + + )} +
+ {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())}
+ + Subtotal + + + + {formatCurrency(subtotalBalanceDue)} + + + + {formatCurrency(subtotalTotal)} + +
+ + Total + + + + {formatCurrency(subtotalBalanceDue + taxAmount)} + + + + {formatCurrency(finalTotal)} + +
+
+ + ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + /> +
+ + ) +} + +export default ExpenseListTable diff --git a/src/views/apps/expense/list/index.tsx b/src/views/apps/expense/list/index.tsx new file mode 100644 index 0000000..6d14bf5 --- /dev/null +++ b/src/views/apps/expense/list/index.tsx @@ -0,0 +1,23 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import ExpenseListTable from './ExpenseListTable' +import ExpenseListCard from './ExpenseListCard' + +// Type Imports + +// Component Imports + +const ExpenseList = () => { + return ( + + + + + + + + + ) +} + +export default ExpenseList