From 759f91a9a2a4be08ea780e7b7d29681e6018aad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Forc=C3=A9n=20Mu=C3=B1oz?= Date: Wed, 24 Apr 2024 23:20:37 +0200 Subject: [PATCH] Added new views, joined rules and categories --- base.css | 31 ++++++++ static/styles.css | 61 +++++++++++++- templates/accounts.html | 18 +++-- templates/base.html | 1 - templates/categories_new.html | 39 ++++----- templates/classifiers.html | 50 ++++++------ templates/index.html | 88 ++++++++++++++++---- templates/transaction.html | 41 ++++++++++ webserver/src/main.rs | 2 + webserver/src/routes/ui.rs | 106 ++++++++++++++++++++++++- webserver/src/routes/ui/account.rs | 16 ++-- webserver/src/routes/ui/transaction.rs | 99 +++++++++++++++++++++++ 12 files changed, 469 insertions(+), 83 deletions(-) create mode 100644 templates/transaction.html create mode 100644 webserver/src/routes/ui/transaction.rs diff --git a/base.css b/base.css index b5c61c9..1a5cb65 100644 --- a/base.css +++ b/base.css @@ -1,3 +1,34 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.ars-input { + margin: 0.25rem; + margin-bottom: 0.75rem; +} + +.ars-input input { + width: 100%; + padding: 0.25rem 0.75rem; + border: solid 1px #57534e; + border-radius: 0.5rem; +} + +.ars-input select { + width: 100%; + padding: 0.25rem 0.75rem; + border: solid 1px #57534e; + border-radius: 0.5rem; + background-color: transparent; +} + +.ars-button { + padding: 0.25rem 0.75rem; + border: solid 1px #57534e; + border-radius: 0.5rem; +} + +.ars-button:hover { + background: #57534e77; + cursor: pointer; +} diff --git a/static/styles.css b/static/styles.css index 49beb93..19a77f8 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1,5 +1,5 @@ /* -! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com +! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com */ /* @@ -211,6 +211,8 @@ textarea { /* 1 */ line-height: inherit; /* 1 */ + letter-spacing: inherit; + /* 1 */ color: inherit; /* 1 */ margin: 0; @@ -234,9 +236,9 @@ select { */ button, -[type='button'], -[type='reset'], -[type='submit'] { +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { -webkit-appearance: button; /* 1 */ background-color: transparent; @@ -492,6 +494,10 @@ video { --tw-backdrop-opacity: ; --tw-backdrop-saturate: ; --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; } ::backdrop { @@ -542,6 +548,10 @@ video { --tw-backdrop-opacity: ; --tw-backdrop-saturate: ; --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; } .container { @@ -578,6 +588,18 @@ video { } } +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + .block { display: block; } @@ -636,6 +658,37 @@ video { line-height: 1.75rem; } +.ars-input { + margin: 0.25rem; + margin-bottom: 0.75rem; +} + +.ars-input input { + width: 100%; + padding: 0.25rem 0.75rem; + border: solid 1px #57534e; + border-radius: 0.5rem; +} + +.ars-input select { + width: 100%; + padding: 0.25rem 0.75rem; + border: solid 1px #57534e; + border-radius: 0.5rem; + background-color: transparent; +} + +.ars-button { + padding: 0.25rem 0.75rem; + border: solid 1px #57534e; + border-radius: 0.5rem; +} + +.ars-button:hover { + background: #57534e77; + cursor: pointer; +} + .hover\:bg-stone-200:hover { --tw-bg-opacity: 1; background-color: rgb(231 229 228 / var(--tw-bg-opacity)); diff --git a/templates/accounts.html b/templates/accounts.html index f63fa7c..3ed3e1b 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -8,22 +8,26 @@
- +
- - - - + + + + + + {% for tx in transactions %} - + - + + + {% endfor %} diff --git a/templates/base.html b/templates/base.html index 53e5666..d183c85 100644 --- a/templates/base.html +++ b/templates/base.html @@ -27,7 +27,6 @@
diff --git a/templates/categories_new.html b/templates/categories_new.html index 5903d33..e930a23 100644 --- a/templates/categories_new.html +++ b/templates/categories_new.html @@ -1,28 +1,21 @@ {% extends "base.html" %} {% block title %}Create category{% endblock title %} {% block body %} -
- - - + +
+ +
+
+ +
+
+ +
- {% endblock body %} diff --git a/templates/classifiers.html b/templates/classifiers.html index f0decd6..88f51a9 100644 --- a/templates/classifiers.html +++ b/templates/classifiers.html @@ -1,35 +1,38 @@ {% extends "base.html" %} {% block title %}Rules{% endblock title %} {% block body %} -
- New -
-
DescriptionDateAmountCategoryDescriptionDateAmountAccCategoryLink
{{tx.description}}{{tx.transaction_timestamp}}{{tx.tx_date}} {{tx.amount/100}}{{tx.category}}{{tx.accumulated/100}}{% if tx.category %}{{categories[tx.category]}}{% endif %}Go to
- - - - - - - - - {% for category in categories %} - - - - - - {% endfor %} - -
IdNameDescription
{{category.category_id}}{{category.name}}{{category.description}}
+
+
+ New +
+ + + + + + + + + + {% for category in categories %} + + + + + + {% endfor %} + +
IdNameDescription
{{category.category_id}}{{category.name}}{{category.description}}
+
New
- +
- + @@ -42,4 +45,5 @@ {% endfor %}
CategoríaCategoría Regla
+
{% endblock body %} diff --git a/templates/index.html b/templates/index.html index d66e104..cc228ef 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,33 +1,91 @@ {% extends "base.html" %} {% block title %}Index{% endblock title %} {% block body %} -

Accounts

-
- {% for account in accounts %} - {{account.account_name}} - {% endfor %} -
-

Last transactions

-
- +
+

Accounts

+
+ - - - + + + + + + {% for account in accounts %} + + + + + + + {% endfor %} + +
ID DescriptionDateAmountCategoryAccumulatedGo to
{{ account.id }}{{ account.description }}{{ account.accumulated | round(precision=2) }} + {{ account.description }} +
+
+
+

Last month summary

+
+ +
+
+
+

Last transactions

+ + + + + + + {% for tx in transactions %} - + - + - + {% endfor %}
DescriptionDateAmountCategory
{{tx.description}}{{tx.transaction_timestamp}}{{tx.tx_date}} {{tx.amount/100}}{{tx.category}}{% if tx.category %}{{categories[tx.category]}}{% endif %}
+ +
{% endblock body %} diff --git a/templates/transaction.html b/templates/transaction.html new file mode 100644 index 0000000..75dda7f --- /dev/null +++ b/templates/transaction.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Transaction {{tx_id}}{% endblock title %} +{% block body %} +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+{% endblock body %} diff --git a/webserver/src/main.rs b/webserver/src/main.rs index fceb693..27c4f75 100644 --- a/webserver/src/main.rs +++ b/webserver/src/main.rs @@ -42,6 +42,8 @@ async fn main() { "/accounts/id/:id/transactions/add", post(routes::ui::account::add_transactions_action), ) + .route("/transaction/:id", get(routes::ui::transaction::view)) + .route("/transaction/:id", post(routes::ui::transaction::update)) .route( "/classifiers", get(routes::ui::classifier::view_classifiers), diff --git a/webserver/src/routes/ui.rs b/webserver/src/routes/ui.rs index ec45f47..4d1eaad 100644 --- a/webserver/src/routes/ui.rs +++ b/webserver/src/routes/ui.rs @@ -1,17 +1,57 @@ -use std::sync::Arc; +use std::{borrow::BorrowMut, collections::HashMap, sync::Arc}; use axum::{extract::State, response::IntoResponse}; +use chrono::{DateTime, Utc}; use hyper::{header::CONTENT_TYPE, StatusCode}; +use serde::Serialize; use sqlx::SqlitePool; use tera::{Context, Tera}; use crate::users::UserToken; -use accounters::models::{account::Account, transaction::Transaction}; +use accounters::models::{account::Account, categories::Category, transaction::Transaction}; pub mod account; pub mod categories; pub mod classifier; pub mod rules; +pub mod transaction; + +#[derive(Serialize)] +struct AccountRender { + id: i32, + description: String, + accumulated: f32, +} + +impl AccountRender { + async fn from_account(pool: &SqlitePool, acc: Account) -> Self { + let last_acc = Transaction::list(pool, acc.get_id(), 1, 0, false) + .await + .map_or(0.0, |x| { + x.get(0) + .map_or(0.0, |x| (x.get_accumulated() as f32) / 100.0) + }); + Self { + id: acc.get_id(), + description: acc.get_account_name().to_string(), + accumulated: last_acc, + } + } +} + +fn hm_sort(hm: HashMap, collapse: usize) -> Vec<(i32, i64)> { + let mut res: Vec<(i32, i64)> = hm.into_iter().collect(); + res.sort_unstable_by(|a, b| b.1.cmp(&a.1)); + if res.len() > collapse { + let rest = res + .split_off(collapse) + .iter() + .fold(0i64, |acc, item| acc + item.1); + let last = res.last_mut().unwrap(); + *last = (-1, last.1 + rest); + } + res +} pub async fn index( State(db): State>, @@ -21,7 +61,65 @@ pub async fn index( let mut ctx = Context::new(); let accounts = Account::list(db.as_ref(), uid.user_id).await.unwrap(); - ctx.insert("accounts", &accounts); + let mut acc_render = Vec::new(); + + for acc in accounts.into_iter() { + acc_render.push(AccountRender::from_account(db.as_ref(), acc).await); + } + + ctx.insert("accounts", &acc_render); + + let last_month = Transaction::list_by_date( + db.as_ref(), + uid.user_id, + Some(Utc::now() - chrono::Duration::days(30)), + Some(Utc::now()), + None, + false, + ) + .await + .unwrap(); + + let mut categories: HashMap = Category::list(db.as_ref()) + .await + .unwrap() + .iter() + .map(|x| (x.category_id, x.name.clone())) + .collect(); + categories.insert(0, String::from("Unclassified")); + ctx.insert("categories", &categories); + + let mut income: HashMap = HashMap::new(); + let mut expenses: HashMap = HashMap::new(); + + for tx in last_month.iter() { + if tx.get_amount() > 0 { + let acc = income + .entry(tx.get_category().unwrap_or(0)) + .or_default() + .borrow_mut(); + *acc = *acc + tx.get_amount() as i64; + } else { + let acc = expenses + .entry(tx.get_category().unwrap_or(0)) + .or_default() + .borrow_mut(); + *acc = *acc - tx.get_amount() as i64; + } + } + + let income = hm_sort(income, 5); + let expenses = hm_sort(expenses, 5); + ctx.insert("income", &income); + ctx.insert("expenses", &expenses); + + let mut colors = Vec::new(); + colors.extend_from_slice(&(["85FF33", "60F000", "46AF00", "2C6D00", "1A4200"][..income.len()])); + + colors + .extend_from_slice(&(["FF3333", "C50000", "830000", "570000", "420000"][..expenses.len()])); + + ctx.insert("colors", &colors); let transactions = Transaction::list_by_user(db.as_ref(), uid.user_id, 10, 0, false) .await @@ -37,7 +135,7 @@ pub async fn index( Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, [(CONTENT_TYPE, "text/plain")], - format!("{e}"), + format!("{e:?}"), ), } } diff --git a/webserver/src/routes/ui/account.rs b/webserver/src/routes/ui/account.rs index bf50c20..d2bfe68 100644 --- a/webserver/src/routes/ui/account.rs +++ b/webserver/src/routes/ui/account.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use axum::{ extract::{Path, Query, State}, @@ -12,10 +12,7 @@ use sqlx::SqlitePool; use tera::{Context, Tera}; use crate::users::UserToken; -use accounters::models::{ - account::Account, - transaction::{Transaction, TxConflictResolutionMode}, -}; +use accounters::models::{account::Account, categories::Category, transaction::Transaction}; #[derive(Deserialize)] pub struct AccountViewParams { @@ -51,6 +48,14 @@ pub async fn list( ); } + let categories: HashMap = Category::list(db.as_ref()) + .await + .unwrap() + .iter() + .map(|x| (x.category_id, x.name.clone())) + .collect(); + ctx.insert("categories", &categories); + let n_entries = entries.unwrap_or(10).max(10); let page = page.unwrap_or(0).max(0); @@ -146,7 +151,6 @@ pub async fn add_transactions_action( &tx.date, None, (tx.amount * 100.0).round() as i32, - TxConflictResolutionMode::Nothing, ) .await { diff --git a/webserver/src/routes/ui/transaction.rs b/webserver/src/routes/ui/transaction.rs new file mode 100644 index 0000000..e9f7f90 --- /dev/null +++ b/webserver/src/routes/ui/transaction.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use accounters::models::{categories::Category, transaction::Transaction}; +use axum::{ + extract::{Path, State}, + response::IntoResponse, + Form, +}; +use chrono::{DateTime, Utc}; +use hyper::{header, StatusCode}; +use serde::{Deserialize, Deserializer}; +use sqlx::SqlitePool; +use tera::Tera; + +use crate::users::UserToken; + +pub async fn view( + db: State>, + tmpl: State>, + user: UserToken, + Path(id): Path, +) -> impl IntoResponse { + let tx = Transaction::get_by_id(db.as_ref(), id).await.unwrap(); + let mut ctx = tera::Context::new(); + ctx.insert("tx_id", &id); + ctx.insert("tx", &tx); + + let categories = Category::list(db.as_ref()).await.unwrap(); + ctx.insert("categories", &categories); + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html;charset=utf-8")], + tmpl.render("transaction.html", &ctx).unwrap(), + ) +} + +fn deserialize_optional<'de, D>(data: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let str = String::deserialize(data)?; + if str.is_empty() { + Ok(None) + } else { + Ok(Some(str.parse().unwrap())) + } +} + +#[derive(Deserialize, Debug)] +pub struct TxUpdateRequest { + description: String, + date: DateTime, + amount: f32, + #[serde(deserialize_with = "deserialize_optional")] + category: Option, +} + +pub async fn update( + db: State>, + tmpl: State>, + user: UserToken, + Path(id): Path, + Form(req): Form, +) -> impl IntoResponse { + let ret_str = format!("/transaction/{id}"); + let mut tx = match Transaction::get_by_id(db.as_ref(), id).await { + Ok(tx) => tx, + Err(e) => { + return ( + StatusCode::NOT_FOUND, + [(header::LOCATION, ret_str)], + format!("{e:?}"), + ); + } + }; + + let amount = (req.amount * 100.0).round() as i32; + + if tx.get_amount() != amount { + tx.set_amount(db.as_ref(), amount).await.unwrap(); + } + + if tx.get_description() != req.description { + tx.set_description(db.as_ref(), &req.description) + .await + .unwrap(); + } + + if tx.get_category() != req.category { + tx.set_category(db.as_ref(), req.category).await.unwrap(); + } + + ( + StatusCode::MOVED_PERMANENTLY, + [(header::LOCATION, ret_str)], + String::new(), + ) +}