Can attach inbox entry to movement
This commit is contained in:
parent
5e4de364e0
commit
1ef4710992
108
moneymgr_web/src/dialogs/AttachInboxEntryToMovementDialog.tsx
Normal file
108
moneymgr_web/src/dialogs/AttachInboxEntryToMovementDialog.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
Button,
|
||||
Dialog,
|
||||
Grid,
|
||||
IconButton,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { InboxEntry } from "../api/InboxApi";
|
||||
import { Movement } from "../api/MovementsApi";
|
||||
import { AsyncFileViewerWidget } from "../widgets/FileViewerWidget";
|
||||
import { SelectMovementWidget } from "../widgets/SelectMovementWidget";
|
||||
import { AmountWidget } from "../widgets/AmountWidget";
|
||||
import { fmtDateFromTime } from "../utils/DateUtils";
|
||||
|
||||
export function AttachInboxEntryToMovementDialog(p: {
|
||||
open: boolean;
|
||||
entry: InboxEntry;
|
||||
onSelected: (m: Movement) => void;
|
||||
onClose: () => void;
|
||||
}): React.ReactElement {
|
||||
const [value, setValue] = React.useState<undefined | Movement>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!value) return;
|
||||
p.onSelected(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog fullScreen open={true} onClose={p.onClose}>
|
||||
<AppBar sx={{ position: "relative" }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={p.onClose}
|
||||
aria-label="close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||
Attach inbox entry to movement
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{ ml: 2, flex: 1 }}
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<span>
|
||||
{p.entry.amount !== undefined && (
|
||||
<>
|
||||
<span>
|
||||
Amount: <AmountWidget amount={p.entry.amount} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span>{p.entry.label && <>Label: {p.entry.label}</>}</span>
|
||||
<span>
|
||||
{p.entry.time && <>Date: {fmtDateFromTime(p.entry.time)}</>}
|
||||
</span>
|
||||
</Typography>
|
||||
{Number.isFinite(p.entry.amount) &&
|
||||
value !== undefined &&
|
||||
p.entry.amount !== value?.amount && (
|
||||
<Alert severity="warning">Amount mismatch!</Alert>
|
||||
)}
|
||||
<Button
|
||||
autoFocus
|
||||
color="inherit"
|
||||
onClick={handleSubmit}
|
||||
disabled={!value}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Grid container style={{ flex: 1 }}>
|
||||
<Grid
|
||||
size={{ sm: 12, md: 6 }}
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<AsyncFileViewerWidget fileID={p.entry.file_id} />
|
||||
</Grid>
|
||||
<Grid
|
||||
size={{ sm: 12, md: 6 }}
|
||||
style={{ display: "flex", height: "100%" }}
|
||||
>
|
||||
<SelectMovementWidget
|
||||
value={value?.id}
|
||||
onChange={setValue}
|
||||
initialValues={{
|
||||
amount: p.entry.amount,
|
||||
time: p.entry.time,
|
||||
label: p.entry.label,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -1,18 +1,17 @@
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CloudDownloadIcon from "@mui/icons-material/CloudDownload";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import {
|
||||
AppBar,
|
||||
Button,
|
||||
Dialog,
|
||||
IconButton,
|
||||
Paper,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import React from "react";
|
||||
import { FileApi, UploadedFile } from "../api/FileApi";
|
||||
import { FileViewerWidget } from "../widgets/FileViewerWidget";
|
||||
|
||||
export function FileViewerDialog(p: {
|
||||
open: boolean;
|
||||
@ -51,65 +50,7 @@ export function FileViewerDialog(p: {
|
||||
</a>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<FileViewer
|
||||
url={FileApi.DownloadURL(p.file)}
|
||||
downloadUrl={FileApi.DownloadURL(p.file, true)}
|
||||
{...p.file}
|
||||
/>
|
||||
<FileViewerWidget file={p.file} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewerProps = {
|
||||
url: string;
|
||||
downloadUrl: string;
|
||||
} & UploadedFile;
|
||||
|
||||
function FileViewer(p: ViewerProps): React.ReactElement {
|
||||
// Image
|
||||
if (p.mime_type.startsWith("image/")) return <ImageViewer {...p} />;
|
||||
// PDF
|
||||
else if (p.mime_type === "application/pdf") return <PDFViewer {...p} />;
|
||||
// Default viewer
|
||||
else return <DefaultViewer {...p} />;
|
||||
}
|
||||
|
||||
function ImageViewer(p: ViewerProps): React.ReactElement {
|
||||
return (
|
||||
<img
|
||||
src={p.url}
|
||||
style={{ maxWidth: "100%", width: "fit-content", margin: "auto" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PDFViewer(p: ViewerProps): React.ReactElement {
|
||||
// eslint-disable-next-line react-dom/no-missing-iframe-sandbox
|
||||
return <iframe style={{ flex: 1 }} src={p.url} />;
|
||||
}
|
||||
|
||||
function DefaultViewer(p: ViewerProps): React.ReactElement {
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{
|
||||
margin: "10px",
|
||||
padding: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
<CloudDownloadIcon fontSize="large" />
|
||||
</Typography>
|
||||
<Typography variant="caption" gutterBottom>
|
||||
{filesize(p.file_size)}
|
||||
</Typography>
|
||||
<a href={p.downloadUrl} target="_blank" referrerPolicy="no-referrer">
|
||||
<Button variant="outlined">Download</Button>
|
||||
</a>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ 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,
|
||||
@ -13,9 +14,16 @@ import {
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||
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 { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||
@ -124,6 +132,51 @@ function InboxTable(p: {
|
||||
const [rowSelectionModel, setRowSelectionModel] =
|
||||
React.useState<GridRowSelectionModel>([]);
|
||||
|
||||
const [attaching, setAttaching] = React.useState<InboxEntry | undefined>();
|
||||
|
||||
// Request to attach entry to movement
|
||||
const handleAttachClick = async (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 {
|
||||
@ -278,11 +331,18 @@ function InboxTable(p: {
|
||||
field: "actions",
|
||||
type: "actions",
|
||||
headerName: "",
|
||||
width: 55,
|
||||
width: 82,
|
||||
cellClassName: "actions",
|
||||
editable: false,
|
||||
getActions: (params) => {
|
||||
return [
|
||||
<GridActionsCellItem
|
||||
key={`del-${params.row.id}`}
|
||||
icon={<SearchIcon />}
|
||||
label="Attach entry to movement"
|
||||
color="inherit"
|
||||
onClick={() => handleAttachClick(params.row)}
|
||||
/>,
|
||||
<InboxEntryActionMenu
|
||||
key="menu"
|
||||
entry={params.row}
|
||||
@ -296,6 +356,15 @@ function InboxTable(p: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{attaching && (
|
||||
<AttachInboxEntryToMovementDialog
|
||||
open
|
||||
entry={attaching}
|
||||
onSelected={performAttach}
|
||||
onClose={handleCloseAttachDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex" }}>
|
||||
<TextField
|
||||
placeholder="Filter by label"
|
||||
|
91
moneymgr_web/src/widgets/FileViewerWidget.tsx
Normal file
91
moneymgr_web/src/widgets/FileViewerWidget.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import CloudDownloadIcon from "@mui/icons-material/CloudDownload";
|
||||
import { Button, Paper, Typography } from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import { FileApi, UploadedFile } from "../api/FileApi";
|
||||
import { AsyncWidget } from "./AsyncWidget";
|
||||
import React from "react";
|
||||
|
||||
export function AsyncFileViewerWidget(p: {
|
||||
fileID: number;
|
||||
}): React.ReactElement {
|
||||
const [file, setFile] = React.useState<UploadedFile | undefined>();
|
||||
|
||||
const load = async () => {
|
||||
setFile(await FileApi.GetFile(p.fileID));
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={p.fileID}
|
||||
load={load}
|
||||
errMsg="Failed to load file information!"
|
||||
build={() => <FileViewerWidget file={file!} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileViewerWidget(p: {
|
||||
file: UploadedFile;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<FileViewer
|
||||
url={FileApi.DownloadURL(p.file)}
|
||||
downloadUrl={FileApi.DownloadURL(p.file, true)}
|
||||
{...p.file}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewerProps = {
|
||||
url: string;
|
||||
downloadUrl: string;
|
||||
} & UploadedFile;
|
||||
|
||||
function FileViewer(p: ViewerProps): React.ReactElement {
|
||||
// Image
|
||||
if (p.mime_type.startsWith("image/")) return <ImageViewer {...p} />;
|
||||
// PDF
|
||||
else if (p.mime_type === "application/pdf") return <PDFViewer {...p} />;
|
||||
// Default viewer
|
||||
else return <DefaultViewer {...p} />;
|
||||
}
|
||||
|
||||
function ImageViewer(p: ViewerProps): React.ReactElement {
|
||||
return (
|
||||
<img
|
||||
src={p.url}
|
||||
style={{ maxWidth: "100%", width: "fit-content", margin: "auto" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PDFViewer(p: ViewerProps): React.ReactElement {
|
||||
// eslint-disable-next-line react-dom/no-missing-iframe-sandbox
|
||||
return <iframe style={{ flex: 1 }} src={p.url} />;
|
||||
}
|
||||
|
||||
function DefaultViewer(p: ViewerProps): React.ReactElement {
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{
|
||||
margin: "10px",
|
||||
padding: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
<CloudDownloadIcon fontSize="large" />
|
||||
</Typography>
|
||||
<Typography variant="caption" gutterBottom>
|
||||
{filesize(p.file_size)}
|
||||
</Typography>
|
||||
<a href={p.downloadUrl} target="_blank" referrerPolicy="no-referrer">
|
||||
<Button variant="outlined">Download</Button>
|
||||
</a>
|
||||
</Paper>
|
||||
);
|
||||
}
|
@ -1,20 +1,20 @@
|
||||
import { ListItem, ListItemButton, Paper, Typography } from "@mui/material";
|
||||
import React from "react";
|
||||
import { Movement, MovementApi } from "../api/MovementsApi";
|
||||
import { AsyncWidget } from "./AsyncWidget";
|
||||
import { AccountInput } from "./forms/AccountInput";
|
||||
import { AmountInput } from "./forms/AmountInput";
|
||||
import { DateInput } from "./forms/DateInput";
|
||||
import { TextInput } from "./forms/TextInput";
|
||||
import { Movement, MovementApi } from "../api/MovementsApi";
|
||||
import { AsyncWidget } from "./AsyncWidget";
|
||||
import { AsyncMovementWidget, MovementWidget } from "./MovementWidget";
|
||||
import { MovementWidget } from "./MovementWidget";
|
||||
|
||||
export function SelectMovementWidget(p: {
|
||||
value?: number;
|
||||
onChange: (movementId: number) => void;
|
||||
onChange: (movement: Movement) => void;
|
||||
initialValues?: {
|
||||
amount?: number;
|
||||
accountId?: number;
|
||||
date?: number;
|
||||
time?: number;
|
||||
label?: string;
|
||||
};
|
||||
}): React.ReactElement {
|
||||
@ -24,8 +24,8 @@ export function SelectMovementWidget(p: {
|
||||
const [accountId, setAccountId] = React.useState<number | undefined>(
|
||||
p.initialValues?.accountId
|
||||
);
|
||||
const [date, setDate] = React.useState<number | undefined>(
|
||||
p.initialValues?.date
|
||||
const [time, setTime] = React.useState<number | undefined>(
|
||||
p.initialValues?.time
|
||||
);
|
||||
const [label, setLabel] = React.useState<string | undefined>(
|
||||
p.initialValues?.label
|
||||
@ -35,8 +35,8 @@ export function SelectMovementWidget(p: {
|
||||
label: label,
|
||||
amount_min: amount ? amount - 0.5 : undefined,
|
||||
amount_max: amount ? amount + 0.5 : undefined,
|
||||
time_min: date ? date - 3600 * 24 : undefined,
|
||||
time_max: date ? date + 3600 * 24 : undefined,
|
||||
time_min: time ? time - 3600 * 24 : undefined,
|
||||
time_max: time ? time + 3600 * 24 : undefined,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
@ -49,7 +49,7 @@ export function SelectMovementWidget(p: {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper style={{ padding: "10px" }}>
|
||||
<Paper style={{ padding: "10px", flex: 1 }}>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
|
||||
>
|
||||
@ -71,8 +71,8 @@ export function SelectMovementWidget(p: {
|
||||
<span style={{ flex: 1 }} />
|
||||
<DateInput
|
||||
editable
|
||||
value={date}
|
||||
onValueChange={setDate}
|
||||
value={time}
|
||||
onValueChange={setTime}
|
||||
label="Date"
|
||||
style={{ flex: 20 }}
|
||||
variant="outlined"
|
||||
@ -112,7 +112,7 @@ export function SelectMovementWidget(p: {
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
selected={entry.id === p.value}
|
||||
onClick={() => p.onChange(entry.id)}
|
||||
onClick={() => p.onChange(entry)}
|
||||
>
|
||||
<MovementWidget movement={entry} />
|
||||
</ListItemButton>
|
||||
|
Loading…
x
Reference in New Issue
Block a user