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 CloseIcon from "@mui/icons-material/Close";
|
||||||
import CloudDownloadIcon from "@mui/icons-material/CloudDownload";
|
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 { FileViewerWidget } from "../widgets/FileViewerWidget";
|
||||||
|
|
||||||
export function FileViewerDialog(p: {
|
export function FileViewerDialog(p: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -51,65 +50,7 @@ export function FileViewerDialog(p: {
|
|||||||
</a>
|
</a>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<FileViewer
|
<FileViewerWidget file={p.file} />
|
||||||
url={FileApi.DownloadURL(p.file)}
|
|
||||||
downloadUrl={FileApi.DownloadURL(p.file, true)}
|
|
||||||
{...p.file}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</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 LinkOffIcon from "@mui/icons-material/LinkOff";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
@ -13,9 +14,16 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mui/material";
|
} 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 React from "react";
|
||||||
import { InboxApi, InboxEntry } from "../api/InboxApi";
|
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 { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||||
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
import { useConfirm } from "../hooks/context_providers/ConfirmDialogProvider";
|
||||||
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||||
@ -124,6 +132,51 @@ function InboxTable(p: {
|
|||||||
const [rowSelectionModel, setRowSelectionModel] =
|
const [rowSelectionModel, setRowSelectionModel] =
|
||||||
React.useState<GridRowSelectionModel>([]);
|
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
|
// Delete inbox entry
|
||||||
const handleDeleteClick = async (entry: InboxEntry) => {
|
const handleDeleteClick = async (entry: InboxEntry) => {
|
||||||
try {
|
try {
|
||||||
@ -278,11 +331,18 @@ function InboxTable(p: {
|
|||||||
field: "actions",
|
field: "actions",
|
||||||
type: "actions",
|
type: "actions",
|
||||||
headerName: "",
|
headerName: "",
|
||||||
width: 55,
|
width: 82,
|
||||||
cellClassName: "actions",
|
cellClassName: "actions",
|
||||||
editable: false,
|
editable: false,
|
||||||
getActions: (params) => {
|
getActions: (params) => {
|
||||||
return [
|
return [
|
||||||
|
<GridActionsCellItem
|
||||||
|
key={`del-${params.row.id}`}
|
||||||
|
icon={<SearchIcon />}
|
||||||
|
label="Attach entry to movement"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => handleAttachClick(params.row)}
|
||||||
|
/>,
|
||||||
<InboxEntryActionMenu
|
<InboxEntryActionMenu
|
||||||
key="menu"
|
key="menu"
|
||||||
entry={params.row}
|
entry={params.row}
|
||||||
@ -296,6 +356,15 @@ function InboxTable(p: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{attaching && (
|
||||||
|
<AttachInboxEntryToMovementDialog
|
||||||
|
open
|
||||||
|
entry={attaching}
|
||||||
|
onSelected={performAttach}
|
||||||
|
onClose={handleCloseAttachDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Filter by label"
|
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 { ListItem, ListItemButton, Paper, Typography } from "@mui/material";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Movement, MovementApi } from "../api/MovementsApi";
|
||||||
|
import { AsyncWidget } from "./AsyncWidget";
|
||||||
import { AccountInput } from "./forms/AccountInput";
|
import { AccountInput } from "./forms/AccountInput";
|
||||||
import { AmountInput } from "./forms/AmountInput";
|
import { AmountInput } from "./forms/AmountInput";
|
||||||
import { DateInput } from "./forms/DateInput";
|
import { DateInput } from "./forms/DateInput";
|
||||||
import { TextInput } from "./forms/TextInput";
|
import { TextInput } from "./forms/TextInput";
|
||||||
import { Movement, MovementApi } from "../api/MovementsApi";
|
import { MovementWidget } from "./MovementWidget";
|
||||||
import { AsyncWidget } from "./AsyncWidget";
|
|
||||||
import { AsyncMovementWidget, MovementWidget } from "./MovementWidget";
|
|
||||||
|
|
||||||
export function SelectMovementWidget(p: {
|
export function SelectMovementWidget(p: {
|
||||||
value?: number;
|
value?: number;
|
||||||
onChange: (movementId: number) => void;
|
onChange: (movement: Movement) => void;
|
||||||
initialValues?: {
|
initialValues?: {
|
||||||
amount?: number;
|
amount?: number;
|
||||||
accountId?: number;
|
accountId?: number;
|
||||||
date?: number;
|
time?: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
};
|
};
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
@ -24,8 +24,8 @@ export function SelectMovementWidget(p: {
|
|||||||
const [accountId, setAccountId] = React.useState<number | undefined>(
|
const [accountId, setAccountId] = React.useState<number | undefined>(
|
||||||
p.initialValues?.accountId
|
p.initialValues?.accountId
|
||||||
);
|
);
|
||||||
const [date, setDate] = React.useState<number | undefined>(
|
const [time, setTime] = React.useState<number | undefined>(
|
||||||
p.initialValues?.date
|
p.initialValues?.time
|
||||||
);
|
);
|
||||||
const [label, setLabel] = React.useState<string | undefined>(
|
const [label, setLabel] = React.useState<string | undefined>(
|
||||||
p.initialValues?.label
|
p.initialValues?.label
|
||||||
@ -35,8 +35,8 @@ export function SelectMovementWidget(p: {
|
|||||||
label: label,
|
label: label,
|
||||||
amount_min: amount ? amount - 0.5 : undefined,
|
amount_min: amount ? amount - 0.5 : undefined,
|
||||||
amount_max: amount ? amount + 0.5 : undefined,
|
amount_max: amount ? amount + 0.5 : undefined,
|
||||||
time_min: date ? date - 3600 * 24 : undefined,
|
time_min: time ? time - 3600 * 24 : undefined,
|
||||||
time_max: date ? date + 3600 * 24 : undefined,
|
time_max: time ? time + 3600 * 24 : undefined,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ export function SelectMovementWidget(p: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper style={{ padding: "10px" }}>
|
<Paper style={{ padding: "10px", flex: 1 }}>
|
||||||
<div
|
<div
|
||||||
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
|
style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
|
||||||
>
|
>
|
||||||
@ -71,8 +71,8 @@ export function SelectMovementWidget(p: {
|
|||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<DateInput
|
<DateInput
|
||||||
editable
|
editable
|
||||||
value={date}
|
value={time}
|
||||||
onValueChange={setDate}
|
onValueChange={setTime}
|
||||||
label="Date"
|
label="Date"
|
||||||
style={{ flex: 20 }}
|
style={{ flex: 20 }}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -112,7 +112,7 @@ export function SelectMovementWidget(p: {
|
|||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
selected={entry.id === p.value}
|
selected={entry.id === p.value}
|
||||||
onClick={() => p.onChange(entry.id)}
|
onClick={() => p.onChange(entry)}
|
||||||
>
|
>
|
||||||
<MovementWidget movement={entry} />
|
<MovementWidget movement={entry} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user