Added new views, joined rules and categories

This commit is contained in:
Manuel Forcén Muñoz 2024-04-24 23:20:37 +02:00
parent bbc61cfe45
commit 759f91a9a2
12 changed files with 469 additions and 83 deletions

View file

@ -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;
}

View file

@ -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));

View file

@ -8,22 +8,26 @@
</div>
</div>
<div>
<table>
<table width="100%">
<thead>
<tr>
<th>Description</th>
<th>Date</th>
<th>Amount</th>
<th>Category</th>
<th width="40%">Description</th>
<th width="20%">Date</th>
<th width="10%">Amount</th>
<th width="10%">Acc</th>
<th width="15%">Category</th>
<th width="5%">Link</th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{tx.description}}</td>
<td>{{tx.transaction_timestamp}}</td>
<td>{{tx.tx_date}}</td>
<td>{{tx.amount/100}}</td>
<td>{{tx.category}}</td>
<td>{{tx.accumulated/100}}</td>
<td>{% if tx.category %}{{categories[tx.category]}}{% endif %}</td>
<td><a href="/transaction/{{ tx.transaction_id }}">Go to</a></td>
</tr>
{% endfor %}
</tbody>

View file

@ -27,7 +27,6 @@
<aside class="sidebar bg-stone-300 p-4 flex flex-col">
<a class="hover:bg-stone-400" href="/">Summary</a>
<a class="hover:bg-stone-400" href="/accounts">Accounts</a>
<a class="hover:bg-stone-400" href="/categories">Categories</a>
<a class="hover:bg-stone-400" href="/classifiers">Classifiers</a>
</aside>
<div class="p-4 grow h-full overflow-auto">

View file

@ -1,28 +1,21 @@
{% extends "base.html" %}
{% block title %}Create category{% endblock title %}
{% block body %}
<form action="/categories/new" method="post" class="flex flex-col">
<label class="grow">
Name
<input type="text" name="name" />
</label>
<label class="grow">
Description
<input type="text" name="description" />
</label>
<button type="submit">Submit</button>
<form action="/classifiers/new_category" method="post">
<div class="mb-2">
<label class="ars-input">
Name
<input type="text" name="name" />
</label>
</div>
<div class="mb-2">
<label class="ars-input">
Description
<input type="text" name="description" />
</label>
</div>
<div class="mb-2" style="text-align: right;">
<button class="ars-button" type="submit">Submit</button>
</div>
</form>
<style>
label {
width: 100%;
}
input {
width: 100%;
border: 1px solid black;
border-radius: 3px;
padding: 0.25rem;
margin: 0.25rem;
}
</style>
{% endblock body %}

View file

@ -1,35 +1,38 @@
{% extends "base.html" %}
{% block title %}Rules{% endblock title %}
{% block body %}
<div>
<a href="/classifiers/new_category">New</a>
</div>
<table>
<thead>
<tr>
<th span="col">Id</th>
<th span="col">Name</th>
<th span="col">Description</th>
<tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>{{category.category_id}}</td>
<td>{{category.name}}</td>
<td>{{category.description}}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mb-8">
<div>
<a href="/classifiers/new_category">New</a>
</div>
<table width="100%">
<thead>
<tr>
<th width="10%" span="col">Id</th>
<th width="30%" span="col">Name</th>
<th span="col">Description</th>
<tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>{{category.category_id}}</td>
<td>{{category.name}}</td>
<td>{{category.description}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div
<div>
<div>
<a href="/classifiers/new_rule">New</a>
</div>
<table>
<table width="100%">
<thead>
<tr>
<th>Categoría</th>
<th width="30%">Categoría</th>
<th>Regla</th>
</tr>
</thead>
@ -42,4 +45,5 @@
{% endfor %}
</tbody>
</table>
</div>
{% endblock body %}

View file

@ -1,33 +1,91 @@
{% extends "base.html" %}
{% block title %}Index{% endblock title %}
{% block body %}
<div><h2 class="text-lg">Accounts</h2></div>
<div>
{% for account in accounts %}
<a class="p-2 hover:bg-stone-200" href="/accounts/id/{{account.account_id}}">{{account.account_name}}</a>
{% endfor %}
</div>
<div><h2 class="text-lg">Last transactions</h2></div>
<div>
<table>
<div class="mb-4">
<h2 class="text-lg">Accounts</h2>
<table width="100%">
<thead>
<tr>
<th width="10%">ID</th>
<th>Description</th>
<th>Date</th>
<th>Amount</th>
<th>Category</th>
<th width="30%">Accumulated</th>
<th width="20%">Go to</th>
</tr>
</thead>
<tbody>
{% for account in accounts %}
<tr>
<td style="text-align: center;">{{ account.id }}</td>
<td style="text-align: center;">{{ account.description }}</td>
<td style="text-align: center;">{{ account.accumulated | round(precision=2) }}</td>
<td style="text-align: center;">
<a class="p-2 hover:bg-stone-200" href="/accounts/id/{{ account.id }}">{{ account.description }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mb-4">
<h2 class="text-lg">Last month summary</h2>
<div style="width: 200px; height: 200px;">
<canvas id="chart" style="width: 200px; height: 200px;"></canvas>
</div>
</div>
<div>
<h2 class="text-lg">Last transactions</h2>
<table width="100%">
<thead>
<tr>
<th width="40%">Description</th>
<th width="20%">Date</th>
<th width="20%">Amount</th>
<th width="20%">Category</th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<tr onclick="document.href='transactions/{{tx.transaction_id}}'">
<td>{{tx.description}}</td>
<td>{{tx.transaction_timestamp}}</td>
<td>{{tx.tx_date}}</td>
<td>{{tx.amount/100}}</td>
<td>{{tx.category}}</td>
<td>{% if tx.category %}{{categories[tx.category]}}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('chart');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: [
{% for i in income -%}
'Income: {{ categories[i.0] }}',
{% endfor -%}
{% for e in expenses -%}
'Expenses: {{ categories[e.0] }}',
{% endfor -%}
],
datasets: [{
label: 'Amount',
data: [
{% for i in income -%}
{{ i.1/100 }},
{% endfor -%}
{% for e in expenses -%}
{{ e.1/100 }},
{% endfor -%}
],
backgroundColor: [
{% for c in colors -%}
'#{{c}}',
{% endfor -%}
]
}]
}
})
</script>
</div>
{% endblock body %}

View file

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Transaction {{tx_id}}{% endblock title %}
{% block body %}
<div class="mb-4">
<form method="post">
<div class="mb-2">
<label class="ars-input">
Name
<input type="text" name="description" value="{{tx.description}}" />
</label>
</div>
<div class="mb-2">
<label class="ars-input">
Date
<input type="text" name="date" value="{{tx.tx_date}}" />
</label>
</div>
<div class="mb-2">
<label class="ars-input">
Amount
<input type="text" name="amount" value="{{ tx.amount/100 }}" />
</label>
</div>
<div class="mb-2">
<label class="ars-input">
Category
<select style="width: 100%;" name="category">
<option></option>
{% for c in categories %}
<option {% if tx.category and c.category_id==tx.category %}selected{% endif %} value="{{ c.category_id }}">
{{ c.name }}
</option>
{% endfor %}
</select>
</label>
</div>
<div style="text-align: right;">
<input class="ars-button" type="submit" value="Update" />
</form>
</div>
{% endblock body %}

View file

@ -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),

View file

@ -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<i32, i64>, 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<Arc<SqlitePool>>,
@ -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<i32, String> = 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<i32, i64> = HashMap::new();
let mut expenses: HashMap<i32, i64> = 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:?}"),
),
}
}

View file

@ -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<i32, String> = 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
{

View file

@ -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<Arc<SqlitePool>>,
tmpl: State<Arc<Tera>>,
user: UserToken,
Path(id): Path<i32>,
) -> 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<Option<i32>, 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<Utc>,
amount: f32,
#[serde(deserialize_with = "deserialize_optional")]
category: Option<i32>,
}
pub async fn update(
db: State<Arc<SqlitePool>>,
tmpl: State<Arc<Tera>>,
user: UserToken,
Path(id): Path<i32>,
Form(req): Form<TxUpdateRequest>,
) -> 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(),
)
}