mirror of
https://github.com/linuxserver/budge.git
synced 2026-03-09 00:08:38 +08:00
1261 lines
40 KiB
JavaScript
1261 lines
40 KiB
JavaScript
import React, { useState, useMemo } from 'react'
|
|
import { useDispatch, useSelector } from 'react-redux'
|
|
import {
|
|
accountsSelectors,
|
|
fetchAccounts,
|
|
createTransaction,
|
|
deleteTransaction,
|
|
deleteTransactions,
|
|
updateTransaction,
|
|
updateTransactions,
|
|
} from '../../redux/slices/Accounts'
|
|
import { createPayee, fetchPayees, selectPayeesMap } from '../../redux/slices/Payees'
|
|
import { formatMonthFromDateString } from '../../utils/Date'
|
|
import { ExportCsv } from '../../utils/Export'
|
|
import { refreshBudget, fetchAvailableMonths } from '../../redux/slices/Budgets'
|
|
import { fetchBudgetMonth, fetchCategoryMonths } from '../../redux/slices/BudgetMonths'
|
|
import TextField from '@mui/material/TextField'
|
|
import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'
|
|
import IconButton from '@mui/material/IconButton'
|
|
import AccessTimeIcon from '@mui/icons-material/AccessTime'
|
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
|
import LockIcon from '@mui/icons-material/Lock'
|
|
import { FromAPI, intlFormat, valueToDinero } from '../../utils/Currency'
|
|
import { dinero, isPositive } from 'dinero.js'
|
|
import Tooltip from '@mui/material/Tooltip'
|
|
import { useTheme } from '@mui/styles'
|
|
import { payeesSelectors } from '../../redux/slices/Payees'
|
|
import { createSelector } from '@reduxjs/toolkit'
|
|
import { categoriesSelectors } from '../../redux/slices/Categories'
|
|
import SaveAltIcon from '@mui/icons-material/SaveAlt'
|
|
import Stack from '@mui/material/Stack'
|
|
import Box from '@mui/material/Box'
|
|
import Typography from '@mui/material/Typography'
|
|
import Button from '@mui/material/Button'
|
|
import ButtonGroup from '@mui/material/ButtonGroup'
|
|
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
|
import EditIcon from '@mui/icons-material/Edit'
|
|
import { bindTrigger, bindMenu } from 'material-ui-popup-state'
|
|
import { usePopupState } from 'material-ui-popup-state/hooks'
|
|
import Menu from '@mui/material/Menu'
|
|
import MenuItem from '@mui/material/MenuItem'
|
|
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'
|
|
import CheckBoxIcon from '@mui/icons-material/CheckBox'
|
|
import { styled } from '@mui/material/styles'
|
|
import UploadIcon from '@mui/icons-material/Upload'
|
|
import ImportCSV from '../ImportCSV'
|
|
import { useGlobalFilter, useRowSelect, useSortBy, useTable, useAsyncDebounce } from 'react-table'
|
|
import Checkbox from '@mui/material/Checkbox'
|
|
import Table from '@mui/material/Table'
|
|
import TableCell from '@mui/material/TableCell'
|
|
import TableHead from '@mui/material/TableHead'
|
|
import TableRow from '@mui/material/TableRow'
|
|
import TableSortLabel from '@mui/material/TableSortLabel'
|
|
import AccountTableBody from './AccountTableBody'
|
|
import AccountAmountCell from './AccountAmountCell'
|
|
import { makeStyles } from '@mui/styles'
|
|
import clsx from 'clsx'
|
|
import { ROW_HEIGHT } from './constants'
|
|
import SaveIcon from '@mui/icons-material/Save'
|
|
import CancelIcon from '@mui/icons-material/Cancel'
|
|
import TransactionDatePicker from './TransactionDatePicker'
|
|
|
|
export const useStyles = makeStyles(theme => ({
|
|
hideIconPadding: {
|
|
'& .MuiSelect-outlined': {
|
|
paddingRight: '0px',
|
|
},
|
|
},
|
|
|
|
smallerSelect: {
|
|
'& .MuiOutlinedInput-root': { padding: '1px 0 0 0 !important', 'margin-top': '1px' },
|
|
},
|
|
|
|
rootFirstSelect: {
|
|
padding: '4px 0px',
|
|
},
|
|
rootSecondSelect: {
|
|
padding: '10px 80px',
|
|
},
|
|
|
|
root: {
|
|
display: 'block',
|
|
flex: 1,
|
|
},
|
|
table: {
|
|
width: '100%',
|
|
backgroundColor: theme.palette.background.tableBody,
|
|
display: 'grid',
|
|
gridTemplateColums: '1fr',
|
|
gridTemplateRows: 'auto 1fr auto',
|
|
height: '100vh',
|
|
},
|
|
list: {},
|
|
thead: {},
|
|
tbody: {
|
|
width: '100%',
|
|
},
|
|
row: {
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
flexWrap: 'nowrap',
|
|
alignItems: 'center',
|
|
boxSizing: 'border-box',
|
|
minWidth: '100%',
|
|
width: '100%',
|
|
},
|
|
headerRow: {},
|
|
sortType: 'defaultSort',
|
|
cell: {
|
|
display: 'block',
|
|
flexGrow: 0,
|
|
flexShrink: 0,
|
|
padding: 4,
|
|
},
|
|
sortType: 'defaultSort',
|
|
textCell: {
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
sortType: 'defaultSort',
|
|
expandingCell: {
|
|
flex: 1,
|
|
},
|
|
column: {},
|
|
}))
|
|
|
|
function customFilter(rows, columnIds, filterValue) {
|
|
const matchRegex = new RegExp(filterValue, 'i')
|
|
return rows.filter(row => {
|
|
if (row.id === 0) {
|
|
return true
|
|
}
|
|
|
|
const rowValue = row.values.filterField
|
|
|
|
return rowValue !== undefined ? String(rowValue).match(matchRegex) !== null : true
|
|
})
|
|
}
|
|
|
|
// Create an editable cell renderer
|
|
const EditableCell = ({
|
|
value: initialValue,
|
|
row: { index },
|
|
column: { id },
|
|
updateMyData, // This is a custom function that we supplied to our table instance
|
|
}) => {
|
|
// We need to keep and update the state of the cell normally
|
|
const [value, setValue] = React.useState(initialValue)
|
|
|
|
const onChange = e => {
|
|
setValue(e.target.value)
|
|
}
|
|
|
|
// We'll only update the external data when the input is blurred
|
|
const onBlur = () => {
|
|
// updateMyData(index, id, value)
|
|
}
|
|
|
|
// If the initialValue is changed externall, sync it up with our state
|
|
React.useEffect(() => {
|
|
setValue(initialValue)
|
|
}, [initialValue])
|
|
|
|
return <input /*style={inputStyle}*/ value={value} onChange={onChange} onBlur={onBlur} />
|
|
}
|
|
|
|
const IndeterminateCheckbox = React.forwardRef(({ indeterminate, ...rest }, ref) => {
|
|
const defaultRef = React.useRef()
|
|
const resolvedRef = ref || defaultRef
|
|
|
|
React.useEffect(() => {
|
|
resolvedRef.current.indeterminate = indeterminate
|
|
}, [resolvedRef, indeterminate])
|
|
|
|
const onClick = e => {
|
|
e.stopPropagation()
|
|
}
|
|
|
|
return <Checkbox ref={resolvedRef} {...rest} size="small" sx={{ p: 0, ...rest.sx }} onClick={onClick} />
|
|
})
|
|
|
|
const BudgetTableCell = styled(TableCell)(({ theme }) => ({
|
|
paddingTop: '4px',
|
|
paddingBottom: '4px',
|
|
}))
|
|
|
|
function StatusIconButton(props) {
|
|
const handleClick = e => {
|
|
e.stopPropagation()
|
|
props.setTransactionStatus(props.rowData)
|
|
}
|
|
|
|
return (
|
|
<Tooltip title={props.tooltipText}>
|
|
<IconButton
|
|
size="small"
|
|
style={{ padding: 0 }}
|
|
aria-label="transaction status"
|
|
onClick={handleClick}
|
|
color="inherit"
|
|
>
|
|
{props.statusIcon}
|
|
</IconButton>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
function AccountFilter({ globalFilter, setGlobalFilter }) {
|
|
const theme = useTheme()
|
|
const [value, setValue] = useState(globalFilter)
|
|
const onChange = useAsyncDebounce(value => {
|
|
setGlobalFilter(value || undefined)
|
|
}, 500)
|
|
|
|
return (
|
|
<TextField
|
|
sx={{ minWidth: 125, m: 0.5 }}
|
|
placeholder="Search Transactions"
|
|
margin="dense"
|
|
InputProps={{
|
|
style: {
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
},
|
|
}}
|
|
inputProps={{
|
|
sx: {
|
|
p: 0,
|
|
px: 1,
|
|
},
|
|
}}
|
|
value={value || ''}
|
|
onChange={e => {
|
|
setValue(e.target.value)
|
|
onChange(e.target.value)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export default function Account(props) {
|
|
const classes = useStyles()
|
|
|
|
// const whyDidYouRender = true
|
|
let tableProps = null
|
|
|
|
const theme = useTheme()
|
|
const dispatch = useDispatch()
|
|
|
|
const account = props.account
|
|
const accounts = useSelector(accountsSelectors.selectAll)
|
|
const currentTheme = useSelector(state => state.app.theme)
|
|
|
|
const [editingRow, setEditingRow] = useState(0)
|
|
|
|
const [bulkEnabled, setBulkEnabled] = useState(false)
|
|
const [showReconciled, setShowReconciled] = useState(false)
|
|
const toggleReconciled = () => {
|
|
setShowReconciled(!showReconciled)
|
|
}
|
|
|
|
const [importerOpen, setImporterOpen] = useState(false)
|
|
const openImporter = () => {
|
|
setImporterOpen(true)
|
|
}
|
|
const closeImporter = () => {
|
|
setImporterOpen(false)
|
|
}
|
|
|
|
const bulkMenuPopupState = usePopupState({
|
|
variant: 'popover',
|
|
popupId: 'bulkActions',
|
|
})
|
|
|
|
const budgetId = useSelector(state => state.budgets.activeBudgetId)
|
|
|
|
const payeeIds = useSelector(payeesSelectors.selectIds)
|
|
const categoryIds = useSelector(categoriesSelectors.selectIds)
|
|
|
|
const selectCategoriesMap = createSelector(categoriesSelectors.selectAll, categories => {
|
|
return categories.reduce(
|
|
(acc, category) => {
|
|
acc[category.id] = category.name
|
|
return acc
|
|
},
|
|
{ 0: 'Category Not Needed' },
|
|
)
|
|
})
|
|
const categoriesMap = useSelector(selectCategoriesMap)
|
|
|
|
const payeesMap = useSelector(selectPayeesMap)
|
|
|
|
const [addingTransaction, setAddingTransaction] = useState(false)
|
|
|
|
const selectTransactions = createSelector(
|
|
[
|
|
(state, accountId) =>
|
|
state.accounts.entities[accountId] ? state.accounts.entities[accountId].transactions.entities : [],
|
|
(state, accountId, reconciled) => reconciled,
|
|
// (state, accountId, reconciled, addingTransaction) => addingTransaction,
|
|
],
|
|
(transactions, reconciled) => {
|
|
const retval = []
|
|
// if (addingTransaction) {
|
|
// const now = new Date().toISOString()
|
|
// retval.push({
|
|
// id: 0,
|
|
// accountId: props.accountId,
|
|
// amount: 0,
|
|
// categoryId: '',
|
|
// created: now,
|
|
// date: now,
|
|
// filterField: '',
|
|
// memo: '',
|
|
// payeeId: '',
|
|
// status: 0,
|
|
// updated: now,
|
|
// })
|
|
// }
|
|
|
|
if (!showReconciled) {
|
|
return retval.concat(
|
|
Object.values(transactions)
|
|
.filter(transaction => transaction.status !== 2)
|
|
.map(trx => {
|
|
return {
|
|
...trx,
|
|
...(trx.categoryId === null && { categoryId: '0' }),
|
|
filterField: `${categoriesMap[trx.categoryId]} ${payeesMap[trx.payeeId]}`,
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
return retval.concat(
|
|
Object.values(transactions).map(trx => {
|
|
return {
|
|
...trx,
|
|
...(trx.categoryId === null && { categoryId: '0' }),
|
|
filterField: `${categoriesMap[trx.categoryId]} ${payeesMap[trx.payeeId]}`,
|
|
}
|
|
}),
|
|
)
|
|
},
|
|
)
|
|
// const transactions = useSelector(state => selectTransactions(state, props.accountId, showReconciled))
|
|
|
|
const selectTransactionsData = createSelector(
|
|
[selectTransactions, (state, accountId, reconciled, addingTransaction) => addingTransaction],
|
|
(transactions, addingTransaction) => {
|
|
if (addingTransaction) {
|
|
const now = new Date().toISOString()
|
|
transactions.push({
|
|
id: 0,
|
|
accountId: props.accountId,
|
|
amount: 0,
|
|
categoryId: '',
|
|
created: now,
|
|
date: now,
|
|
filterField: '',
|
|
memo: '',
|
|
payeeId: '',
|
|
status: 0,
|
|
updated: now,
|
|
})
|
|
}
|
|
|
|
return transactions
|
|
},
|
|
)
|
|
|
|
const transactions = useSelector(state =>
|
|
selectTransactionsData(state, props.accountId, showReconciled, addingTransaction),
|
|
)
|
|
const data = useMemo(() => transactions, [transactions, account])
|
|
|
|
const cancelAddTransaction = () => {
|
|
if (addingTransaction === true) {
|
|
setAddingTransaction(false)
|
|
setEditingRow(0)
|
|
}
|
|
}
|
|
|
|
const filter = createFilterOptions()
|
|
const columns = useMemo(
|
|
() =>
|
|
[
|
|
{
|
|
accessor: 'filterField',
|
|
},
|
|
{
|
|
title: 'Date',
|
|
accessor: 'date',
|
|
Header: 'DATE',
|
|
width: 135,
|
|
sortType: 'defaultSort',
|
|
Cell: props => {
|
|
return <Box>{new Date(props.cell.value).toLocaleDateString()}</Box>
|
|
},
|
|
Editing: props => <TransactionDatePicker {...props} />,
|
|
exportTransformer: value => new Date(value).toLocaleDateString(),
|
|
},
|
|
{
|
|
title: 'Payee',
|
|
accessor: 'payeeId',
|
|
Header: 'PAYEE',
|
|
width: 200,
|
|
style: {
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
sortType: 'defaultSort',
|
|
Cell: props => <Box>{payeesMap[props.cell.value]}</Box>,
|
|
Editing: props => (
|
|
<Autocomplete
|
|
{...props}
|
|
autoSelect={true}
|
|
sx={{
|
|
[`& .MuiOutlinedInput-root`]: { p: 0, pt: '1px' },
|
|
[`& #payee-text-field.MuiOutlinedInput-input`]: { px: 1, py: 0 },
|
|
}}
|
|
options={payeeIds}
|
|
getOptionLabel={option => {
|
|
if (payeesMap[option]) {
|
|
return payeesMap[option]
|
|
}
|
|
|
|
return option
|
|
}}
|
|
renderInput={params => (
|
|
<TextField
|
|
{...params}
|
|
label=""
|
|
InputProps={{
|
|
style: {
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
py: 0,
|
|
px: 1,
|
|
},
|
|
...params.InputProps,
|
|
}}
|
|
inputProps={{
|
|
...params.inputProps,
|
|
id: 'payee-text-field',
|
|
}}
|
|
/>
|
|
)}
|
|
value={props.value}
|
|
onInputChange={(e, value) => {
|
|
props.onChange(value)
|
|
}}
|
|
onChange={(e, value) => {
|
|
const transferAccount = accounts.filter(account => account.transferPayeeId === value)
|
|
const updateProps = {}
|
|
if (transferAccount.length === 1 && transferAccount[0].type !== 2) {
|
|
updateProps.categoryId = '0'
|
|
} else if (props.rowData.categoryId === 'Category Not Needed') {
|
|
updateProps.categoryId = Object.keys(categoriesMap)[1]
|
|
}
|
|
|
|
const newRow = {
|
|
...props.rowData,
|
|
payeeId: value,
|
|
...updateProps,
|
|
}
|
|
return props.onRowDataChange(newRow)
|
|
}}
|
|
noOptionsText={`"${props.value} will be created`}
|
|
/>
|
|
),
|
|
exportTransformer: value => payeesMap[value],
|
|
},
|
|
...(account.type !== 2
|
|
? [
|
|
{
|
|
title: 'Evenlope',
|
|
accessor: 'categoryId',
|
|
Header: 'ENVELOPE',
|
|
style: {
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
sortType: 'defaultSort',
|
|
Cell: props => <Box>{categoriesMap[props.cell.value]}</Box>,
|
|
Editing: props => {
|
|
const disabled =
|
|
props.rowData.categoryId === '0' || props.rowData.categoryId === 'Category Not Needed'
|
|
const options = { ...categoriesMap }
|
|
if (disabled === false) {
|
|
delete options['0']
|
|
}
|
|
|
|
return (
|
|
<Autocomplete
|
|
{...props}
|
|
autoSelect={true}
|
|
disablePortal
|
|
disabled={disabled}
|
|
options={categoryIds}
|
|
getOptionLabel={option => {
|
|
if (categoriesMap[option]) {
|
|
return categoriesMap[option]
|
|
}
|
|
|
|
return option
|
|
}}
|
|
sx={{
|
|
[`& .MuiOutlinedInput-root`]: { p: 0, pt: '1px' },
|
|
[`& #envelope-text-field.MuiOutlinedInput-input`]: { px: 1, py: 0 },
|
|
}}
|
|
renderInput={params => (
|
|
<TextField
|
|
{...params}
|
|
InputProps={{
|
|
style: {
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
py: 0,
|
|
px: 1,
|
|
},
|
|
...params.InputProps,
|
|
}}
|
|
inputProps={{
|
|
...params.inputProps,
|
|
id: 'envelope-text-field',
|
|
}}
|
|
/>
|
|
)}
|
|
value={props.value}
|
|
onInputChange={(e, value) => {
|
|
props.onChange(value)
|
|
}}
|
|
onChange={(e, value) => {
|
|
return props.onRowDataChange({
|
|
...props.rowData,
|
|
categoryId: value,
|
|
})
|
|
}}
|
|
/>
|
|
)
|
|
},
|
|
exportTransformer: value => categoriesMap[value],
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
title: 'Memo',
|
|
accessor: 'memo',
|
|
Header: 'MEMO',
|
|
style: {
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
sortType: 'defaultSort',
|
|
Cell: props => (
|
|
<Tooltip title={props.cell.value}>
|
|
<span
|
|
style={{
|
|
display: 'block',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{props.cell.value}
|
|
</span>
|
|
</Tooltip>
|
|
),
|
|
Editing: props => {
|
|
return (
|
|
<TextField
|
|
{...props}
|
|
// variant="standard"
|
|
size="small"
|
|
fullWidth
|
|
inputProps={{
|
|
sx: {
|
|
py: 0,
|
|
px: 1,
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
},
|
|
}}
|
|
InputProps={{
|
|
sx: {
|
|
paddingBottom: '1px',
|
|
},
|
|
}}
|
|
value={props.value}
|
|
onChange={e => {
|
|
props.onChange(e.target.value)
|
|
}}
|
|
/>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: 'Amount',
|
|
accessor: 'amount',
|
|
Header: 'AMOUNT',
|
|
numeric: true,
|
|
width: 120,
|
|
sortType: 'defaultSort',
|
|
Cell: props => {
|
|
const trx = FromAPI.transformTransaction(props.row.original)
|
|
const color = isPositive(trx.amount) ? 'success' : 'error'
|
|
return (
|
|
<Typography
|
|
sx={{
|
|
fontWeight: 'bold',
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
color: theme.palette[color].main,
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{intlFormat(trx.amount)}
|
|
</Typography>
|
|
)
|
|
},
|
|
Editing: props => <AccountAmountCell {...props} />,
|
|
exportTransformer: value => intlFormat(valueToDinero(value)),
|
|
},
|
|
{
|
|
title: 'Status',
|
|
accessor: 'status',
|
|
field: 'status',
|
|
width: 50,
|
|
numeric: true,
|
|
Header: props => (
|
|
<Box sx={{ mr: 1 }}>
|
|
<Tooltip title="Status">
|
|
<AccessTimeIcon color="disabled" fontSize="small" />
|
|
</Tooltip>
|
|
</Box>
|
|
),
|
|
disableSortBy: true,
|
|
Cell: props => {
|
|
let statusIcon = <></>
|
|
let tooltipText = ''
|
|
switch (props.cell.value) {
|
|
case 0: // pending
|
|
tooltipText = 'Pending'
|
|
statusIcon = <AccessTimeIcon color="disabled" fontSize="small" />
|
|
break
|
|
case 1: // cleared
|
|
tooltipText = 'Cleared'
|
|
statusIcon = <CheckCircleOutlineIcon color="success" fontSize="small" />
|
|
break
|
|
case 2: // reconciled
|
|
tooltipText = 'Reconciled'
|
|
statusIcon = <LockIcon color="success" fontSize="small" />
|
|
break
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ mr: 1 }}>
|
|
<StatusIconButton
|
|
tooltipText={tooltipText}
|
|
rowData={props.row.original}
|
|
setTransactionStatus={setTransactionStatus}
|
|
statusIcon={statusIcon}
|
|
/>
|
|
</Box>
|
|
)
|
|
},
|
|
Editing: props => (
|
|
<Box sx={{ mr: 0 }}>
|
|
<IconButton
|
|
size="small"
|
|
style={{ padding: 0 }}
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
props.save()
|
|
}}
|
|
>
|
|
<SaveIcon fontSize="small" />
|
|
</IconButton>
|
|
<IconButton size="small" style={{ padding: 0 }} onClick={props.cancel}>
|
|
<CancelIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
),
|
|
exportTransformer: value => {
|
|
switch (value) {
|
|
case 0:
|
|
return 'Pending'
|
|
case 1:
|
|
return 'Cleared'
|
|
case 2:
|
|
return 'Reconciled'
|
|
}
|
|
},
|
|
},
|
|
].map(col => {
|
|
col.cellStyle = {
|
|
paddingTop: 0,
|
|
paddingBottom: 0,
|
|
}
|
|
return col
|
|
}),
|
|
[currentTheme, payeesMap, account.type],
|
|
)
|
|
|
|
const filterTypes = useMemo(
|
|
() => ({
|
|
customFilter: customFilter,
|
|
// Or, override the default text filter to use
|
|
// "startWith"
|
|
text: customFilter,
|
|
}),
|
|
[],
|
|
)
|
|
|
|
const sortTypes = useMemo(
|
|
() => ({
|
|
defaultSort: (rowA, rowB, columnId, desc) => {
|
|
if (rowA.id === 0) {
|
|
return desc ? 1 : -1
|
|
} else if (rowB.id === 0) {
|
|
return desc ? -1 : 1
|
|
}
|
|
|
|
return rowA[columnId] < rowB[columnId] ? -1 : 1
|
|
},
|
|
}),
|
|
[],
|
|
)
|
|
|
|
const {
|
|
getTableProps,
|
|
getTableBodyProps,
|
|
headerGroups,
|
|
prepareRow,
|
|
rows,
|
|
preGlobalFilteredRows,
|
|
setGlobalFilter,
|
|
toggleRowSelected,
|
|
state: { selectedRowIds, globalFilter },
|
|
} = useTable(
|
|
{
|
|
columns,
|
|
data,
|
|
defaultColumn: {
|
|
width: null,
|
|
},
|
|
autoResetSortBy: false,
|
|
// autoResetPage: false,
|
|
autoResetGlobalFilter: false,
|
|
autoResetSelectedRows: false,
|
|
|
|
initialState: {
|
|
hiddenColumns: ['filterField'],
|
|
sortBy: [{ id: 'date', desc: true }],
|
|
},
|
|
|
|
getRowId: (row, relativeIndex, parent) => row.id,
|
|
|
|
// updateMyData isn't part of the API, but
|
|
// anything we put into these options will
|
|
// automatically be available on the instance.
|
|
// That way we can call this function from our
|
|
// cell renderer!
|
|
// updateMyData,
|
|
|
|
filterTypes,
|
|
sortTypes,
|
|
globalFilter: 'customFilter',
|
|
},
|
|
useGlobalFilter,
|
|
useSortBy,
|
|
useRowSelect,
|
|
hooks => {
|
|
hooks.allColumns.push(columns => [
|
|
// Let's make a column for selection
|
|
{
|
|
id: 'selection',
|
|
width: 5,
|
|
// The header can use the table's getToggleAllRowsSelectedProps method
|
|
// to render a checkbox. Pagination is a problem since this will select all
|
|
// rows even though not all rows are on the current page. The solution should
|
|
// be server side pagination. For one, the clients should not download all
|
|
// rows in most cases. The client should only download data for the current page.
|
|
// In that case, getToggleAllRowsSelectedProps works fine.
|
|
Header: ({ getToggleAllRowsSelectedProps }) => (
|
|
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} sx={{ pb: 2 }} />
|
|
),
|
|
// The cell can use the individual row's getToggleRowSelectedProps method
|
|
// to the render a checkbox
|
|
sortType: 'defaultSort',
|
|
Cell: ({ row }) => <IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />,
|
|
Editing: ({ row }) => <IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />,
|
|
},
|
|
...columns,
|
|
])
|
|
},
|
|
)
|
|
|
|
const createNewPayee = async name => {
|
|
name = name.replace(/^New: /, '')
|
|
return (
|
|
await dispatch(
|
|
createPayee({
|
|
name,
|
|
}),
|
|
)
|
|
).payload
|
|
}
|
|
|
|
const onTransactionAdd = async newRow => {
|
|
let refreshPayees = false
|
|
|
|
console.log(newRow)
|
|
if (!payeesMap[newRow.payeeId]) {
|
|
newRow.payeeId = (await createNewPayee(newRow.payeeId)).id
|
|
}
|
|
|
|
await dispatch(
|
|
createTransaction({
|
|
transaction: {
|
|
accountId: props.accountId,
|
|
amount: newRow.amount,
|
|
date: newRow.date,
|
|
memo: newRow.memo,
|
|
payeeId: newRow.payeeId,
|
|
categoryId: newRow.categoryId === '0' ? null : newRow.categoryId,
|
|
status: 0,
|
|
},
|
|
}),
|
|
)
|
|
|
|
await dispatch(fetchBudgetMonth({ month: formatMonthFromDateString(newRow.date) }))
|
|
dispatch(refreshBudget())
|
|
dispatch(fetchAvailableMonths())
|
|
dispatch(fetchAccounts())
|
|
|
|
if (newRow.categoryId && newRow.categoryId !== '0') {
|
|
dispatch(fetchCategoryMonths({ categoryId: newRow.categoryId }))
|
|
}
|
|
if (refreshPayees === true) {
|
|
dispatch(fetchPayees())
|
|
}
|
|
}
|
|
|
|
const setTransactionStatus = rowData => {
|
|
if (rowData.status === 2) {
|
|
// already reconciled
|
|
return
|
|
}
|
|
|
|
onTransactionEdit(
|
|
{
|
|
...rowData,
|
|
status: rowData.status === 0 ? 1 : 0,
|
|
amount: valueToDinero(rowData.amount),
|
|
},
|
|
{ ...rowData },
|
|
)
|
|
}
|
|
|
|
const onTransactionEdit = async (newRow, oldData) => {
|
|
let refreshPayees = false
|
|
|
|
if (!payeesMap[newRow.payeeId]) {
|
|
// @TODO: Fix : Because of the 'onInputChange' autocomplete, the edited value gets subbed out for the 'text' value. Make sure this doesn't truly already exist.
|
|
const payee = Object.keys(payeesMap).find(key => payeesMap[key] === newRow.payeeId)
|
|
if (!payee) {
|
|
refreshPayees = true
|
|
newRow.payeeId = (await createNewPayee(newRow.payeeId)).id
|
|
} else {
|
|
newRow.payeeId = payee
|
|
}
|
|
}
|
|
|
|
if (!categoriesMap[newRow.categoryId]) {
|
|
// @TODO: Fix : Because of the 'onInputChange' autocomplete, the edited value gets subbed out for the 'text' value. Make sure this doesn't truly already exist.
|
|
const category = Object.keys(categoriesMap).find(key => categoriesMap[key] === newRow.categoryId)
|
|
newRow.categoryId = category
|
|
}
|
|
|
|
await dispatch(
|
|
updateTransaction({
|
|
transaction: {
|
|
id: newRow.id,
|
|
accountId: props.accountId,
|
|
date: newRow.date,
|
|
memo: newRow.memo,
|
|
payeeId: newRow.payeeId,
|
|
categoryId: newRow.categoryId === '0' ? null : newRow.categoryId,
|
|
amount: newRow.amount,
|
|
status: newRow.status,
|
|
},
|
|
}),
|
|
)
|
|
|
|
if (refreshPayees === true) {
|
|
dispatch(fetchPayees())
|
|
}
|
|
|
|
dispatch(fetchBudgetMonth({ month: formatMonthFromDateString(newRow.date) }))
|
|
|
|
if (newRow.categoryId && newRow.categoryId !== '0') {
|
|
dispatch(fetchCategoryMonths({ categoryId: newRow.categoryId }))
|
|
}
|
|
|
|
if (oldData.categoryId !== newRow.categoryId) {
|
|
if (oldData.categoryId && oldData.categoryId !== '0') {
|
|
dispatch(fetchCategoryMonths({ categoryId: oldData.categoryId }))
|
|
dispatch(fetchBudgetMonth({ month: formatMonthFromDateString(oldData.date) }))
|
|
}
|
|
}
|
|
|
|
dispatch(refreshBudget())
|
|
dispatch(fetchAccounts())
|
|
|
|
if (oldData.date !== newRow.date) {
|
|
dispatch(fetchAvailableMonths())
|
|
}
|
|
}
|
|
|
|
const onTransactionDelete = async transaction => {
|
|
await dispatch(deleteTransaction({ transaction }))
|
|
|
|
await dispatch(fetchBudgetMonth({ month: formatMonthFromDateString(transaction.date) }))
|
|
if (transaction.categoryId && transaction.categoryId !== '0') {
|
|
dispatch(fetchCategoryMonths({ categoryId: transaction.categoryId }))
|
|
}
|
|
dispatch(fetchAccounts())
|
|
dispatch(refreshBudget())
|
|
}
|
|
|
|
const getSelectedRows = () => {
|
|
const selectedIds = Object.keys(selectedRowIds)
|
|
return transactions.filter(trx => selectedIds.includes(trx.id))
|
|
}
|
|
|
|
const afterBulkAction = async transactions => {
|
|
const months = new Set(transactions.map(transaction => formatMonthFromDateString(transaction.date)))
|
|
const categoryIds = new Set(transactions.map(transaction => transaction.categoryId))
|
|
|
|
Promise.all([...months].map(month => dispatch(fetchBudgetMonth({ month }))))
|
|
Promise.all(
|
|
[...categoryIds].map(categoryId => {
|
|
if (!categoryId || categoryId === '0') {
|
|
return
|
|
}
|
|
return dispatch(fetchCategoryMonths({ categoryId }))
|
|
}),
|
|
)
|
|
|
|
dispatch(fetchAccounts())
|
|
dispatch(refreshBudget())
|
|
}
|
|
|
|
const deleteSelected = async () => {
|
|
bulkMenuPopupState.close()
|
|
const transactions = getSelectedRows()
|
|
await dispatch(deleteTransactions({ accountId: account.id, transactions }))
|
|
|
|
afterBulkAction(transactions)
|
|
}
|
|
|
|
const markSelectedTransactionsCleared = async () => {
|
|
bulkMenuPopupState.close()
|
|
await bulkEditTransactions(
|
|
getSelectedRows().map(row => ({
|
|
...row,
|
|
status: 1,
|
|
amount: valueToDinero(row.amount),
|
|
})),
|
|
)
|
|
}
|
|
|
|
const markSelectedTransactionsUncleared = async () => {
|
|
bulkMenuPopupState.close()
|
|
await bulkEditTransactions(
|
|
getSelectedRows().map(row => ({
|
|
...row,
|
|
status: 0,
|
|
amount: valueToDinero(row.amount),
|
|
})),
|
|
)
|
|
}
|
|
|
|
const bulkEditTransactions = async transactions => {
|
|
transactions = transactions.map(transaction => {
|
|
return {
|
|
id: transaction.id,
|
|
accountId: props.accountId,
|
|
date: transaction.date,
|
|
memo: transaction.memo,
|
|
payeeId: transaction.payeeId,
|
|
categoryId: transaction.categoryId === '0' ? null : transaction.categoryId,
|
|
amount: transaction.amount,
|
|
status: transaction.status,
|
|
}
|
|
})
|
|
|
|
await dispatch(
|
|
updateTransactions({
|
|
accountId: account.id,
|
|
transactions,
|
|
}),
|
|
)
|
|
|
|
afterBulkAction(transactions)
|
|
}
|
|
|
|
/**
|
|
* This was pulled (and modified) from m-table-body.js. The formatting for the default 'export' action
|
|
* didn't match other actions, so had to resort to creating this myself.
|
|
*/
|
|
const getTableData = () => {
|
|
const cols = columns
|
|
.filter(
|
|
columnDef => (!columnDef.hidden || columnDef.export === true) && columnDef.field && columnDef.export !== false,
|
|
)
|
|
.sort((a, b) => (a.tableData.columnOrder > b.tableData.columnOrder ? 1 : -1))
|
|
const data = (props.exportAllData ? transactions : tableProps.renderData).map(rowData =>
|
|
cols.map(columnDef => {
|
|
/*
|
|
About: column.customExport
|
|
This bit of code checks if prop customExport in column is a function, and if it is then it
|
|
uses that function to transform the data, this is useful in cases where a column contains
|
|
complex objects or array and it needs to be handled before it's passed to the exporter
|
|
to avoid [object Object] output (e.g. to flatten data).
|
|
Please note that it is also possible to transform data within under exportMenu
|
|
using a custom function (exportMenu.exportFunc) for each exporter.
|
|
*/
|
|
if (typeof columnDef.customExport === 'function') {
|
|
return columnDef.customExport(rowData)
|
|
}
|
|
|
|
return tableProps.getFieldValue(rowData, columnDef)
|
|
}),
|
|
)
|
|
|
|
return [cols, data]
|
|
}
|
|
|
|
const exportData = () => {
|
|
ExportCsv(columns, rows, `${account.name} Transactions`)
|
|
}
|
|
|
|
const onSelectionChange = data => {
|
|
// setBulkEnabled(data.length > 0)
|
|
}
|
|
|
|
const addTransactionClick = () => {
|
|
setAddingTransaction(true)
|
|
setEditingRow(0)
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
backgroundColor: theme.palette.background.tableBody,
|
|
display: 'grid',
|
|
gridTemplateColums: '1fr',
|
|
gridTemplateRows: 'auto 1fr auto',
|
|
height: '100vh',
|
|
}}
|
|
>
|
|
<ImportCSV accountId={props.accountId} open={importerOpen} close={closeImporter}></ImportCSV>
|
|
|
|
{/* <TableToolbar
|
|
numSelected={Object.keys(selectedRowIds).length}
|
|
// deleteUserHandler={deleteUserHandler}
|
|
// addUserHandler={addUserHandler}
|
|
preGlobalFilteredRows={preGlobalFilteredRows}
|
|
setGlobalFilter={setGlobalFilter}
|
|
globalFilter={globalFilter}
|
|
/> */}
|
|
<Table {...getTableProps()} className={classes.table} component="div">
|
|
<TableHead
|
|
component="div"
|
|
className={classes.thead}
|
|
sx={{
|
|
backgroundColor: theme.palette.background.tableHeader,
|
|
}}
|
|
>
|
|
<TableRow component="div" sx={{ display: 'block', backgroundColor: theme.palette.background.tableHeader }}>
|
|
<TableCell colSpan={7} component="div" className={clsx(classes.row, classes.headerRow)} sx={{ p: 0 }}>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ width: '100%' }}>
|
|
<Stack
|
|
direction="row"
|
|
alignItems="center"
|
|
sx={
|
|
{
|
|
// backgroundColor: theme.palette.background.header,
|
|
}
|
|
}
|
|
>
|
|
<ButtonGroup variant="text" aria-label="outlined button group">
|
|
<Button color="primary" size="small" onClick={addTransactionClick}>
|
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
<AddCircleIcon
|
|
style={{
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
}}
|
|
/>
|
|
<Typography style={{ fontSize: theme.typography.caption.fontSize, fontWeight: 'bold' }}>
|
|
Add Transaction
|
|
</Typography>
|
|
</Stack>
|
|
</Button>
|
|
|
|
<Button size="small" onClick={openImporter}>
|
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
<UploadIcon
|
|
style={{
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
}}
|
|
/>
|
|
<Typography style={{ fontSize: theme.typography.caption.fontSize, fontWeight: 'bold' }}>
|
|
Import
|
|
</Typography>
|
|
</Stack>
|
|
</Button>
|
|
|
|
<Button size="small" onClick={exportData}>
|
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
<SaveAltIcon
|
|
style={{
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
}}
|
|
/>
|
|
<Typography style={{ fontSize: theme.typography.caption.fontSize, fontWeight: 'bold' }}>
|
|
Export
|
|
</Typography>
|
|
</Stack>
|
|
</Button>
|
|
|
|
<Button
|
|
size="small"
|
|
onClick={toggleReconciled}
|
|
{...bindTrigger(bulkMenuPopupState)}
|
|
// disabled={!bulkEnabled}
|
|
>
|
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
<EditIcon
|
|
style={{
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
}}
|
|
/>
|
|
<Typography style={{ fontSize: theme.typography.caption.fontSize, fontWeight: 'bold' }}>
|
|
Edit
|
|
</Typography>
|
|
</Stack>
|
|
</Button>
|
|
<Menu {...bindMenu(bulkMenuPopupState)}>
|
|
<MenuItem
|
|
// disabled={selectedRows.length === 0}
|
|
onClick={markSelectedTransactionsCleared}
|
|
>
|
|
Mark Cleared
|
|
</MenuItem>
|
|
|
|
<MenuItem
|
|
// disabled={selectedRows.length === 0}
|
|
onClick={markSelectedTransactionsUncleared}
|
|
>
|
|
Mark Uncleared
|
|
</MenuItem>
|
|
|
|
<MenuItem
|
|
// disabled={selectedRows.length === 0}
|
|
onClick={deleteSelected}
|
|
>
|
|
Delete Transactions
|
|
</MenuItem>
|
|
</Menu>
|
|
|
|
<Button size="small" onClick={toggleReconciled}>
|
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
{showReconciled && (
|
|
<CheckBoxIcon
|
|
style={{
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
}}
|
|
/>
|
|
)}
|
|
{!showReconciled && (
|
|
<CheckBoxOutlineBlankIcon
|
|
style={{
|
|
fontSize: theme.typography.subtitle2.fontSize,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<Typography style={{ fontSize: theme.typography.caption.fontSize, fontWeight: 'bold' }}>
|
|
Reconciled
|
|
</Typography>
|
|
</Stack>
|
|
</Button>
|
|
</ButtonGroup>
|
|
</Stack>
|
|
|
|
<Box>
|
|
<AccountFilter globalFilter={globalFilter} setGlobalFilter={setGlobalFilter} />
|
|
</Box>
|
|
</Stack>
|
|
</TableCell>
|
|
</TableRow>
|
|
|
|
{headerGroups.map(headerGroup => (
|
|
<TableRow
|
|
{...headerGroup.getHeaderGroupProps()}
|
|
component="div"
|
|
className={clsx(classes.row, classes.headerRow)}
|
|
>
|
|
{headerGroup.headers.map(column => (
|
|
<TableCell
|
|
{...(column.id === 'selection'
|
|
? column.getHeaderProps()
|
|
: column.getHeaderProps(column.getSortByToggleProps()))}
|
|
component="div"
|
|
variant="head"
|
|
align={column.numeric || false ? 'right' : 'left'}
|
|
className={clsx(classes.cell, classes.column, !column.width && classes.expandingCell)}
|
|
style={{
|
|
flexBasis: column.width || false,
|
|
height: ROW_HEIGHT,
|
|
...(column.style && column.style),
|
|
fontSize: theme.typography.caption.fontSize,
|
|
fontWeight: 'bold',
|
|
}}
|
|
scope="col"
|
|
>
|
|
{column.render('Header')}
|
|
{column.id !== 'selection' ? (
|
|
<TableSortLabel
|
|
active={column.isSorted}
|
|
// react-table has a unsorted state which is not treated here
|
|
direction={column.isSortedDesc ? 'desc' : 'asc'}
|
|
/>
|
|
) : null}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHead>
|
|
|
|
<AccountTableBody
|
|
{...getTableBodyProps()}
|
|
classes={classes}
|
|
rows={rows}
|
|
prepareRow={prepareRow}
|
|
onRowSave={onTransactionEdit}
|
|
toggleRowSelected={toggleRowSelected}
|
|
selectedRowIds={selectedRowIds}
|
|
cancelAddTransaction={cancelAddTransaction}
|
|
onTransactionAdd={onTransactionAdd}
|
|
editingRow={editingRow}
|
|
setEditingRow={setEditingRow}
|
|
/>
|
|
</Table>
|
|
</Box>
|
|
)
|
|
}
|