Can attach multiple inbox entries to movements at once

This commit is contained in:
Pierre HUBERT 2025-05-15 19:40:07 +02:00
parent 5aa954dca2
commit 0f6447155b
7 changed files with 232 additions and 27 deletions

View File

@ -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": {

View File

@ -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",

View File

@ -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>

View File

@ -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

View 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" }} />
&bull;
<span style={{ width: "0.5em" }} />
{fmtDateFromTime(p.entry.time)}
</span>
</span>
</span>
);
}

View File

@ -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);
}}
>
<FileIcon file={p.file} />
</IconButton>
) : ( ) : (
<ImageIcon /> <Button
) startIcon={<FileIcon file={p.file} />}
} onClick={() => {
onClick={() => { setOpen(true); }} setOpen(true);
}}
> >
{p.file.file_name} ({filesize(p.file.file_size)}) {p.file.file_name} ({filesize(p.file.file_size)})
</Button> </Button>
)}
</> </>
); );
} }
function FileIcon(p: { file: UploadedFile }): React.ReactElement {
return p.file.mime_type === "application/pdf" ? (
<PictureAsPdfIcon />
) : (
<ImageIcon />
);
}

View 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>
);
}