Can create inbox entries

This commit is contained in:
Pierre HUBERT 2025-05-10 18:37:51 +02:00
parent cceed381bd
commit 0b586039c3
8 changed files with 240 additions and 32 deletions

View File

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

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

View File

@ -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[];
}

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

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

View File

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

View File

@ -1,43 +1,59 @@
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;
export function NewMovementWidget(
p: {
onCreated: () => void;
}): React.ReactElement {
} & ({ 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 {
if (!p.isInbox) {
await MovementApi.Create({
account_id: p.account.id,
checked: false,
@ -45,19 +61,28 @@ export function NewMovementWidget(p: {
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}
/>
)
) : (
<></>
)}
&nbsp;
{/* Date input */}
<DateInput
ref={dateInputRef}
autoFocus
@ -77,15 +139,21 @@ export function NewMovementWidget(p: {
onValueChange={setMovTime}
/>
&nbsp;
{/* 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
}
/>
&nbsp;
{/* 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>

View File

@ -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,6 +40,7 @@ export function UploadFileButton(p: {
const result = await FileApi.UploadFile(file[0]);
if (!p.disableSuccessSnackBar)
snackbar("The file was successfully uploaded!");
p.onUploaded(result);