612 lines
17 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|