MoneyMgr/moneymgr_web/src/routes/AccountRoute.tsx

530 lines
15 KiB
TypeScript

import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import DriveFileMoveOutlineIcon from "@mui/icons-material/DriveFileMoveOutline";
import LinkOffIcon from "@mui/icons-material/LinkOff";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
IconButton,
ListItemIcon,
ListItemText,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { DataGrid, GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import React from "react";
import { useParams } from "react-router-dom";
import { UploadedFile } from "../api/FileApi";
import { Movement, MovementApi } from "../api/MovementsApi";
import { useAccounts } from "../hooks/AccountsListProvider";
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
import { useSelectAccount } from "../hooks/context_providers/ChooseAccountDialogProvider";
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
import { AccountWidget } from "../widgets/AccountWidget";
import { AmountWidget } from "../widgets/AmountWidget";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { DateWidget } from "../widgets/DateWidget";
import { UploadFileButton } from "../widgets/forms/UploadFileButton";
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
import { NewMovementWidget } from "../widgets/NewMovementWidget";
import { UploadedFileWidget } from "../widgets/UploadedFileWidget";
import { NotFoundRoute } from "./NotFound";
export function AccountRoute(): React.ReactElement {
const loadingMessage = useLoadingMessage();
const { accountId } = useParams();
const loadKey = React.useRef(0);
const accounts = useAccounts();
const account = accounts.get(Number(accountId));
const [movements, setMovements] = React.useState<Movement[] | undefined>();
const load = async () => {
setMovements(await MovementApi.GetAccountMovements(account!.id));
};
const reload = async (skipMovements = false) => {
try {
accounts.reloadBalances();
if (!skipMovements) {
loadingMessage.show("Refreshing the list of movements");
await load();
}
} catch (e) {
console.error("Failed to load list of movements!", e);
alert(`Failed to refresh the list of movements! ${e}`);
} finally {
loadingMessage.hide();
}
};
if (account === null) return <NotFoundRoute />;
return (
<MoneyMgrWebRouteContainer
label={
<span style={{ display: "inline-flex", alignItems: "center" }}>
<AccountWidget account={account} />
&nbsp;
<span style={{ display: "inline-flex", flexDirection: "column" }}>
<span>{account.name}</span>
<Typography component={"span"} variant="subtitle1">
<AmountWidget amount={account.balance} />
</Typography>
</span>
</span>
}
>
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<AsyncWidget
loadKey={`${account.id}-${loadKey.current}`}
load={load}
ready={movements !== undefined}
errMsg="Failed to load the list of movements!"
build={() => (
<MovementsTable
needReload={reload}
movements={movements!}
onDeleteMovement={(del) => {
setMovements((movements) => {
return movements?.filter((m) => m.id !== del.id);
});
}}
/>
)}
/>
</div>
<NewMovementWidget account={account} onCreated={reload} />
</div>
</MoneyMgrWebRouteContainer>
);
}
function MovementsTable(p: {
movements: Movement[];
needReload: (skipMovements: boolean) => void;
onDeleteMovement: (movement: Movement) => void;
}): React.ReactElement {
const accounts = useAccounts();
const alert = useAlert();
const confirm = useConfirm();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const chooseAccount = useSelectAccount();
const [labelFilter, setLabelFilter] = React.useState("");
const filteredList = React.useMemo(() => {
return p.movements.filter((m) =>
m.label.toLowerCase().includes(labelFilter.toLowerCase())
);
}, [p.movements, labelFilter]);
const [rowSelectionModel, setRowSelectionModel] =
React.useState<GridRowSelectionModel>([]);
// Set uploaded file
const setUploadedFile = async (
m: Movement,
file: UploadedFile | undefined
) => {
try {
await MovementApi.Update({ ...m, file_id: file?.id ?? undefined });
p.needReload(false);
} catch (e) {
console.error("Failed to attach file to movement!", e);
alert("Failed to attach uploaded file to movement!");
}
};
// Change account of movement
const handleMoveClick = async (movement: Movement) => {
const targetAccount = await chooseAccount(
"Transfer movement",
`Please select the target account that will receive the movement: ${movement.label} (${movement.amount} €)`,
"Transfer movement",
[accounts.get(movement.account_id)!]
);
if (!targetAccount) return;
try {
movement.account_id = targetAccount.id;
await MovementApi.Update(movement);
snackbar("The movement has been successfully moved!");
p.needReload(false);
} catch (e) {
console.error("Failed to update movement information!", e);
alert(`Failed to change movemlent account! ${e}`);
}
};
// Detach file from movement
const handleDetachFile = async (movement: Movement) => {
try {
if (
!(await confirm(
`Do you really want to detach the file attached to the movement ${movement.label} (${movement.amount} €)? The associated file will be automatically deleted within the day if it is not referenced anywhere else!`,
`Detach file from movement`,
`Detach file`
))
)
return;
await setUploadedFile(movement, undefined);
} catch (e) {
console.error("Failed to detach file from movement!", e);
alert(`Failed to detach file from movement! ${e}`);
}
};
// Delete movement
const handleDeleteClick = async (movement: Movement) => {
try {
if (
!(await confirm(
`Do you really want to delete the movement ${movement.label} (${movement.amount} €)?`
))
)
return;
await MovementApi.Delete(movement);
p.onDeleteMovement(movement);
p.needReload(true);
} catch (e) {
console.error("Failed to delete movement!", e);
alert(`Failed to delete movement! ${e}`);
}
};
// Move multiple movements
const moveMultiple = async () => {
try {
const movements = p.movements.filter((m) =>
rowSelectionModel.includes(m.id)
);
const targetAccount = await chooseAccount(
"Transfer movements",
<>
Please select the target account that will receive the selected
movements:
<ul>
{movements.map((m) => (
<li key={m.id}>
{m.label} ({m.amount} )
</li>
))}
</ul>
</>,
"Transfer movement",
[accounts.get(movements[0].account_id)!]
);
if (!targetAccount) return;
for (const [num, m] of movements.entries()) {
loadingMessage.show(`Moveing movement ${num}/${movements.length}`);
m.account_id = targetAccount.id;
await MovementApi.Update(m);
}
snackbar("The movements have been successfully moved!");
p.needReload(false);
} catch (e) {
console.error("Failed to delete multiple movements!", e);
alert(`Failed to delete multiple movements! ${e}`);
} finally {
loadingMessage.hide();
}
};
// Delete multiple movements
const deleteMultiple = async () => {
try {
const movements = p.movements.filter((m) =>
rowSelectionModel.includes(m.id)
);
if (
!(await confirm(
<>
Do you really want to delete the following movements:
<ul>
{movements.map((m) => (
<li key={m.id}>
{m.label} ({m.amount} )
</li>
))}
</ul>
</>
))
)
return;
for (const [num, m] of movements.entries()) {
loadingMessage.show(`Deleting movement ${num}/${movements.length}`);
await MovementApi.Delete(m);
}
snackbar("The movements have been successfully deleted!");
p.needReload(false);
} catch (e) {
console.error("Failed to delete multiple movements!", e);
alert(`Failed to delete multiple movements! ${e}`);
} finally {
loadingMessage.hide();
}
};
const columns: GridColDef<(typeof p.movements)[number]>[] = [
{
field: "checked",
headerName: "Checked",
width: 50,
type: "boolean",
editable: true,
},
{
field: "time",
headerName: "Date",
width: 98 + 80,
editable: true,
type: "dateTime",
valueGetter(_, m) {
return new Date(m.time * 1000);
},
valueSetter(v, row) {
row.time = Math.floor(v.getTime() / 1000);
return row;
},
renderCell: (params) => {
return <DateWidget time={params.row.time} />;
},
},
{
field: "label",
headerName: "Label",
flex: 6,
editable: true,
type: "string",
},
{
field: "amount",
headerName: "Amount",
width: 110,
editable: true,
type: "number",
align: "left",
headerAlign: "left",
renderCell: (params) => {
return <AmountWidget amount={params.row.amount} />;
},
},
{
field: "file",
headerName: "File",
editable: false,
flex: 3,
renderCell: (params) => {
if (!params.row.file_id)
return (
<UploadFileButton
label="Attach file"
tooltip="Attach a file to this movement"
onUploaded={(f) => setUploadedFile(params.row, f)}
/>
);
else return <UploadedFileWidget file_id={params.row.file_id} />;
},
},
{
field: "actions",
type: "actions",
headerName: "",
width: 55,
cellClassName: "actions",
editable: false,
getActions: (params) => {
return [
<MovementActionMenu
key="menu"
movement={params.row}
onDelete={handleDeleteClick}
onMove={handleMoveClick}
onDetachFile={handleDetachFile}
/>,
];
},
},
];
return (
<>
<div style={{ display: "flex" }}>
<TextField
placeholder="Filter by label"
variant="standard"
size="small"
value={labelFilter}
onChange={(e) => {
setLabelFilter(e.target.value);
}}
style={{ padding: "0px", flex: 1 }}
/>
<span style={{ flex: 1 }}></span>
<Tooltip title="Refresh table">
<IconButton onClick={() => p.needReload(false)}>
<RefreshIcon />
</IconButton>
</Tooltip>
<Tooltip title="Move all the selected entries to another account">
<IconButton
disabled={
rowSelectionModel.length === 0 ||
rowSelectionModel.length === p.movements.length
}
onClick={moveMultiple}
>
<DriveFileMoveOutlineIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete all the selected entries">
<IconButton
disabled={
rowSelectionModel.length === 0 ||
rowSelectionModel.length === p.movements.length
}
onClick={deleteMultiple}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
<div style={{ flex: 1 }}>
<DataGrid<Movement>
columns={columns}
rows={filteredList}
autoPageSize
checkboxSelection
initialState={{
sorting: {
sortModel: [{ field: "time", sort: "desc" }],
},
columns: {
columnVisibilityModel: {
checked: false,
},
},
}}
onRowSelectionModelChange={(newRowSelectionModel) => {
setRowSelectionModel(newRowSelectionModel);
}}
rowSelectionModel={rowSelectionModel}
processRowUpdate={async (n) => {
try {
return await MovementApi.Update(n);
} catch (e) {
console.error("Failed to update movement information!", e);
alert(`Failed to update row! ${e}`);
throw e;
} finally {
p.needReload(true);
}
}}
/>
</div>
</>
);
}
function MovementActionMenu(p: {
movement: Movement;
onDetachFile: (m: Movement) => void;
onMove: (m: Movement) => void;
onDelete: (m: Movement) => void;
}): React.ReactElement {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
aria-label="Actions"
aria-haspopup="true"
onClick={handleClick}
>
<MoreVertIcon />
</IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
{/* Detach file */}
{p.movement.file_id && (
<MenuItem
onClick={() => {
handleClose();
p.onDetachFile(p.movement);
}}
>
<ListItemIcon>
<LinkOffIcon />
</ListItemIcon>
<ListItemText secondary={"Detach linked file"}>
Detach file
</ListItemText>
</MenuItem>
)}
{/* Move to another account */}
<MenuItem
onClick={() => {
handleClose();
p.onMove(p.movement);
}}
>
<ListItemIcon>
<DriveFileMoveOutlineIcon />
</ListItemIcon>
<ListItemText secondary={"Move to another account"}>
Move
</ListItemText>
</MenuItem>
{/* Delete */}
<MenuItem
onClick={() => {
handleClose();
p.onDelete(p.movement);
}}
>
<ListItemIcon>
<DeleteIcon color="error" />
</ListItemIcon>
<ListItemText secondary={"Delete the movement"}>Delete</ListItemText>
</MenuItem>
</Menu>
</>
);
}