Can create inbox entries
This commit is contained in:
parent
cceed381bd
commit
0b586039c3
@ -11,6 +11,7 @@ import { AccountRoute } from "./routes/AccountRoute";
|
|||||||
import { AccountsRoute } from "./routes/AccountsRoute";
|
import { AccountsRoute } from "./routes/AccountsRoute";
|
||||||
import { BackupRoute } from "./routes/BackupRoute";
|
import { BackupRoute } from "./routes/BackupRoute";
|
||||||
import { HomeRoute } from "./routes/HomeRoute";
|
import { HomeRoute } from "./routes/HomeRoute";
|
||||||
|
import { InboxRoute } from "./routes/InboxRoute";
|
||||||
import { NotFoundRoute } from "./routes/NotFound";
|
import { NotFoundRoute } from "./routes/NotFound";
|
||||||
import { TokensRoute } from "./routes/TokensRoute";
|
import { TokensRoute } from "./routes/TokensRoute";
|
||||||
import { LoginRoute } from "./routes/auth/LoginRoute";
|
import { LoginRoute } from "./routes/auth/LoginRoute";
|
||||||
@ -45,6 +46,7 @@ export function App() {
|
|||||||
<Route path="backup" element={<BackupRoute />} />
|
<Route path="backup" element={<BackupRoute />} />
|
||||||
<Route path="accounts" element={<AccountsRoute />} />
|
<Route path="accounts" element={<AccountsRoute />} />
|
||||||
<Route path="account/:accountId" element={<AccountRoute />} />
|
<Route path="account/:accountId" element={<AccountRoute />} />
|
||||||
|
<Route path="inbox" element={<InboxRoute />} />
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundRoute />} />
|
<Route path="*" element={<NotFoundRoute />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
46
moneymgr_web/src/api/InboxApi.ts
Normal file
46
moneymgr_web/src/api/InboxApi.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
|
export interface InboxEntry {
|
||||||
|
id: number;
|
||||||
|
file_id: number;
|
||||||
|
user_id: number;
|
||||||
|
movement_id?: number;
|
||||||
|
time: number;
|
||||||
|
label?: string;
|
||||||
|
amount?: number;
|
||||||
|
time_create: number;
|
||||||
|
time_update: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboxEntryUpdate {
|
||||||
|
file_id: number;
|
||||||
|
movement_id?: number;
|
||||||
|
time: number;
|
||||||
|
label?: string;
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InboxApi {
|
||||||
|
/**
|
||||||
|
* Create a new inbox entry
|
||||||
|
*/
|
||||||
|
static async Create(entry: InboxEntryUpdate): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
uri: `/inbox`,
|
||||||
|
method: "POST",
|
||||||
|
jsonData: entry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of inbox entries
|
||||||
|
*/
|
||||||
|
static async GetList(includeAttached: boolean): Promise<InboxEntry[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
uri: `/inbox?include_attached=${includeAttached ? "true" : "false"}`,
|
||||||
|
method: "GET",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,7 @@ export interface ServerConstraints {
|
|||||||
token_max_inactivity: LenConstraint;
|
token_max_inactivity: LenConstraint;
|
||||||
account_name: LenConstraint;
|
account_name: LenConstraint;
|
||||||
movement_label: LenConstraint;
|
movement_label: LenConstraint;
|
||||||
|
inbox_entry_label: LenConstraint;
|
||||||
file_allowed_types: string[];
|
file_allowed_types: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
75
moneymgr_web/src/routes/InboxRoute.tsx
Normal file
75
moneymgr_web/src/routes/InboxRoute.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import { Checkbox, FormControlLabel, IconButton, Tooltip } from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { InboxApi, InboxEntry } from "../api/InboxApi";
|
||||||
|
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||||
|
import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider";
|
||||||
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
|
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
|
||||||
|
import { NewMovementWidget } from "../widgets/NewMovementWidget";
|
||||||
|
|
||||||
|
export function InboxRoute(): React.ReactElement {
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
const alert = useAlert();
|
||||||
|
|
||||||
|
const [entries, setEntries] = React.useState<InboxEntry[] | undefined>();
|
||||||
|
const [includeAttached, setIncludeAttached] = React.useState(false);
|
||||||
|
|
||||||
|
const loadKey = React.useRef(1);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setEntries(await InboxApi.GetList(includeAttached));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload = async (skipEntries: boolean) => {
|
||||||
|
try {
|
||||||
|
loadingMessage.show("Refreshing the list of inbox entries...");
|
||||||
|
// TODO : trigger reload number of inbox entries
|
||||||
|
if (!skipEntries) await load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load list of inbox entries!", e);
|
||||||
|
alert(`Failed to refresh the list of inbox entries! ${e}`);
|
||||||
|
} finally {
|
||||||
|
loadingMessage.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MoneyMgrWebRouteContainer
|
||||||
|
label={"Inbox"}
|
||||||
|
actions={
|
||||||
|
<span>
|
||||||
|
<FormControlLabel
|
||||||
|
checked={includeAttached}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
onChange={(e) => {
|
||||||
|
setIncludeAttached(e.target.checked);
|
||||||
|
reload(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Include attached"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Refresh table">
|
||||||
|
<IconButton onClick={() => reload(false)}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||||
|
<AsyncWidget
|
||||||
|
loadKey={loadKey.current}
|
||||||
|
load={load}
|
||||||
|
errMsg="Failed to load the content of inbox!"
|
||||||
|
build={() => <>todo table</>}
|
||||||
|
/>
|
||||||
|
<NewMovementWidget isInbox onCreated={() => reload(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MoneyMgrWebRouteContainer>
|
||||||
|
);
|
||||||
|
}
|
7
moneymgr_web/src/utils/StringUtils.tsx
Normal file
7
moneymgr_web/src/utils/StringUtils.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Make the first letter of a word uppercase
|
||||||
|
*/
|
||||||
|
export function firstLetterUppercase(s: string): string {
|
||||||
|
if (s.length === 0) return s;
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { mdiCashMultiple, mdiHome } from "@mdi/js";
|
import { mdiCashMultiple, mdiHome, mdiInbox } from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import {
|
import {
|
||||||
Divider,
|
Divider,
|
||||||
@ -35,6 +35,12 @@ export function MoneyNavList(): React.ReactElement {
|
|||||||
uri="/accounts"
|
uri="/accounts"
|
||||||
icon={<Icon path={mdiCashMultiple} size={1} />}
|
icon={<Icon path={mdiCashMultiple} size={1} />}
|
||||||
/>
|
/>
|
||||||
|
{/* TODO : show number of unmatched */}
|
||||||
|
<NavLink
|
||||||
|
label="Inbox"
|
||||||
|
uri="/inbox"
|
||||||
|
icon={<Icon path={mdiInbox} size={1} />}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
{accounts.list.isEmpty && (
|
{accounts.list.isEmpty && (
|
||||||
<Typography
|
<Typography
|
||||||
|
@ -1,63 +1,88 @@
|
|||||||
import { IconButton, Tooltip, Typography } from "@mui/material";
|
|
||||||
import { Account } from "../api/AccountApi";
|
|
||||||
import { DateInput } from "./forms/DateInput";
|
|
||||||
import { time } from "../utils/DateUtils";
|
|
||||||
import React, { useRef } from "react";
|
|
||||||
import { TextInput } from "./forms/TextInput";
|
|
||||||
import { ServerApi } from "../api/ServerApi";
|
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
import ClearIcon from "@mui/icons-material/Clear";
|
||||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
import { IconButton, Tooltip, Typography } from "@mui/material";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { Account } from "../api/AccountApi";
|
||||||
|
import { UploadedFile } from "../api/FileApi";
|
||||||
|
import { InboxApi } from "../api/InboxApi";
|
||||||
import { MovementApi } from "../api/MovementsApi";
|
import { MovementApi } from "../api/MovementsApi";
|
||||||
|
import { ServerApi } from "../api/ServerApi";
|
||||||
|
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||||
|
import { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||||
|
import { time } from "../utils/DateUtils";
|
||||||
|
import { firstLetterUppercase } from "../utils/StringUtils";
|
||||||
import { AmountInput } from "./forms/AmountInput";
|
import { AmountInput } from "./forms/AmountInput";
|
||||||
|
import { DateInput } from "./forms/DateInput";
|
||||||
|
import { TextInput } from "./forms/TextInput";
|
||||||
|
import { UploadFileButton } from "./forms/UploadFileButton";
|
||||||
|
import { UploadedFileWidget } from "./UploadedFileWidget";
|
||||||
|
|
||||||
export function NewMovementWidget(p: {
|
export function NewMovementWidget(
|
||||||
account: Account;
|
p: {
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
}): React.ReactElement {
|
} & ({ isInbox: true } | { isInbox?: undefined; account: Account })
|
||||||
|
): React.ReactElement {
|
||||||
const snackbar = useSnackbar();
|
const snackbar = useSnackbar();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
|
||||||
const dateInputRef = useRef<HTMLInputElement>(null);
|
const dateInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [file, setFile] = React.useState<UploadedFile | undefined>();
|
||||||
const [movTime, setMovTime] = React.useState<number | undefined>(time());
|
const [movTime, setMovTime] = React.useState<number | undefined>(time());
|
||||||
const [label, setLabel] = React.useState<string | undefined>("");
|
const [label, setLabel] = React.useState<string | undefined>("");
|
||||||
const [amount, setAmount] = React.useState<number | undefined>(0);
|
const [amount, setAmount] = React.useState<number | undefined>(0);
|
||||||
|
|
||||||
|
const entity = p.isInbox ? "inbox entry" : "movement";
|
||||||
|
|
||||||
const submit = async (e: React.SyntheticEvent<any>) => {
|
const submit = async (e: React.SyntheticEvent<any>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if ((label?.length ?? 0) === 0) {
|
if (!p.isInbox && (label?.length ?? 0) === 0) {
|
||||||
alert("Please specify movement label!");
|
alert(`Please specify ${entity} label!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.isInbox && !file) {
|
||||||
|
alert(`Please specify ${entity} file!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!movTime) {
|
if (!movTime) {
|
||||||
alert("Please specify movement date!");
|
alert(`Please specify ${entity} date!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await MovementApi.Create({
|
if (!p.isInbox) {
|
||||||
account_id: p.account.id,
|
await MovementApi.Create({
|
||||||
checked: false,
|
account_id: p.account.id,
|
||||||
amount: amount!,
|
checked: false,
|
||||||
label: label!,
|
amount: amount!,
|
||||||
time: movTime,
|
label: label!,
|
||||||
});
|
time: movTime,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await InboxApi.Create({
|
||||||
|
file_id: file!.id,
|
||||||
|
amount: amount,
|
||||||
|
label: label,
|
||||||
|
time: movTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
snackbar("The movement was successfully created!");
|
snackbar(`The ${entity} was successfully created!`);
|
||||||
|
|
||||||
p.onCreated();
|
p.onCreated();
|
||||||
|
|
||||||
|
setFile(undefined);
|
||||||
setLabel("");
|
setLabel("");
|
||||||
setAmount(0);
|
setAmount(0);
|
||||||
|
|
||||||
// Give back focus to date input
|
// Give back focus to date input
|
||||||
dateInputRef.current?.querySelector("input")?.focus();
|
dateInputRef.current?.querySelector("input")?.focus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to create movement!`, e);
|
console.error(`Failed to create ${entity}!`, e);
|
||||||
alert(`Failed to create movement! ${e}`);
|
alert(`Failed to create ${entity}! ${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,8 +91,45 @@ export function NewMovementWidget(p: {
|
|||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
style={{ marginTop: "10px", display: "flex", alignItems: "center" }}
|
style={{ marginTop: "10px", display: "flex", alignItems: "center" }}
|
||||||
>
|
>
|
||||||
<Typography style={{ marginRight: "10px" }}>New movement</Typography>
|
{/* Label */}
|
||||||
|
{/* Add label only when creating movement */}
|
||||||
|
{!p.isInbox ? (
|
||||||
|
<Typography style={{ marginRight: "10px" }}>New {entity}</Typography>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
{/* File input */}
|
||||||
|
{/* Add file only when creating inbox entries */}
|
||||||
|
{p.isInbox ? (
|
||||||
|
file ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
maxWidth: "100px",
|
||||||
|
overflow: "hidden",
|
||||||
|
textWrap: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadedFileWidget file_id={file.id} />
|
||||||
|
</span>
|
||||||
|
<IconButton onClick={() => setFile(undefined)}>
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<UploadFileButton
|
||||||
|
disableSuccessSnackBar
|
||||||
|
label="Join File"
|
||||||
|
tooltip="Set the file of this new inbox entry"
|
||||||
|
onUploaded={setFile}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date input */}
|
||||||
<DateInput
|
<DateInput
|
||||||
ref={dateInputRef}
|
ref={dateInputRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -77,15 +139,21 @@ export function NewMovementWidget(p: {
|
|||||||
onValueChange={setMovTime}
|
onValueChange={setMovTime}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Label input */}
|
||||||
<TextInput
|
<TextInput
|
||||||
editable
|
editable
|
||||||
placeholder="Movement label"
|
placeholder={`${firstLetterUppercase(entity)} label`}
|
||||||
value={label}
|
value={label}
|
||||||
onValueChange={setLabel}
|
onValueChange={setLabel}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
size={ServerApi.Config.constraints.movement_label}
|
size={
|
||||||
|
p.isInbox
|
||||||
|
? ServerApi.Config.constraints.inbox_entry_label
|
||||||
|
: ServerApi.Config.constraints.movement_label
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Amount input */}
|
||||||
<AmountInput
|
<AmountInput
|
||||||
editable
|
editable
|
||||||
type="text"
|
type="text"
|
||||||
@ -94,7 +162,8 @@ export function NewMovementWidget(p: {
|
|||||||
value={amount ?? 0}
|
value={amount ?? 0}
|
||||||
onValueChange={setAmount}
|
onValueChange={setAmount}
|
||||||
/>
|
/>
|
||||||
<Tooltip title="Add new movement">
|
{/* Submit button */}
|
||||||
|
<Tooltip title={`Add new ${entity}`}>
|
||||||
<IconButton onClick={submit}>
|
<IconButton onClick={submit}>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -13,6 +13,7 @@ export function UploadFileButton(p: {
|
|||||||
onUploaded: (file: UploadedFile) => void;
|
onUploaded: (file: UploadedFile) => void;
|
||||||
label: string;
|
label: string;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
|
disableSuccessSnackBar?: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const loadingMessage = useLoadingMessage();
|
const loadingMessage = useLoadingMessage();
|
||||||
@ -39,7 +40,8 @@ export function UploadFileButton(p: {
|
|||||||
|
|
||||||
const result = await FileApi.UploadFile(file[0]);
|
const result = await FileApi.UploadFile(file[0]);
|
||||||
|
|
||||||
snackbar("The file was successfully uploaded!");
|
if (!p.disableSuccessSnackBar)
|
||||||
|
snackbar("The file was successfully uploaded!");
|
||||||
|
|
||||||
p.onUploaded(result);
|
p.onUploaded(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user