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?))
|
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
|
||||||
|
@ -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;
|
||||||
|
@ -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} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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,46 +20,51 @@ 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!} />}
|
||||||
<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" }} />
|
|
||||||
•
|
|
||||||
<span style={{ width: "0.5em" }} />
|
|
||||||
<AccountIconWidget
|
|
||||||
account={accounts.get(movement!.account_id)!}
|
|
||||||
/>
|
|
||||||
{accounts.get(movement!.account_id)?.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" }} />
|
||||||
|
•
|
||||||
|
<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" }} />
|
||||||
|
• <span style={{ width: "0.5em" }} />
|
||||||
|
{fmtDateFromTime(p.movement.time)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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}
|
||||||
|
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";
|
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}>
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user