530 lines
15 KiB
TypeScript
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 { AccountIconWidget } from "../widgets/AccountIconWidget";
|
|
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" }}>
|
|
<AccountIconWidget account={account} />
|
|
|
|
<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>
|
|
</>
|
|
);
|
|
}
|