Can attach inbox entry to movement

This commit is contained in:
Pierre HUBERT 2025-05-13 21:16:14 +02:00
parent 5e4de364e0
commit 1ef4710992
5 changed files with 285 additions and 76 deletions

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

View File

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

View File

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

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

View File

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