Files
MoneyMgr/moneymgr_web/src/routes/InboxRoute.tsx
Pierre HUBERT 3c5c82371a
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Fix mui-grid issue after update
2025-05-15 21:51:12 +02:00

612 lines
17 KiB
TypeScript

import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import LinkOffIcon from "@mui/icons-material/LinkOff";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import RefreshIcon from "@mui/icons-material/Refresh";
import SearchIcon from "@mui/icons-material/Search";
import {
Checkbox,
FormControlLabel,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
TextField,
Tooltip,
} from "@mui/material";
import {
DataGrid,
GridActionsCellItem,
GridColDef,
GridRowSelectionModel,
} from "@mui/x-data-grid";
import React from "react";
import { InboxApi, InboxEntry } from "../api/InboxApi";
import { Movement, MovementApi } from "../api/MovementsApi";
import { AttachInboxEntryToMovementDialog } from "../dialogs/AttachInboxEntryToMovementDialog";
import { AttachMultipleInboxEntriesDialog } from "../dialogs/AttachMultipleInboxEntriesDialog";
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 { useUnmatchedInboxEntriesCount } from "../hooks/UnmatchedInboxEntriesCountProvider";
import { AmountWidget } from "../widgets/AmountWidget";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { DateWidget } from "../widgets/DateWidget";
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
import { AsyncMovementWidget } from "../widgets/MovementWidget";
import { NewMovementWidget } from "../widgets/NewMovementWidget";
import { UploadedFileWidget } from "../widgets/UploadedFileWidget";
export function InboxRoute(): React.ReactElement {
const loadingMessage = useLoadingMessage();
const alert = useAlert();
const unmatched = useUnmatchedInboxEntriesCount();
const [entries, setEntries] = React.useState<InboxEntry[] | undefined>();
const [includeAttached, setIncludeAttached] = React.useState(false);
const loadKey = React.useRef(1);
const load = async () => {
setEntries(await InboxApi.GetList(includeAttached));
};
const reload = async (skipEntries: boolean) => {
try {
loadingMessage.show("Refreshing the list of inbox entries...");
unmatched.reload();
if (!skipEntries) await load();
} catch (e) {
console.error("Failed to load list of inbox entries!", e);
alert(`Failed to refresh the list of inbox entries! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<MoneyMgrWebRouteContainer
label={"Inbox"}
actions={
<span>
<FormControlLabel
checked={includeAttached}
control={
<Checkbox
onChange={(_e, value) => {
setIncludeAttached(value);
}}
/>
}
label="Include attached"
/>
</span>
}
>
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<AsyncWidget
loadKey={`${loadKey.current}/${includeAttached}`}
load={load}
errMsg="Failed to load the content of inbox!"
build={() => (
<InboxTable
entries={entries!}
onDeleteEntry={(del) => {
setEntries((entries) => {
return entries?.filter((m) => m.id !== del.id);
});
}}
onReload={reload}
showMovements={includeAttached}
/>
)}
/>
<NewMovementWidget isInbox onCreated={() => reload(false)} />
</div>
</div>
</MoneyMgrWebRouteContainer>
);
}
function InboxTable(p: {
entries: InboxEntry[];
onDeleteEntry: (entry: InboxEntry) => void;
onReload: (skipEntries: boolean) => void;
showMovements?: boolean;
}): React.ReactElement {
const alert = useAlert();
const confirm = useConfirm();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const selectAccount = useSelectAccount();
const [labelFilter, setLabelFilter] = React.useState("");
const filteredList = React.useMemo(() => {
return p.entries.filter((m) =>
(m.label ?? "").toLowerCase().includes(labelFilter.toLowerCase())
);
}, [p.entries, labelFilter]);
const [rowSelectionModel, setRowSelectionModel] =
React.useState<GridRowSelectionModel>({ type: "include", ids: new Set() });
const [attaching, setAttaching] = React.useState<InboxEntry | undefined>();
// Request to attach entry to movement
const handleAttachClick = (entry: InboxEntry) => {
setAttaching(entry);
};
const handleCloseAttachDialog = () => {
setAttaching(undefined);
};
// Finish attach entry to movement
const performAttach = async (movement: Movement) => {
try {
loadingMessage.show("Attaching inbox entry to movement...");
await Promise.all([
// Update movement
MovementApi.Update({
...movement,
file_id: attaching?.file_id,
}),
// Update inbox entries
InboxApi.Update({
...attaching!,
movement_id: movement.id,
}),
]);
snackbar(
"The inbox entry has been successfully attached to the movement!"
);
setAttaching(undefined);
p.onReload(false);
} catch (e) {
console.error("Failed to attach inbox entry to movement!", e);
alert(`Failed to attach inbox entry to movement! ${e}`);
} finally {
loadingMessage.hide();
}
};
// Delete inbox entry
const handleDeleteClick = async (entry: InboxEntry) => {
try {
if (
!(await confirm(
`Do you really want to delete this inbox entry ${
entry.label ?? ""
} (${entry.amount ?? 0} €)?`
))
)
return;
await InboxApi.Delete(entry);
p.onDeleteEntry(entry);
p.onReload(true);
} catch (e) {
console.error("Failed to delete entry!", e);
alert(`Failed to delete entry! ${e}`);
}
};
// Detach inbox entry from movement
const handleDetachClick = async (entry: InboxEntry) => {
try {
if (
!(await confirm(
`Do you really want to detach this inbox entry ${
entry.label ?? ""
} (${entry.amount ?? 0} €) from its movement?`,
"Detach inbox entry from movement",
"Detach"
))
)
return;
entry.movement_id = undefined;
await InboxApi.Update(entry);
p.onReload(true);
} catch (e) {
console.error("Failed to detach movement from entry!", e);
alert(`Failed to detach movement from entry! ${e}`);
}
};
// Attach multiple entries to movements
const [attachMultipleEntries, setAttachMultipleEntries] =
React.useState<InboxEntry[]>();
const [attachMultipleMovements, setAttachMultipleMovements] =
React.useState<Movement[][]>();
const attachMultiple = async () => {
try {
const targetAccount = await selectAccount(
"Attach multiple entries",
"Select the target account for automatic attaching",
"Start search"
);
if (!targetAccount) return;
// Find the entry to map
const entries = p.entries.filter(
(m) => rowSelectionModel.ids.has(m.id) && !m.movement_id
);
const movements: Movement[][] = [];
// Search for applicable movements
for (const [num, e] of entries.entries()) {
loadingMessage.show(
`Searching for proper movements ${num}/${entries.length}`
);
movements.push(
await MovementApi.GetAccountMovements(targetAccount.id, {
amount_min: e.amount ? e.amount - 0.5 : undefined,
amount_max: e.amount ? e.amount + 0.5 : undefined,
label: e.label,
limit: 5,
time_min: e.time ? e.time - 3600 * 24 : undefined,
time_max: e.time ? e.time + 3600 * 24 : undefined,
})
);
}
setAttachMultipleEntries(entries);
setAttachMultipleMovements(movements);
} catch (e) {
console.error("Failed to attach multiple accounts to movements!", e);
alert(`Failed to attach multiple accounts to movements! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleCancelAttachMultiple = () => {
setAttachMultipleEntries(undefined);
setAttachMultipleMovements(undefined);
};
const handlePerformAttachMultiple = async (
mapping: (Movement | undefined)[]
) => {
try {
for (const [num, entry] of attachMultipleEntries!.entries()) {
loadingMessage.show(`Attaching ${num} of ${mapping.length}`);
const movement = mapping[num];
if (!movement) continue;
await Promise.all([
// Update movement
MovementApi.Update({
...movement,
file_id: entry.file_id,
}),
// Update inbox entries
InboxApi.Update({
...entry,
movement_id: movement.id,
}),
]);
}
setAttachMultipleEntries(undefined);
setAttachMultipleMovements(undefined);
snackbar("Mapping successfully performed!");
p.onReload(false);
} catch (e) {
console.error("Performing mapping...", e);
alert(`Failed to perform mapping! ${e}`);
} finally {
loadingMessage.hide();
}
};
// Delete multiple inbox entries
const deleteMultiple = async () => {
try {
const deletedEntries = p.entries.filter((m) =>
rowSelectionModel.ids.has(m.id)
);
if (
!(await confirm(
<>
Do you really want to delete the following inbox entries:
<ul>
{deletedEntries.map((m) => (
<li key={m.id}>
{m.label ?? "Label unspecified"} ({m.amount ?? 0} )
</li>
))}
</ul>
</>
))
)
return;
for (const [num, e] of deletedEntries.entries()) {
loadingMessage.show(
`Deleting inbox entry ${num}/${deletedEntries.length}`
);
await InboxApi.Delete(e);
p.onDeleteEntry(e);
}
snackbar("The inbox entries have been successfully deleted!");
p.onReload(false);
} catch (e) {
console.error("Failed to delete multiple inbox entries!", e);
alert(`Failed to delete multiple inbox entries! ${e}`);
} finally {
loadingMessage.hide();
}
};
const columns: GridColDef<(typeof p.entries)[number]>[] = [
{
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: 3,
editable: true,
type: "string",
},
{
field: "amount",
headerName: "Amount",
width: 110,
editable: true,
type: "number",
align: "left",
headerAlign: "left",
renderCell: (params) => {
if (params.row.amount)
return <AmountWidget amount={params.row.amount} />;
else return <i>Unspecified</i>;
},
},
{
field: "file",
headerName: "File",
editable: false,
flex: 3,
renderCell: (params) => {
return <UploadedFileWidget file_id={params.row.file_id} />;
},
},
{
field: "movement_id",
headerName: "Movement",
editable: false,
flex: 3,
renderCell: (params) => {
if (params.row.movement_id)
return <AsyncMovementWidget id={params.row.movement_id} />;
},
},
{
field: "actions",
type: "actions",
headerName: "",
width: 82,
cellClassName: "actions",
editable: false,
getActions: (params) => {
return [
<Tooltip key="attach" title="Attach entry to movement">
<GridActionsCellItem
key={`attach-${params.row.id}`}
icon={<SearchIcon />}
label="Attach entry to movement"
color="inherit"
onClick={() => {
handleAttachClick(params.row);
}}
disabled={!!params.row.movement_id}
/>
</Tooltip>,
<InboxEntryActionMenu
key="menu"
entry={params.row}
onDelete={handleDeleteClick}
onDetach={handleDetachClick}
/>,
];
},
},
];
return (
<>
{attaching && (
<AttachInboxEntryToMovementDialog
open
entry={attaching}
onSelected={performAttach}
onClose={handleCloseAttachDialog}
/>
)}
{attachMultipleEntries && attachMultipleMovements && (
<AttachMultipleInboxEntriesDialog
open
entries={attachMultipleEntries}
movements={attachMultipleMovements}
onClose={handleCancelAttachMultiple}
onSelected={handlePerformAttachMultiple}
/>
)}
<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.onReload(false);
}}
>
<RefreshIcon />
</IconButton>
</Tooltip>
<Tooltip title="Attach all the selected inbox entries to movements">
<IconButton
disabled={rowSelectionModel.ids.size === 0}
onClick={attachMultiple}
>
<SearchIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete all the selected inbox entries">
<IconButton
disabled={
rowSelectionModel.ids.size === 0 ||
rowSelectionModel.ids.size === p.entries.length
}
onClick={deleteMultiple}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
<div style={{ flex: 1 }}>
<DataGrid<InboxEntry>
columns={columns}
rows={filteredList}
autoPageSize
checkboxSelection
initialState={{
sorting: {
sortModel: [{ field: "time", sort: "desc" }],
},
columns: {
columnVisibilityModel: {
movement_id: p.showMovements!,
},
},
}}
onRowSelectionModelChange={(newRowSelectionModel) => {
setRowSelectionModel(newRowSelectionModel);
}}
rowSelectionModel={rowSelectionModel}
processRowUpdate={async (n) => {
try {
return await InboxApi.Update(n);
} catch (e) {
console.error("Failed to update inbox entry information!", e);
alert(`Failed to update row! ${e}`);
throw e;
} finally {
p.onReload(true);
}
}}
/>
</div>
</>
);
}
function InboxEntryActionMenu(p: {
entry: InboxEntry;
onDelete: (entry: InboxEntry) => void;
onDetach: (entry: InboxEntry) => 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}>
{/* Unlink entry */}
{p.entry.movement_id && (
<MenuItem
onClick={() => {
handleClose();
p.onDetach(p.entry);
}}
>
<ListItemIcon>
<LinkOffIcon />
</ListItemIcon>
<ListItemText secondary={"Detach the entry from its movement"}>
Detach from movement
</ListItemText>
</MenuItem>
)}
{/* Delete */}
<MenuItem
onClick={() => {
handleClose();
p.onDelete(p.entry);
}}
>
<ListItemIcon>
<DeleteIcon color="error" />
</ListItemIcon>
<ListItemText secondary={"Delete the entry"}>Delete</ListItemText>
</MenuItem>
</Menu>
</>
);
}