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(); 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 ( { setIncludeAttached(value); }} /> } label="Include attached" /> } >
( { setEntries((entries) => { return entries?.filter((m) => m.id !== del.id); }); }} onReload={reload} showMovements={includeAttached} /> )} /> reload(false)} />
); } 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({ type: "include", ids: new Set() }); const [attaching, setAttaching] = React.useState(); // 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(); const [attachMultipleMovements, setAttachMultipleMovements] = React.useState(); 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:
    {deletedEntries.map((m) => (
  • {m.label ?? "Label unspecified"} ({m.amount ?? 0} €)
  • ))}
)) ) 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 ; }, }, { 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 ; else return Unspecified; }, }, { field: "file", headerName: "File", editable: false, flex: 3, renderCell: (params) => { return ; }, }, { field: "movement_id", headerName: "Movement", editable: false, flex: 3, renderCell: (params) => { if (params.row.movement_id) return ; }, }, { field: "actions", type: "actions", headerName: "", width: 82, cellClassName: "actions", editable: false, getActions: (params) => { return [ } label="Attach entry to movement" color="inherit" onClick={() => { handleAttachClick(params.row); }} disabled={!!params.row.movement_id} /> , , ]; }, }, ]; return ( <> {attaching && ( )} {attachMultipleEntries && attachMultipleMovements && ( )}
{ setLabelFilter(e.target.value); }} style={{ padding: "0px", flex: 1 }} /> { p.onReload(false); }} >
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); } }} />
); } function InboxEntryActionMenu(p: { entry: InboxEntry; onDelete: (entry: InboxEntry) => void; onDetach: (entry: InboxEntry) => void; }): React.ReactElement { const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; return ( <> {/* Unlink entry */} {p.entry.movement_id && ( { handleClose(); p.onDetach(p.entry); }} > Detach from movement )} {/* Delete */} { handleClose(); p.onDelete(p.entry); }} > Delete ); }