From 83f7e394798973404aa38ddef65d3deb0002677b Mon Sep 17 00:00:00 2001 From: ltabis Date: Wed, 26 Nov 2025 22:16:43 +0100 Subject: [PATCH 1/2] feat: add a currency edition page in settings --- .gitignore | 3 +- desktop/src-tauri/.gitignore | 2 +- desktop/src/App.tsx | 2 + desktop/src/pages/Currency.tsx | 220 +++++++++++++++++++++++++++++++++ desktop/src/pages/Settings.tsx | 21 ++++ 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 desktop/src/pages/Currency.tsx diff --git a/.gitignore b/.gitignore index 53d54ba..f7a783c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ dist-ssr *.sln *.sw? -target \ No newline at end of file +target +history.txt diff --git a/desktop/src-tauri/.gitignore b/desktop/src-tauri/.gitignore index b21bd68..31134e7 100644 --- a/desktop/src-tauri/.gitignore +++ b/desktop/src-tauri/.gitignore @@ -4,4 +4,4 @@ # Generated by Tauri # will have schema files for capabilities auto-completion -/gen/schemas +/gen/schemas \ No newline at end of file diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index a149c99..ea0cbf5 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -24,6 +24,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { SnackbarProvider } from "./contexts/Snackbar"; import Budget from "./pages/Budget"; import { useSettingStore } from "./stores/setting"; +import Currency from "./pages/Currency"; function Layout() { const store = useSettingStore(); @@ -138,6 +139,7 @@ export default function App() { } /> } /> } /> + } /> ); diff --git a/desktop/src/pages/Currency.tsx b/desktop/src/pages/Currency.tsx new file mode 100644 index 0000000..676157f --- /dev/null +++ b/desktop/src/pages/Currency.tsx @@ -0,0 +1,220 @@ +import { + Autocomplete, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + InputLabel, + MenuItem, + Select, + TextField, + Typography, +} from "@mui/material"; +import { Dispatch, FormEvent, SetStateAction, useState } from "react"; + +import { AccountIdentifiers } from "../../../cli/bindings/AccountIdentifiers"; +import { CreateSplitBudgetOptions } from "../../../cli/bindings/CreateSplitBudgetOptions"; +import { useDispatchSnackbar } from "../contexts/Snackbar"; +import { useBudgetNavigate } from "../hooks/budget"; +import { filterFloat } from "../utils"; + +import { Account } from "../../../cli/bindings/Account"; + +import { useAccountStore } from "../stores/account"; +import { useBudgetStore } from "../stores/budget"; +import Page from "./Page"; + +function AddCurrencyDialog({ + open, + setOpen, +}: { + open: boolean; + setOpen: Dispatch>; +}) { + const navigate = useBudgetNavigate(); + const budgetStore = useBudgetStore(); + const accountStore = useAccountStore(); + const dispatchSnackbar = useDispatchSnackbar()!; + + const [form, setForm] = useState< + Omit & { + income: string; + accounts: AccountIdentifiers[]; + } + >({ + name: "", + income: "0", + currency: "", + accounts: [], + }); + + // FIXME: filter using the backend. + const filterAccountByCurrency = ( + accounts: Map, + currency: string + ) => + Array.from(accounts.values()).filter( + (account) => account.currency === currency + ); + + const handleCloseForm = () => { + setOpen(false); + }; + + const handleValidAmount = () => isNaN(filterFloat(form.income)); + + const handleBudgetSubmission = async () => { + const income = filterFloat(form.income); + const accounts = form.accounts.map((account) => account.id); + + budgetStore + .create({ + ...form, + income, + accounts, + }) + .then((budget) => { + handleCloseForm(); + navigate({ id: budget.id, name: budget.name }); + }) + .catch((error) => + dispatchSnackbar({ type: "open", severity: "error", message: error }) + ); + }; + + return ( + ) => { + event.preventDefault(); + return handleBudgetSubmission(); + }, + }, + }} + > + Add budget + + + + setForm({ ...form, name: name.target.value })} + /> + + + + setForm({ ...form, income: income.target.value }) + } + error={handleValidAmount()} + helperText={handleValidAmount() && "Not a valid amount"} + /> + + + { + + + Currency + + + + + {form.currency !== "" && ( + account.name} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const { key, id, ...optionProps } = props; + return ( + + + + ); + }} + onChange={(_event, newAccounts) => + setForm({ ...form, accounts: newAccounts }) + } + /> + )} + + + } + + + + + + + ); +} +export default function () { + return ( + Currencies} + actions={[]} + > +

Empty

+
+ ); +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index cf5c7ff..b273ce2 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -18,6 +18,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { exportBackup, importBackup } from "../api"; import { useDispatchSnackbar } from "../contexts/Snackbar"; import { useSettingStore } from "../stores/setting"; +import { useNavigate } from "react-router-dom"; function SettingDescription({ children }: { children: ReactNode }) { return ( @@ -57,6 +58,7 @@ function SettingSection({ const SETTINGS_GRID_PADDING = 5; export default function Settings() { + const navigate = useNavigate(); const store = useSettingStore(); const dispatchSnackbar = useDispatchSnackbar()!; @@ -108,6 +110,25 @@ export default function Settings() { + + + Add and customize currencies + + + + + + From 7fd8bec8e6572fa64f2d78306a5255d1cd782dbe Mon Sep 17 00:00:00 2001 From: ltabis Date: Wed, 26 Nov 2025 22:56:00 +0100 Subject: [PATCH 2/2] feat: currency crud --- cli/src/currency.rs | 75 ++++++++++++++++++++++ cli/src/lib.rs | 1 + desktop/src-tauri/src/commands/currency.rs | 72 ++++++++++++++++++++- desktop/src-tauri/tests/currency.rs | 47 ++++++++++++++ 4 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 cli/src/currency.rs create mode 100644 desktop/src-tauri/tests/currency.rs diff --git a/cli/src/currency.rs b/cli/src/currency.rs new file mode 100644 index 0000000..1ffbcae --- /dev/null +++ b/cli/src/currency.rs @@ -0,0 +1,75 @@ +use surrealdb::{engine::local::Db, RecordId, Surreal}; + +use crate::Error; + +#[derive(ts_rs::TS)] +#[ts(export)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Currency { + pub name: String, + pub symbol: Option, + pub decimal_digits: usize, +} + +#[derive(ts_rs::TS)] +#[ts(export)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CurrencyWithId { + #[serde(flatten)] + pub data: Currency, + #[ts(type = "{ tb: string, id: { String: string }}")] + pub id: RecordId, +} + +#[derive(ts_rs::TS)] +#[ts(export)] +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct AddCurrencyOptions { + pub name: String, + pub symbol: Option, + pub decimal_digits: usize, +} + +pub async fn create( + db: &Surreal, + options: AddCurrencyOptions, +) -> Result { + let currency: Option = db + .create("currency") + .content(options) + .await + .map_err::(core::convert::Into::into)?; + + currency.ok_or(Error::RecordNotFound) +} + +pub async fn read(db: &Surreal, id: RecordId) -> Result { + db.select(id).await?.ok_or(Error::RecordNotFound) +} + +#[derive(ts_rs::TS)] +#[ts(export)] +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct UpdateCurrencyOptions { + #[ts(type = "{ tb: string, id: { String: string }}")] + pub id: RecordId, + pub name: Option, + pub symbol: Option, + pub decimal_digits: Option, +} + +pub async fn update( + db: &Surreal, + options: UpdateCurrencyOptions, +) -> Result<(), surrealdb::Error> { + let _: Option = db.update(options.id.clone()).merge(options).await?; + + Ok(()) +} + +// FIXME: opens a lot of complexity => what happens for accounts that uses this currency ? +pub async fn delete(db: &Surreal, id: RecordId) -> Result<(), surrealdb::Error> { + let _: Option = db.delete(id).await?; + + Ok(()) +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index fa661b6..d839d87 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -3,6 +3,7 @@ use surrealdb::RecordId; pub mod account; pub mod budget; +pub mod currency; pub mod migrations; pub mod portfolio; pub mod settings; diff --git a/desktop/src-tauri/src/commands/currency.rs b/desktop/src-tauri/src/commands/currency.rs index 54d307b..1b70070 100644 --- a/desktop/src-tauri/src/commands/currency.rs +++ b/desktop/src-tauri/src/commands/currency.rs @@ -1,7 +1,11 @@ -use surrealdb::engine::local::Db; use surrealdb::Surreal; +use surrealdb::{engine::local::Db, RecordId}; use tauri::State; -use thunes_cli::portfolio::currency::{Currency, ReadCurrencyOptions}; +use thunes_cli::currency::UpdateCurrencyOptions; +use thunes_cli::{ + currency::{AddCurrencyOptions, CurrencyWithId}, + portfolio::currency::{Currency, ReadCurrencyOptions}, +}; #[tauri::command] #[tracing::instrument(skip(database), ret(level = tracing::Level::DEBUG))] @@ -33,3 +37,67 @@ pub async fn get_currency( "failed to get currency data".to_string() }) } + +#[tauri::command] +#[tracing::instrument(skip(database), ret(level = tracing::Level::DEBUG))] +pub async fn add_currency( + database: State<'_, tokio::sync::Mutex>>, + options: AddCurrencyOptions, +) -> Result { + let database = database.lock().await; + + thunes_cli::currency::create(&database, options) + .await + .map_err(|error| { + error.trace(); + "failed to create currency".to_string() + }) +} + +#[tauri::command] +#[tracing::instrument(skip(database), ret(level = tracing::Level::DEBUG))] +pub async fn get_currency_2( + database: State<'_, tokio::sync::Mutex>>, + id: RecordId, +) -> Result { + let database = database.lock().await; + + thunes_cli::currency::read(&database, id) + .await + .map_err(|error| { + error.trace(); + "failed to get currency".to_string() + }) +} + +#[tauri::command] +#[tracing::instrument(skip(database), ret(level = tracing::Level::DEBUG))] +pub async fn update_currency( + database: State<'_, tokio::sync::Mutex>>, + options: UpdateCurrencyOptions, +) -> Result<(), String> { + let database = database.lock().await; + + thunes_cli::currency::update(&database, options) + .await + .map_err(|error| { + tracing::error!(%error, "database error"); + "failed to update currency".to_string() + }) +} + +#[tauri::command] +#[tracing::instrument(skip(database), ret(level = tracing::Level::DEBUG))] +pub async fn delete_currency( + database: State<'_, tokio::sync::Mutex>>, + id: surrealdb::RecordId, +) -> Result<(), String> { + let database = database.lock().await; + + thunes_cli::currency::delete(&database, id) + .await + .map_err(|error| { + tracing::error!(%error, "database error"); + "failed to delete currency".to_string() + }) +} diff --git a/desktop/src-tauri/tests/currency.rs b/desktop/src-tauri/tests/currency.rs new file mode 100644 index 0000000..da55753 --- /dev/null +++ b/desktop/src-tauri/tests/currency.rs @@ -0,0 +1,47 @@ +#[cfg(test)] +mod common; + +#[cfg(test)] +mod tests { + use tauri::Manager; + use thunes_cli::currency::{AddCurrencyOptions, CurrencyWithId}; + use thunes_lib::commands::currency::add_currency; + + async fn setup() -> (tauri::App, CurrencyWithId) { + let app = crate::common::setup().await; + let currency = add_currency( + app.state(), + AddCurrencyOptions { + name: "Euro".to_string(), + symbol: Some("€".to_string()), + decimal_digits: 2, + }, + ) + .await + .expect("failed to create currency"); + + (app, currency) + } + + #[tokio::test] + pub async fn test_add_currency() { + let (app, currency1) = setup().await; + let currency2 = add_currency( + app.state(), + AddCurrencyOptions { + name: "Bitcoin".to_string(), + symbol: Some("₿".to_string()), + decimal_digits: 8, + }, + ) + .await + .expect("failed to create currency"); + + assert_eq!(currency1.data.name, "Euro".to_string()); + assert_eq!(currency1.data.symbol, Some("€".to_string())); + assert_eq!(currency1.data.decimal_digits, 2); + assert_eq!(currency2.data.name, "Bitcoin".to_string()); + assert_eq!(currency2.data.symbol, Some("₿".to_string())); + assert_eq!(currency2.data.decimal_digits, 8); + } +}