Add a widget to select a movement
This commit is contained in:
		@@ -22,10 +22,55 @@ pub async fn get_accounts_balances(auth: AuthExtractor) -> HttpResult {
 | 
			
		||||
    Ok(HttpResponse::Ok().json(movements_service::get_balances(auth.user_id()).await?))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Select movements filter
 | 
			
		||||
#[derive(serde::Deserialize)]
 | 
			
		||||
pub struct SelectMovementFilters {
 | 
			
		||||
    amount_min: Option<f32>,
 | 
			
		||||
    amount_max: Option<f32>,
 | 
			
		||||
    time_min: Option<i64>,
 | 
			
		||||
    time_max: Option<i64>,
 | 
			
		||||
    label: Option<String>,
 | 
			
		||||
    limit: Option<usize>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Get the list of movements of an account
 | 
			
		||||
pub async fn get_list_of_account(account_id: AccountInPath) -> HttpResult {
 | 
			
		||||
    Ok(HttpResponse::Ok()
 | 
			
		||||
        .json(movements_service::get_list_account(account_id.as_ref().id()).await?))
 | 
			
		||||
pub async fn get_list_of_account(
 | 
			
		||||
    account_id: AccountInPath,
 | 
			
		||||
    query: web::Query<SelectMovementFilters>,
 | 
			
		||||
) -> HttpResult {
 | 
			
		||||
    let mut list = movements_service::get_list_account(account_id.as_ref().id()).await?;
 | 
			
		||||
 | 
			
		||||
    if let Some(amount_min) = query.amount_min {
 | 
			
		||||
        list.retain(|l| l.amount >= amount_min);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if let Some(amount_max) = query.amount_max {
 | 
			
		||||
        list.retain(|l| l.amount <= amount_max);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if let Some(time_min) = query.time_min {
 | 
			
		||||
        list.retain(|l| l.time >= time_min);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if let Some(time_max) = query.time_max {
 | 
			
		||||
        list.retain(|l| l.time <= time_max);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if let Some(label) = &query.label {
 | 
			
		||||
        list.retain(|l| {
 | 
			
		||||
            l.label
 | 
			
		||||
                .to_lowercase()
 | 
			
		||||
                .contains(label.to_lowercase().as_str())
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if let Some(limit) = query.limit {
 | 
			
		||||
        if list.len() > limit {
 | 
			
		||||
            list = list[..limit].to_vec();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(HttpResponse::Ok().json(list))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Get a single movement information
 | 
			
		||||
 
 | 
			
		||||
@@ -50,10 +50,34 @@ export class MovementApi {
 | 
			
		||||
  /**
 | 
			
		||||
   * Get all the movements of an account
 | 
			
		||||
   */
 | 
			
		||||
  static async GetAccountMovements(account_id: number): Promise<Movement[]> {
 | 
			
		||||
  static async GetAccountMovements(
 | 
			
		||||
    account_id: number,
 | 
			
		||||
    filters?: {
 | 
			
		||||
      amount_min?: number;
 | 
			
		||||
      amount_max?: number;
 | 
			
		||||
      time_min?: number;
 | 
			
		||||
      time_max?: number;
 | 
			
		||||
      label?: string;
 | 
			
		||||
      limit?: number;
 | 
			
		||||
    }
 | 
			
		||||
  ): Promise<Movement[]> {
 | 
			
		||||
    let filtersS = new URLSearchParams();
 | 
			
		||||
    if (filters) {
 | 
			
		||||
      if (filters.amount_min)
 | 
			
		||||
        filtersS.append("amount_min", filters.amount_min.toString());
 | 
			
		||||
      if (filters.amount_max)
 | 
			
		||||
        filtersS.append("amount_max", filters.amount_max.toString());
 | 
			
		||||
      if (filters.time_min)
 | 
			
		||||
        filtersS.append("time_min", filters.time_min.toString());
 | 
			
		||||
      if (filters.time_max)
 | 
			
		||||
        filtersS.append("time_max", filters.time_max.toString());
 | 
			
		||||
      if (filters.label) filtersS.append("label", filters.label);
 | 
			
		||||
      if (filters.limit) filtersS.append("limit", filters.limit);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      await APIClient.exec({
 | 
			
		||||
        uri: `/account/${account_id}/movements`,
 | 
			
		||||
        uri: `/account/${account_id}/movements?${filtersS}`,
 | 
			
		||||
        method: "GET",
 | 
			
		||||
      })
 | 
			
		||||
    ).data;
 | 
			
		||||
 
 | 
			
		||||
@@ -25,7 +25,7 @@ import { AmountWidget } from "../widgets/AmountWidget";
 | 
			
		||||
import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
			
		||||
import { DateWidget } from "../widgets/DateWidget";
 | 
			
		||||
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
 | 
			
		||||
import { MovementWidget } from "../widgets/MovementWidget";
 | 
			
		||||
import { AsyncMovementWidget } from "../widgets/MovementWidget";
 | 
			
		||||
import { NewMovementWidget } from "../widgets/NewMovementWidget";
 | 
			
		||||
import { UploadedFileWidget } from "../widgets/UploadedFileWidget";
 | 
			
		||||
 | 
			
		||||
@@ -271,7 +271,7 @@ function InboxTable(p: {
 | 
			
		||||
      flex: 3,
 | 
			
		||||
      renderCell: (params) => {
 | 
			
		||||
        if (params.row.movement_id)
 | 
			
		||||
          return <MovementWidget id={params.row.movement_id} />;
 | 
			
		||||
          return <AsyncMovementWidget id={params.row.movement_id} />;
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,13 +3,12 @@ import CallReceivedIcon from "@mui/icons-material/CallReceived";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Movement, MovementApi } from "../api/MovementsApi";
 | 
			
		||||
import { useAccounts } from "../hooks/AccountsListProvider";
 | 
			
		||||
import { fmtDateFromTime } from "../utils/DateUtils";
 | 
			
		||||
import { AccountIconWidget } from "./AccountIconWidget";
 | 
			
		||||
import { AmountWidget } from "./AmountWidget";
 | 
			
		||||
import { AsyncWidget } from "./AsyncWidget";
 | 
			
		||||
 | 
			
		||||
export function MovementWidget(p: { id: number }): React.ReactElement {
 | 
			
		||||
  const accounts = useAccounts();
 | 
			
		||||
 | 
			
		||||
export function AsyncMovementWidget(p: { id: number }): React.ReactElement {
 | 
			
		||||
  const [movement, setMovement] = React.useState<Movement | undefined>();
 | 
			
		||||
 | 
			
		||||
  const load = async () => {
 | 
			
		||||
@@ -21,7 +20,15 @@ export function MovementWidget(p: { id: number }): React.ReactElement {
 | 
			
		||||
      loadKey={p.id}
 | 
			
		||||
      load={load}
 | 
			
		||||
      errMsg="Failed to load movement!"
 | 
			
		||||
      build={() => (
 | 
			
		||||
      build={() => <MovementWidget movement={movement!} />}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function MovementWidget(p: { movement: Movement }): React.ReactElement {
 | 
			
		||||
  const accounts = useAccounts();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <span
 | 
			
		||||
      style={{
 | 
			
		||||
        display: "inline-flex",
 | 
			
		||||
@@ -29,7 +36,7 @@ export function MovementWidget(p: { id: number }): React.ReactElement {
 | 
			
		||||
        height: "100%",
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
          {movement!.amount > 0 ? (
 | 
			
		||||
      {p.movement!.amount > 0 ? (
 | 
			
		||||
        <CallReceivedIcon color="success" />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <CallMadeIcon color="error" />
 | 
			
		||||
@@ -44,23 +51,20 @@ export function MovementWidget(p: { id: number }): React.ReactElement {
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <span style={{ height: "1em", lineHeight: 1 }}>
 | 
			
		||||
              {movement?.label}
 | 
			
		||||
          {p.movement?.label}
 | 
			
		||||
        </span>
 | 
			
		||||
            <span
 | 
			
		||||
              style={{ display: "flex", alignItems: "center", lineHeight: 1 }}
 | 
			
		||||
            >
 | 
			
		||||
              <AmountWidget amount={movement!.amount} />
 | 
			
		||||
        <span style={{ display: "flex", alignItems: "center", lineHeight: 1 }}>
 | 
			
		||||
          <AmountWidget amount={p.movement!.amount} />
 | 
			
		||||
          <span style={{ width: "0.5em" }} />
 | 
			
		||||
          •
 | 
			
		||||
          <span style={{ width: "0.5em" }} />
 | 
			
		||||
              <AccountIconWidget
 | 
			
		||||
                account={accounts.get(movement!.account_id)!}
 | 
			
		||||
              />
 | 
			
		||||
              {accounts.get(movement!.account_id)?.name}
 | 
			
		||||
          <AccountIconWidget account={accounts.get(p.movement!.account_id)!} />
 | 
			
		||||
          {accounts.get(p.movement!.account_id)?.name}
 | 
			
		||||
          <span style={{ width: "0.5em" }} />
 | 
			
		||||
          • <span style={{ width: "0.5em" }} />
 | 
			
		||||
          {fmtDateFromTime(p.movement.time)}
 | 
			
		||||
        </span>
 | 
			
		||||
      </span>
 | 
			
		||||
    </span>
 | 
			
		||||
      )}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -113,7 +113,11 @@ export function NewMovementWidget(
 | 
			
		||||
            >
 | 
			
		||||
              <UploadedFileWidget file_id={file.id} />
 | 
			
		||||
            </span>
 | 
			
		||||
            <IconButton onClick={() => { setFile(undefined); }}>
 | 
			
		||||
            <IconButton
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                setFile(undefined);
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <ClearIcon />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </>
 | 
			
		||||
@@ -156,7 +160,6 @@ export function NewMovementWidget(
 | 
			
		||||
      {/* Amount input */}
 | 
			
		||||
      <AmountInput
 | 
			
		||||
        editable
 | 
			
		||||
        type="text"
 | 
			
		||||
        placeholder="Amount"
 | 
			
		||||
        style={{ flex: 1, maxWidth: "110px" }}
 | 
			
		||||
        value={amount ?? 0}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										127
									
								
								moneymgr_web/src/widgets/SelectMovementWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								moneymgr_web/src/widgets/SelectMovementWidget.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,127 @@
 | 
			
		||||
import { ListItem, ListItemButton, Paper, Typography } from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { AccountInput } from "./forms/AccountInput";
 | 
			
		||||
import { AmountInput } from "./forms/AmountInput";
 | 
			
		||||
import { DateInput } from "./forms/DateInput";
 | 
			
		||||
import { TextInput } from "./forms/TextInput";
 | 
			
		||||
import { Movement, MovementApi } from "../api/MovementsApi";
 | 
			
		||||
import { AsyncWidget } from "./AsyncWidget";
 | 
			
		||||
import { AsyncMovementWidget, MovementWidget } from "./MovementWidget";
 | 
			
		||||
 | 
			
		||||
export function SelectMovementWidget(p: {
 | 
			
		||||
  value?: number;
 | 
			
		||||
  onChange: (movementId: number) => void;
 | 
			
		||||
  initialValues?: {
 | 
			
		||||
    amount?: number;
 | 
			
		||||
    accountId?: number;
 | 
			
		||||
    date?: number;
 | 
			
		||||
    label?: string;
 | 
			
		||||
  };
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const [amount, setAmount] = React.useState<number | undefined>(
 | 
			
		||||
    p.initialValues?.amount
 | 
			
		||||
  );
 | 
			
		||||
  const [accountId, setAccountId] = React.useState<number | undefined>(
 | 
			
		||||
    p.initialValues?.accountId
 | 
			
		||||
  );
 | 
			
		||||
  const [date, setDate] = React.useState<number | undefined>(
 | 
			
		||||
    p.initialValues?.date
 | 
			
		||||
  );
 | 
			
		||||
  const [label, setLabel] = React.useState<string | undefined>(
 | 
			
		||||
    p.initialValues?.label
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const filters = {
 | 
			
		||||
    label: label,
 | 
			
		||||
    amount_min: amount ? amount - 0.5 : undefined,
 | 
			
		||||
    amount_max: amount ? amount + 0.5 : undefined,
 | 
			
		||||
    time_min: date ? date - 3600 * 24 : undefined,
 | 
			
		||||
    time_max: date ? date + 3600 * 24 : undefined,
 | 
			
		||||
    limit: 10,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const [list, setList] = React.useState<Movement[] | undefined>();
 | 
			
		||||
 | 
			
		||||
  const load = async () => {
 | 
			
		||||
    if (accountId)
 | 
			
		||||
      setList(await MovementApi.GetAccountMovements(accountId, filters));
 | 
			
		||||
    else setList(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Paper style={{ padding: "10px" }}>
 | 
			
		||||
      <div
 | 
			
		||||
        style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
 | 
			
		||||
      >
 | 
			
		||||
        <AccountInput
 | 
			
		||||
          value={accountId}
 | 
			
		||||
          onChange={setAccountId}
 | 
			
		||||
          style={{ flex: 20 }}
 | 
			
		||||
        />
 | 
			
		||||
        <span style={{ flex: 1 }} />
 | 
			
		||||
        <AmountInput
 | 
			
		||||
          editable
 | 
			
		||||
          value={amount ?? 0}
 | 
			
		||||
          onValueChange={setAmount}
 | 
			
		||||
          label="Amount"
 | 
			
		||||
          placeholder="Amount"
 | 
			
		||||
          variant="outlined"
 | 
			
		||||
          style={{ height: "100%", flex: 20 }}
 | 
			
		||||
        />
 | 
			
		||||
        <span style={{ flex: 1 }} />
 | 
			
		||||
        <DateInput
 | 
			
		||||
          editable
 | 
			
		||||
          value={date}
 | 
			
		||||
          onValueChange={setDate}
 | 
			
		||||
          label="Date"
 | 
			
		||||
          style={{ flex: 20 }}
 | 
			
		||||
          variant="outlined"
 | 
			
		||||
        />
 | 
			
		||||
        <span style={{ flex: 1 }} />
 | 
			
		||||
        <TextInput
 | 
			
		||||
          editable
 | 
			
		||||
          value={label}
 | 
			
		||||
          onValueChange={setLabel}
 | 
			
		||||
          label="Label"
 | 
			
		||||
          variant="outlined"
 | 
			
		||||
          style={{ flex: 20 }}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <AsyncWidget
 | 
			
		||||
        loadKey={accountId + "/" + JSON.stringify(filters)}
 | 
			
		||||
        load={load}
 | 
			
		||||
        errMsg="Failed to load the list of movements!"
 | 
			
		||||
        build={() => {
 | 
			
		||||
          if (list === null)
 | 
			
		||||
            return (
 | 
			
		||||
              <Typography style={{ textAlign: "center", padding: "20px" }}>
 | 
			
		||||
                Select an account to begin research.
 | 
			
		||||
              </Typography>
 | 
			
		||||
            );
 | 
			
		||||
          if (list?.length === 0)
 | 
			
		||||
            return (
 | 
			
		||||
              <Typography style={{ textAlign: "center", padding: "20px" }}>
 | 
			
		||||
                No result.
 | 
			
		||||
              </Typography>
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
          return (
 | 
			
		||||
            <>
 | 
			
		||||
              {list?.map((entry) => (
 | 
			
		||||
                <ListItem>
 | 
			
		||||
                  <ListItemButton
 | 
			
		||||
                    selected={entry.id === p.value}
 | 
			
		||||
                    onClick={() => p.onChange(entry.id)}
 | 
			
		||||
                  >
 | 
			
		||||
                    <MovementWidget movement={entry} />
 | 
			
		||||
                  </ListItemButton>
 | 
			
		||||
                </ListItem>
 | 
			
		||||
              ))}
 | 
			
		||||
            </>
 | 
			
		||||
          );
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </Paper>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -4,21 +4,25 @@ import { useAccounts } from "../../hooks/AccountsListProvider";
 | 
			
		||||
import { AmountWidget } from "../AmountWidget";
 | 
			
		||||
 | 
			
		||||
export function AccountInput(p: {
 | 
			
		||||
  value: number;
 | 
			
		||||
  value?: number;
 | 
			
		||||
  onChange: (value: number) => void;
 | 
			
		||||
  label?: string;
 | 
			
		||||
  style?: React.CSSProperties;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const accounts = useAccounts();
 | 
			
		||||
  let current = p.value;
 | 
			
		||||
  if (!current && accounts.list.list.length > 0)
 | 
			
		||||
  if (!current && accounts.list.list.length > 0) {
 | 
			
		||||
    current = (accounts.list.default ?? accounts.list.list[0]).id;
 | 
			
		||||
    p.onChange(current);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Select
 | 
			
		||||
      value={p.value}
 | 
			
		||||
      value={current}
 | 
			
		||||
      label={p.label ?? ""}
 | 
			
		||||
      onChange={(e) => p.onChange(Number(e.target.value))}
 | 
			
		||||
      size="small"
 | 
			
		||||
      style={p.style}
 | 
			
		||||
    >
 | 
			
		||||
      {accounts.list.list.map((a) => (
 | 
			
		||||
        <MenuItem value={a.id}>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { TextInput } from "./TextInput";
 | 
			
		||||
import { TextFieldVariants } from "@mui/material";
 | 
			
		||||
 | 
			
		||||
enum InputState {
 | 
			
		||||
  Normal,
 | 
			
		||||
@@ -8,12 +9,13 @@ enum InputState {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AmountInput(p: {
 | 
			
		||||
  label?: string;
 | 
			
		||||
  editable: boolean;
 | 
			
		||||
  type: string;
 | 
			
		||||
  placeholder: string;
 | 
			
		||||
  style: React.CSSProperties;
 | 
			
		||||
  style?: React.CSSProperties;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onValueChange: (val: number) => void;
 | 
			
		||||
  variant?: TextFieldVariants;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const [state, setState] = React.useState(InputState.Normal);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import { DatePicker } from "@mui/x-date-pickers";
 | 
			
		||||
import { dateToTime, timeToDate } from "../../utils/DateUtils";
 | 
			
		||||
import { TextFieldVariants } from "@mui/material";
 | 
			
		||||
 | 
			
		||||
export function DateInput(p: {
 | 
			
		||||
  ref?: React.Ref<HTMLInputElement>;
 | 
			
		||||
@@ -9,6 +10,7 @@ export function DateInput(p: {
 | 
			
		||||
  style?: React.CSSProperties;
 | 
			
		||||
  value: number | undefined;
 | 
			
		||||
  onValueChange: (v: number | undefined) => void;
 | 
			
		||||
  variant?: TextFieldVariants;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <DatePicker
 | 
			
		||||
@@ -17,7 +19,7 @@ export function DateInput(p: {
 | 
			
		||||
      label={p.label}
 | 
			
		||||
      slotProps={{
 | 
			
		||||
        field: { ref: p.ref },
 | 
			
		||||
        textField: { variant: "standard", style: p.style },
 | 
			
		||||
        textField: { variant: p.variant ?? "standard", style: p.style },
 | 
			
		||||
      }}
 | 
			
		||||
      value={timeToDate(p.value)}
 | 
			
		||||
      onChange={(v) => {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user