Can attach multiple inbox entries to movements at once
This commit is contained in:
parent
5aa954dca2
commit
0f6447155b
4
moneymgr_web/package-lock.json
generated
4
moneymgr_web/package-lock.json
generated
@ -26,7 +26,8 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router": "^7.4.1",
|
"react-router": "^7.4.1",
|
||||||
"react-router-dom": "^7.4.1"
|
"react-router-dom": "^7.4.1",
|
||||||
|
"ts-pattern": "^5.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.23.0",
|
"@eslint/js": "^9.23.0",
|
||||||
@ -4575,7 +4576,6 @@
|
|||||||
"version": "5.7.0",
|
"version": "5.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.7.0.tgz",
|
||||||
"integrity": "sha512-0/FvIG4g3kNkYgbNwBBW5pZBkfpeYQnH+2AA3xmjkCAit/DSDPKmgwC3fKof4oYUq6gupClVOJlFl+939VRBMg==",
|
"integrity": "sha512-0/FvIG4g3kNkYgbNwBBW5pZBkfpeYQnH+2AA3xmjkCAit/DSDPKmgwC3fKof4oYUq6gupClVOJlFl+939VRBMg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
|
@ -28,7 +28,8 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router": "^7.4.1",
|
"react-router": "^7.4.1",
|
||||||
"react-router-dom": "^7.4.1"
|
"react-router-dom": "^7.4.1",
|
||||||
|
"ts-pattern": "^5.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.23.0",
|
"@eslint/js": "^9.23.0",
|
||||||
|
@ -1,25 +1,76 @@
|
|||||||
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
import { InboxEntry } from "../api/InboxApi";
|
import { InboxEntry } from "../api/InboxApi";
|
||||||
import { Movement } from "../api/MovementsApi";
|
import { Movement } from "../api/MovementsApi";
|
||||||
|
import { MovementSelect } from "../widgets/forms/MovementSelect";
|
||||||
|
import { InboxEntryWidget } from "../widgets/InboxEntryWidget";
|
||||||
|
|
||||||
export function AttachMultipleInboxEntriesDialog(p: {
|
export function AttachMultipleInboxEntriesDialog(p: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
entries: InboxEntry[];
|
entries: InboxEntry[];
|
||||||
movements: Movement[][];
|
movements: Movement[][];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelected: (mapping: (number | undefined)[]) => void;
|
onSelected: (mapping: (Movement | undefined)[]) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const handleSubmit = () => {};
|
const [mapping, setMapping] = React.useState<(Movement | undefined)[]>(
|
||||||
|
p.movements.map(() => undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
p.onSelected(mapping);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={p.open} onClose={p.onClose}>
|
<Dialog open={p.open} onClose={p.onClose} fullScreen>
|
||||||
<DialogTitle>Attach multiple entries to movements</DialogTitle>
|
<DialogTitle>Attach multiple entries to movements</DialogTitle>
|
||||||
<DialogContent>TODO</DialogContent>
|
<DialogContent>
|
||||||
|
{p.entries.map((entry, num) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "5px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
<InboxEntryWidget entry={entry} />
|
||||||
|
</span>
|
||||||
|
<ArrowForwardIcon />
|
||||||
|
<span
|
||||||
|
style={{ flex: 1, display: "flex", justifyContent: "end" }}
|
||||||
|
>
|
||||||
|
{p.movements[num].length > 0 ? (
|
||||||
|
<MovementSelect
|
||||||
|
list={p.movements[num]}
|
||||||
|
value={mapping[num]}
|
||||||
|
onChange={(v) =>
|
||||||
|
setMapping((m) => {
|
||||||
|
const n = [...m];
|
||||||
|
n[num] = v;
|
||||||
|
return n;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>No movement found</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={p.onClose}>Cancel</Button>
|
<Button onClick={p.onClose}>Cancel</Button>
|
||||||
<Button onClick={handleSubmit} autoFocus>
|
<Button onClick={handleSubmit} autoFocus>
|
||||||
|
@ -242,9 +242,13 @@ function InboxTable(p: {
|
|||||||
);
|
);
|
||||||
if (!targetAccount) return;
|
if (!targetAccount) return;
|
||||||
|
|
||||||
const entries = p.entries.filter((m) => rowSelectionModel.includes(m.id));
|
// Find the entry to map
|
||||||
|
const entries = p.entries.filter(
|
||||||
|
(m) => rowSelectionModel.includes(m.id) && !m.movement_id
|
||||||
|
);
|
||||||
const movements: Movement[][] = [];
|
const movements: Movement[][] = [];
|
||||||
|
|
||||||
|
// Search for applicable movements
|
||||||
for (const [num, e] of entries.entries()) {
|
for (const [num, e] of entries.entries()) {
|
||||||
loadingMessage.show(
|
loadingMessage.show(
|
||||||
`Searching for proper movements ${num}/${entries.length}`
|
`Searching for proper movements ${num}/${entries.length}`
|
||||||
@ -277,8 +281,43 @@ function InboxTable(p: {
|
|||||||
setAttachMultipleMovements(undefined);
|
setAttachMultipleMovements(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePerformAttachMultiple = (mapping: (number | undefined)[]) => {
|
const handlePerformAttachMultiple = async (
|
||||||
console.info(attachMultipleEntries, attachMultipleMovements, mapping);
|
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
|
// Delete multiple inbox entries
|
||||||
|
63
moneymgr_web/src/widgets/InboxEntryWidget.tsx
Normal file
63
moneymgr_web/src/widgets/InboxEntryWidget.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import CallMadeIcon from "@mui/icons-material/CallMade";
|
||||||
|
import CallReceivedIcon from "@mui/icons-material/CallReceived";
|
||||||
|
import QuestionMarkIcon from "@mui/icons-material/QuestionMark";
|
||||||
|
import React from "react";
|
||||||
|
import { match } from "ts-pattern";
|
||||||
|
import { InboxEntry } from "../api/InboxApi";
|
||||||
|
import { fmtDateFromTime } from "../utils/DateUtils";
|
||||||
|
import { AmountWidget } from "./AmountWidget";
|
||||||
|
import { UploadedFileWidget } from "./UploadedFileWidget";
|
||||||
|
|
||||||
|
export function InboxEntryWidget(p: { entry: InboxEntry }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* File */}
|
||||||
|
<UploadedFileWidget small file_id={p.entry.file_id} />
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{match(p.entry.amount)
|
||||||
|
.when(
|
||||||
|
(v) => v === undefined || v === null,
|
||||||
|
() => <QuestionMarkIcon color="secondary" />
|
||||||
|
)
|
||||||
|
.when(
|
||||||
|
(v) => v > 0,
|
||||||
|
() => <CallReceivedIcon color="success" />
|
||||||
|
)
|
||||||
|
.otherwise(() => (
|
||||||
|
<CallMadeIcon color="error" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "5px",
|
||||||
|
display: "inline-flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ height: "1em", lineHeight: 1 }}>
|
||||||
|
{(p.entry.label?.length ?? 0) > 0 ? p.entry.label : <i>No label</i>}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", lineHeight: 1 }}>
|
||||||
|
{p.entry.amount ? (
|
||||||
|
<AmountWidget amount={p.entry.amount} />
|
||||||
|
) : (
|
||||||
|
<i>No amount</i>
|
||||||
|
)}
|
||||||
|
<span style={{ width: "0.5em" }} />
|
||||||
|
•
|
||||||
|
<span style={{ width: "0.5em" }} />
|
||||||
|
{fmtDateFromTime(p.entry.time)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
@ -1,13 +1,16 @@
|
|||||||
import ImageIcon from "@mui/icons-material/Image";
|
import ImageIcon from "@mui/icons-material/Image";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
import { Button } from "@mui/material";
|
import { Button, IconButton } from "@mui/material";
|
||||||
import { filesize } from "filesize";
|
import { filesize } from "filesize";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { FileApi, UploadedFile } from "../api/FileApi";
|
import { FileApi, UploadedFile } from "../api/FileApi";
|
||||||
import { AsyncWidget } from "./AsyncWidget";
|
|
||||||
import { FileViewerDialog } from "../dialogs/FileViewerDialog";
|
import { FileViewerDialog } from "../dialogs/FileViewerDialog";
|
||||||
|
import { AsyncWidget } from "./AsyncWidget";
|
||||||
|
|
||||||
export function UploadedFileWidget(p: { file_id: number }): React.ReactElement {
|
export function UploadedFileWidget(p: {
|
||||||
|
file_id: number;
|
||||||
|
small?: boolean;
|
||||||
|
}): React.ReactElement {
|
||||||
const [file, setFile] = React.useState<UploadedFile | null>(null);
|
const [file, setFile] = React.useState<UploadedFile | null>(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@ -17,7 +20,7 @@ export function UploadedFileWidget(p: { file_id: number }): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<AsyncWidget
|
<AsyncWidget
|
||||||
errMsg="Failed"
|
errMsg="Failed"
|
||||||
build={() => <UploadedFileWidgetInner file={file!} />}
|
build={() => <UploadedFileWidgetInner file={file!} small={p.small} />}
|
||||||
loadKey={p.file_id}
|
loadKey={p.file_id}
|
||||||
load={load}
|
load={load}
|
||||||
/>
|
/>
|
||||||
@ -25,6 +28,7 @@ export function UploadedFileWidget(p: { file_id: number }): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UploadedFileWidgetInner(p: {
|
function UploadedFileWidgetInner(p: {
|
||||||
|
small?: boolean;
|
||||||
file: UploadedFile;
|
file: UploadedFile;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
@ -33,20 +37,36 @@ function UploadedFileWidgetInner(p: {
|
|||||||
<FileViewerDialog
|
<FileViewerDialog
|
||||||
open={open}
|
open={open}
|
||||||
file={p.file}
|
file={p.file}
|
||||||
onClose={() => { setOpen(false); }}
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
{p.small ? (
|
||||||
startIcon={
|
<IconButton
|
||||||
p.file.mime_type === "application/pdf" ? (
|
onClick={() => {
|
||||||
<PictureAsPdfIcon />
|
setOpen(true);
|
||||||
) : (
|
}}
|
||||||
<ImageIcon />
|
>
|
||||||
)
|
<FileIcon file={p.file} />
|
||||||
}
|
</IconButton>
|
||||||
onClick={() => { setOpen(true); }}
|
) : (
|
||||||
>
|
<Button
|
||||||
{p.file.file_name} ({filesize(p.file.file_size)})
|
startIcon={<FileIcon file={p.file} />}
|
||||||
</Button>
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.file.file_name} ({filesize(p.file.file_size)})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FileIcon(p: { file: UploadedFile }): React.ReactElement {
|
||||||
|
return p.file.mime_type === "application/pdf" ? (
|
||||||
|
<PictureAsPdfIcon />
|
||||||
|
) : (
|
||||||
|
<ImageIcon />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
31
moneymgr_web/src/widgets/forms/MovementSelect.tsx
Normal file
31
moneymgr_web/src/widgets/forms/MovementSelect.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { MenuItem, Select, SelectChangeEvent } from "@mui/material";
|
||||||
|
import { Movement } from "../../api/MovementsApi";
|
||||||
|
import { MovementWidget } from "../MovementWidget";
|
||||||
|
|
||||||
|
export function MovementSelect(p: {
|
||||||
|
list: Movement[];
|
||||||
|
value: Movement | undefined;
|
||||||
|
onChange: (value: Movement | undefined) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const handleChange = (event: SelectChangeEvent) => {
|
||||||
|
if (!event.target.value) {
|
||||||
|
p.onChange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = Number(event.target.value);
|
||||||
|
p.onChange(p.list.find((m) => m.id === id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={p.value?.id.toString()} onChange={handleChange}>
|
||||||
|
<MenuItem value={undefined}>
|
||||||
|
<i>None</i>
|
||||||
|
</MenuItem>
|
||||||
|
{p.list.map((l) => (
|
||||||
|
<MenuItem key={l.id} value={l.id.toString()}>
|
||||||
|
<MovementWidget movement={l} />
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user