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 { BackupRoute } from "./routes/BackupRoute";
|
||||
import { HomeRoute } from "./routes/HomeRoute";
|
||||
import { InboxRoute } from "./routes/InboxRoute";
|
||||
import { NotFoundRoute } from "./routes/NotFound";
|
||||
import { TokensRoute } from "./routes/TokensRoute";
|
||||
import { LoginRoute } from "./routes/auth/LoginRoute";
|
||||
@ -45,6 +46,7 @@ export function App() {
|
||||
<Route path="backup" element={<BackupRoute />} />
|
||||
<Route path="accounts" element={<AccountsRoute />} />
|
||||
<Route path="account/:accountId" element={<AccountRoute />} />
|
||||
<Route path="inbox" element={<InboxRoute />} />
|
||||
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</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;
|
||||
account_name: LenConstraint;
|
||||
movement_label: LenConstraint;
|
||||
inbox_entry_label: LenConstraint;
|
||||
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 {
|
||||
Divider,
|
||||
@ -35,6 +35,12 @@ export function MoneyNavList(): React.ReactElement {
|
||||
uri="/accounts"
|
||||
icon={<Icon path={mdiCashMultiple} size={1} />}
|
||||
/>
|
||||
{/* TODO : show number of unmatched */}
|
||||
<NavLink
|
||||
label="Inbox"
|
||||
uri="/inbox"
|
||||
icon={<Icon path={mdiInbox} size={1} />}
|
||||
/>
|
||||
<Divider />
|
||||
{accounts.list.isEmpty && (
|
||||
<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 { useSnackbar } from "../hooks/context_providers/SnackbarProvider";
|
||||
import { useAlert } from "../hooks/context_providers/AlertDialogProvider";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
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 { 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 { DateInput } from "./forms/DateInput";
|
||||
import { TextInput } from "./forms/TextInput";
|
||||
import { UploadFileButton } from "./forms/UploadFileButton";
|
||||
import { UploadedFileWidget } from "./UploadedFileWidget";
|
||||
|
||||
export function NewMovementWidget(p: {
|
||||
account: Account;
|
||||
onCreated: () => void;
|
||||
}): React.ReactElement {
|
||||
export function NewMovementWidget(
|
||||
p: {
|
||||
onCreated: () => void;
|
||||
} & ({ isInbox: true } | { isInbox?: undefined; account: Account })
|
||||
): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
const alert = useAlert();
|
||||
|
||||
const dateInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = React.useState<UploadedFile | undefined>();
|
||||
const [movTime, setMovTime] = React.useState<number | undefined>(time());
|
||||
const [label, setLabel] = React.useState<string | undefined>("");
|
||||
const [amount, setAmount] = React.useState<number | undefined>(0);
|
||||
|
||||
const entity = p.isInbox ? "inbox entry" : "movement";
|
||||
|
||||
const submit = async (e: React.SyntheticEvent<any>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if ((label?.length ?? 0) === 0) {
|
||||
alert("Please specify movement label!");
|
||||
if (!p.isInbox && (label?.length ?? 0) === 0) {
|
||||
alert(`Please specify ${entity} label!`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.isInbox && !file) {
|
||||
alert(`Please specify ${entity} file!`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!movTime) {
|
||||
alert("Please specify movement date!");
|
||||
alert(`Please specify ${entity} date!`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await MovementApi.Create({
|
||||
account_id: p.account.id,
|
||||
checked: false,
|
||||
amount: amount!,
|
||||
label: label!,
|
||||
time: movTime,
|
||||
});
|
||||
if (!p.isInbox) {
|
||||
await MovementApi.Create({
|
||||
account_id: p.account.id,
|
||||
checked: false,
|
||||
amount: amount!,
|
||||
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();
|
||||
|
||||
setFile(undefined);
|
||||
setLabel("");
|
||||
setAmount(0);
|
||||
|
||||
// Give back focus to date input
|
||||
dateInputRef.current?.querySelector("input")?.focus();
|
||||
} catch (e) {
|
||||
console.error(`Failed to create movement!`, e);
|
||||
alert(`Failed to create movement! ${e}`);
|
||||
console.error(`Failed to create ${entity}!`, e);
|
||||
alert(`Failed to create ${entity}! ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -66,8 +91,45 @@ export function NewMovementWidget(p: {
|
||||
onSubmit={submit}
|
||||
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
|
||||
ref={dateInputRef}
|
||||
autoFocus
|
||||
@ -77,15 +139,21 @@ export function NewMovementWidget(p: {
|
||||
onValueChange={setMovTime}
|
||||
/>
|
||||
|
||||
{/* Label input */}
|
||||
<TextInput
|
||||
editable
|
||||
placeholder="Movement label"
|
||||
placeholder={`${firstLetterUppercase(entity)} label`}
|
||||
value={label}
|
||||
onValueChange={setLabel}
|
||||
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
|
||||
editable
|
||||
type="text"
|
||||
@ -94,7 +162,8 @@ export function NewMovementWidget(p: {
|
||||
value={amount ?? 0}
|
||||
onValueChange={setAmount}
|
||||
/>
|
||||
<Tooltip title="Add new movement">
|
||||
{/* Submit button */}
|
||||
<Tooltip title={`Add new ${entity}`}>
|
||||
<IconButton onClick={submit}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
@ -13,6 +13,7 @@ export function UploadFileButton(p: {
|
||||
onUploaded: (file: UploadedFile) => void;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
disableSuccessSnackBar?: boolean;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
@ -39,7 +40,8 @@ export function UploadFileButton(p: {
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user