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,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" }} /> | ||||
|               • | ||||
|               <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" }} /> | ||||
|           • | ||||
|           <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} /> | ||||
|             </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