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?)) 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 /// Get the list of movements of an account
pub async fn get_list_of_account(account_id: AccountInPath) -> HttpResult { pub async fn get_list_of_account(
Ok(HttpResponse::Ok() account_id: AccountInPath,
.json(movements_service::get_list_account(account_id.as_ref().id()).await?)) 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 /// Get a single movement information

View File

@ -50,10 +50,34 @@ export class MovementApi {
/** /**
* Get all the movements of an account * 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 ( return (
await APIClient.exec({ await APIClient.exec({
uri: `/account/${account_id}/movements`, uri: `/account/${account_id}/movements?${filtersS}`,
method: "GET", method: "GET",
}) })
).data; ).data;

View File

@ -25,7 +25,7 @@ import { AmountWidget } from "../widgets/AmountWidget";
import { AsyncWidget } from "../widgets/AsyncWidget"; import { AsyncWidget } from "../widgets/AsyncWidget";
import { DateWidget } from "../widgets/DateWidget"; import { DateWidget } from "../widgets/DateWidget";
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer"; import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
import { MovementWidget } from "../widgets/MovementWidget"; import { AsyncMovementWidget } from "../widgets/MovementWidget";
import { NewMovementWidget } from "../widgets/NewMovementWidget"; import { NewMovementWidget } from "../widgets/NewMovementWidget";
import { UploadedFileWidget } from "../widgets/UploadedFileWidget"; import { UploadedFileWidget } from "../widgets/UploadedFileWidget";
@ -271,7 +271,7 @@ function InboxTable(p: {
flex: 3, flex: 3,
renderCell: (params) => { renderCell: (params) => {
if (params.row.movement_id) 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 React from "react";
import { Movement, MovementApi } from "../api/MovementsApi"; import { Movement, MovementApi } from "../api/MovementsApi";
import { useAccounts } from "../hooks/AccountsListProvider"; import { useAccounts } from "../hooks/AccountsListProvider";
import { fmtDateFromTime } from "../utils/DateUtils";
import { AccountIconWidget } from "./AccountIconWidget"; import { AccountIconWidget } from "./AccountIconWidget";
import { AmountWidget } from "./AmountWidget"; import { AmountWidget } from "./AmountWidget";
import { AsyncWidget } from "./AsyncWidget"; import { AsyncWidget } from "./AsyncWidget";
export function MovementWidget(p: { id: number }): React.ReactElement { export function AsyncMovementWidget(p: { id: number }): React.ReactElement {
const accounts = useAccounts();
const [movement, setMovement] = React.useState<Movement | undefined>(); const [movement, setMovement] = React.useState<Movement | undefined>();
const load = async () => { const load = async () => {
@ -21,7 +20,15 @@ export function MovementWidget(p: { id: number }): React.ReactElement {
loadKey={p.id} loadKey={p.id}
load={load} load={load}
errMsg="Failed to load movement!" errMsg="Failed to load movement!"
build={() => ( build={() => <MovementWidget movement={movement!} />}
/>
);
}
export function MovementWidget(p: { movement: Movement }): React.ReactElement {
const accounts = useAccounts();
return (
<span <span
style={{ style={{
display: "inline-flex", display: "inline-flex",
@ -29,7 +36,7 @@ export function MovementWidget(p: { id: number }): React.ReactElement {
height: "100%", height: "100%",
}} }}
> >
{movement!.amount > 0 ? ( {p.movement!.amount > 0 ? (
<CallReceivedIcon color="success" /> <CallReceivedIcon color="success" />
) : ( ) : (
<CallMadeIcon color="error" /> <CallMadeIcon color="error" />
@ -44,23 +51,20 @@ export function MovementWidget(p: { id: number }): React.ReactElement {
}} }}
> >
<span style={{ height: "1em", lineHeight: 1 }}> <span style={{ height: "1em", lineHeight: 1 }}>
{movement?.label} {p.movement?.label}
</span> </span>
<span <span style={{ display: "flex", alignItems: "center", lineHeight: 1 }}>
style={{ display: "flex", alignItems: "center", lineHeight: 1 }} <AmountWidget amount={p.movement!.amount} />
>
<AmountWidget amount={movement!.amount} />
<span style={{ width: "0.5em" }} /> <span style={{ width: "0.5em" }} />
&bull; &bull;
<span style={{ width: "0.5em" }} /> <span style={{ width: "0.5em" }} />
<AccountIconWidget <AccountIconWidget account={accounts.get(p.movement!.account_id)!} />
account={accounts.get(movement!.account_id)!} {accounts.get(p.movement!.account_id)?.name}
/> <span style={{ width: "0.5em" }} />
{accounts.get(movement!.account_id)?.name} &bull; <span style={{ width: "0.5em" }} />
{fmtDateFromTime(p.movement.time)}
</span> </span>
</span> </span>
</span> </span>
)}
/>
); );
} }

View File

@ -113,7 +113,11 @@ export function NewMovementWidget(
> >
<UploadedFileWidget file_id={file.id} /> <UploadedFileWidget file_id={file.id} />
</span> </span>
<IconButton onClick={() => { setFile(undefined); }}> <IconButton
onClick={() => {
setFile(undefined);
}}
>
<ClearIcon /> <ClearIcon />
</IconButton> </IconButton>
</> </>
@ -156,7 +160,6 @@ export function NewMovementWidget(
{/* Amount input */} {/* Amount input */}
<AmountInput <AmountInput
editable editable
type="text"
placeholder="Amount" placeholder="Amount"
style={{ flex: 1, maxWidth: "110px" }} style={{ flex: 1, maxWidth: "110px" }}
value={amount ?? 0} 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"; import { AmountWidget } from "../AmountWidget";
export function AccountInput(p: { export function AccountInput(p: {
value: number; value?: number;
onChange: (value: number) => void; onChange: (value: number) => void;
label?: string; label?: string;
style?: React.CSSProperties;
}): React.ReactElement { }): React.ReactElement {
const accounts = useAccounts(); const accounts = useAccounts();
let current = p.value; 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; current = (accounts.list.default ?? accounts.list.list[0]).id;
p.onChange(current);
}
return ( return (
<Select <Select
value={p.value} value={current}
label={p.label ?? ""} label={p.label ?? ""}
onChange={(e) => p.onChange(Number(e.target.value))} onChange={(e) => p.onChange(Number(e.target.value))}
size="small" size="small"
style={p.style}
> >
{accounts.list.list.map((a) => ( {accounts.list.list.map((a) => (
<MenuItem value={a.id}> <MenuItem value={a.id}>

View File

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
import { TextFieldVariants } from "@mui/material";
enum InputState { enum InputState {
Normal, Normal,
@ -8,12 +9,13 @@ enum InputState {
} }
export function AmountInput(p: { export function AmountInput(p: {
label?: string;
editable: boolean; editable: boolean;
type: string;
placeholder: string; placeholder: string;
style: React.CSSProperties; style?: React.CSSProperties;
value: number; value: number;
onValueChange: (val: number) => void; onValueChange: (val: number) => void;
variant?: TextFieldVariants;
}): React.ReactElement { }): React.ReactElement {
const [state, setState] = React.useState(InputState.Normal); const [state, setState] = React.useState(InputState.Normal);

View File

@ -1,5 +1,6 @@
import { DatePicker } from "@mui/x-date-pickers"; import { DatePicker } from "@mui/x-date-pickers";
import { dateToTime, timeToDate } from "../../utils/DateUtils"; import { dateToTime, timeToDate } from "../../utils/DateUtils";
import { TextFieldVariants } from "@mui/material";
export function DateInput(p: { export function DateInput(p: {
ref?: React.Ref<HTMLInputElement>; ref?: React.Ref<HTMLInputElement>;
@ -9,6 +10,7 @@ export function DateInput(p: {
style?: React.CSSProperties; style?: React.CSSProperties;
value: number | undefined; value: number | undefined;
onValueChange: (v: number | undefined) => void; onValueChange: (v: number | undefined) => void;
variant?: TextFieldVariants;
}): React.ReactElement { }): React.ReactElement {
return ( return (
<DatePicker <DatePicker
@ -17,7 +19,7 @@ export function DateInput(p: {
label={p.label} label={p.label}
slotProps={{ slotProps={{
field: { ref: p.ref }, field: { ref: p.ref },
textField: { variant: "standard", style: p.style }, textField: { variant: p.variant ?? "standard", style: p.style },
}} }}
value={timeToDate(p.value)} value={timeToDate(p.value)}
onChange={(v) => { onChange={(v) => {