Add a widget to select a movement

This commit is contained in:
Pierre HUBERT 2025-05-13 19:29:26 +02:00
parent 3772dce01c
commit 5e4de364e0
9 changed files with 269 additions and 58 deletions

View File

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

View File

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

View File

@ -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} />;
},
},
{

View File

@ -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,46 +20,51 @@ export function MovementWidget(p: { id: number }): React.ReactElement {
loadKey={p.id}
load={load}
errMsg="Failed to load movement!"
build={() => (
<span
style={{
display: "inline-flex",
alignItems: "center",
height: "100%",
}}
>
{movement!.amount > 0 ? (
<CallReceivedIcon color="success" />
) : (
<CallMadeIcon color="error" />
)}
<span
style={{
marginLeft: "5px",
display: "inline-flex",
flexDirection: "column",
justifyContent: "center",
height: "100%",
}}
>
<span style={{ height: "1em", lineHeight: 1 }}>
{movement?.label}
</span>
<span
style={{ display: "flex", alignItems: "center", lineHeight: 1 }}
>
<AmountWidget amount={movement!.amount} />
<span style={{ width: "0.5em" }} />
&bull;
<span style={{ width: "0.5em" }} />
<AccountIconWidget
account={accounts.get(movement!.account_id)!}
/>
{accounts.get(movement!.account_id)?.name}
</span>
</span>
</span>
)}
build={() => <MovementWidget movement={movement!} />}
/>
);
}
export function MovementWidget(p: { movement: Movement }): React.ReactElement {
const accounts = useAccounts();
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
height: "100%",
}}
>
{p.movement!.amount > 0 ? (
<CallReceivedIcon color="success" />
) : (
<CallMadeIcon color="error" />
)}
<span
style={{
marginLeft: "5px",
display: "inline-flex",
flexDirection: "column",
justifyContent: "center",
height: "100%",
}}
>
<span style={{ height: "1em", lineHeight: 1 }}>
{p.movement?.label}
</span>
<span style={{ display: "flex", alignItems: "center", lineHeight: 1 }}>
<AmountWidget amount={p.movement!.amount} />
<span style={{ width: "0.5em" }} />
&bull;
<span style={{ width: "0.5em" }} />
<AccountIconWidget account={accounts.get(p.movement!.account_id)!} />
{accounts.get(p.movement!.account_id)?.name}
<span style={{ width: "0.5em" }} />
&bull; <span style={{ width: "0.5em" }} />
{fmtDateFromTime(p.movement.time)}
</span>
</span>
</span>
);
}

View File

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

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

View File

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

View File

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

View File

@ -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) => {