Add a widget to select a movement
This commit is contained in:
parent
3772dce01c
commit
5e4de364e0
@ -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) => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user