Can create inbox entries
This commit is contained in:
		@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user