Added new views, joined rules and categories
This commit is contained in:
parent
bbc61cfe45
commit
759f91a9a2
12 changed files with 469 additions and 83 deletions
31
base.css
31
base.css
|
|
@ -1,3 +1,34 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/* 1 */
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
/* 1 */
|
/* 1 */
|
||||||
|
letter-spacing: inherit;
|
||||||
|
/* 1 */
|
||||||
color: inherit;
|
color: inherit;
|
||||||
/* 1 */
|
/* 1 */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -234,9 +236,9 @@ select {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
button,
|
button,
|
||||||
[type='button'],
|
input:where([type='button']),
|
||||||
[type='reset'],
|
input:where([type='reset']),
|
||||||
[type='submit'] {
|
input:where([type='submit']) {
|
||||||
-webkit-appearance: button;
|
-webkit-appearance: button;
|
||||||
/* 1 */
|
/* 1 */
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
@ -492,6 +494,10 @@ video {
|
||||||
--tw-backdrop-opacity: ;
|
--tw-backdrop-opacity: ;
|
||||||
--tw-backdrop-saturate: ;
|
--tw-backdrop-saturate: ;
|
||||||
--tw-backdrop-sepia: ;
|
--tw-backdrop-sepia: ;
|
||||||
|
--tw-contain-size: ;
|
||||||
|
--tw-contain-layout: ;
|
||||||
|
--tw-contain-paint: ;
|
||||||
|
--tw-contain-style: ;
|
||||||
}
|
}
|
||||||
|
|
||||||
::backdrop {
|
::backdrop {
|
||||||
|
|
@ -542,6 +548,10 @@ video {
|
||||||
--tw-backdrop-opacity: ;
|
--tw-backdrop-opacity: ;
|
||||||
--tw-backdrop-saturate: ;
|
--tw-backdrop-saturate: ;
|
||||||
--tw-backdrop-sepia: ;
|
--tw-backdrop-sepia: ;
|
||||||
|
--tw-contain-size: ;
|
||||||
|
--tw-contain-layout: ;
|
||||||
|
--tw-contain-paint: ;
|
||||||
|
--tw-contain-style: ;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|
@ -578,6 +588,18 @@ video {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-8 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
@ -636,6 +658,37 @@ video {
|
||||||
line-height: 1.75rem;
|
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 {
|
.hover\:bg-stone-200:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(231 229 228 / var(--tw-bg-opacity));
|
background-color: rgb(231 229 228 / var(--tw-bg-opacity));
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table width="100%">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description</th>
|
<th width="40%">Description</th>
|
||||||
<th>Date</th>
|
<th width="20%">Date</th>
|
||||||
<th>Amount</th>
|
<th width="10%">Amount</th>
|
||||||
<th>Category</th>
|
<th width="10%">Acc</th>
|
||||||
|
<th width="15%">Category</th>
|
||||||
|
<th width="5%">Link</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for tx in transactions %}
|
{% for tx in transactions %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{tx.description}}</td>
|
<td>{{tx.description}}</td>
|
||||||
<td>{{tx.transaction_timestamp}}</td>
|
<td>{{tx.tx_date}}</td>
|
||||||
<td>{{tx.amount/100}}</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@
|
||||||
<aside class="sidebar bg-stone-300 p-4 flex flex-col">
|
<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="/">Summary</a>
|
||||||
<a class="hover:bg-stone-400" href="/accounts">Accounts</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>
|
<a class="hover:bg-stone-400" href="/classifiers">Classifiers</a>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="p-4 grow h-full overflow-auto">
|
<div class="p-4 grow h-full overflow-auto">
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,21 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Create category{% endblock title %}
|
{% block title %}Create category{% endblock title %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<form action="/categories/new" method="post" class="flex flex-col">
|
<form action="/classifiers/new_category" method="post">
|
||||||
<label class="grow">
|
<div class="mb-2">
|
||||||
|
<label class="ars-input">
|
||||||
Name
|
Name
|
||||||
<input type="text" name="name" />
|
<input type="text" name="name" />
|
||||||
</label>
|
</label>
|
||||||
<label class="grow">
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="ars-input">
|
||||||
Description
|
Description
|
||||||
<input type="text" name="description" />
|
<input type="text" name="description" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Submit</button>
|
</div>
|
||||||
|
<div class="mb-2" style="text-align: right;">
|
||||||
|
<button class="ars-button" type="submit">Submit</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<style>
|
|
||||||
label {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid black;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0.25rem;
|
|
||||||
margin: 0.25rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Rules{% endblock title %}
|
{% block title %}Rules{% endblock title %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
<div class="mb-8">
|
||||||
<div>
|
<div>
|
||||||
<a href="/classifiers/new_category">New</a>
|
<a href="/classifiers/new_category">New</a>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table width="100%">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th span="col">Id</th>
|
<th width="10%" span="col">Id</th>
|
||||||
<th span="col">Name</th>
|
<th width="30%" span="col">Name</th>
|
||||||
<th span="col">Description</th>
|
<th span="col">Description</th>
|
||||||
<tr>
|
<tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -22,14 +23,16 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div
|
||||||
|
|
||||||
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<a href="/classifiers/new_rule">New</a>
|
<a href="/classifiers/new_rule">New</a>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table width="100%">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Categoría</th>
|
<th width="30%">Categoría</th>
|
||||||
<th>Regla</th>
|
<th>Regla</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -42,4 +45,5 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,91 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Index{% endblock title %}
|
{% block title %}Index{% endblock title %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div><h2 class="text-lg">Accounts</h2></div>
|
<div class="mb-4">
|
||||||
<div>
|
<h2 class="text-lg">Accounts</h2>
|
||||||
{% for account in accounts %}
|
<table width="100%">
|
||||||
<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>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th width="10%">ID</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Date</th>
|
<th width="30%">Accumulated</th>
|
||||||
<th>Amount</th>
|
<th width="20%">Go to</th>
|
||||||
<th>Category</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for tx in transactions %}
|
{% for account in accounts %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{tx.description}}</td>
|
<td style="text-align: center;">{{ account.id }}</td>
|
||||||
<td>{{tx.transaction_timestamp}}</td>
|
<td style="text-align: center;">{{ account.description }}</td>
|
||||||
<td>{{tx.amount/100}}</td>
|
<td style="text-align: center;">{{ account.accumulated | round(precision=2) }}</td>
|
||||||
<td>{{tx.category}}</td>
|
<td style="text-align: center;">
|
||||||
|
<a class="p-2 hover:bg-stone-200" href="/accounts/id/{{ account.id }}">{{ account.description }}</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 onclick="document.href='transactions/{{tx.transaction_id}}'">
|
||||||
|
<td>{{tx.description}}</td>
|
||||||
|
<td>{{tx.tx_date}}</td>
|
||||||
|
<td>{{tx.amount/100}}</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 %}
|
{% endblock body %}
|
||||||
|
|
|
||||||
41
templates/transaction.html
Normal file
41
templates/transaction.html
Normal 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 %}
|
||||||
|
|
@ -42,6 +42,8 @@ async fn main() {
|
||||||
"/accounts/id/:id/transactions/add",
|
"/accounts/id/:id/transactions/add",
|
||||||
post(routes::ui::account::add_transactions_action),
|
post(routes::ui::account::add_transactions_action),
|
||||||
)
|
)
|
||||||
|
.route("/transaction/:id", get(routes::ui::transaction::view))
|
||||||
|
.route("/transaction/:id", post(routes::ui::transaction::update))
|
||||||
.route(
|
.route(
|
||||||
"/classifiers",
|
"/classifiers",
|
||||||
get(routes::ui::classifier::view_classifiers),
|
get(routes::ui::classifier::view_classifiers),
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,57 @@
|
||||||
use std::sync::Arc;
|
use std::{borrow::BorrowMut, collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use axum::{extract::State, response::IntoResponse};
|
use axum::{extract::State, response::IntoResponse};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use hyper::{header::CONTENT_TYPE, StatusCode};
|
use hyper::{header::CONTENT_TYPE, StatusCode};
|
||||||
|
use serde::Serialize;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
use crate::users::UserToken;
|
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 account;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
pub mod classifier;
|
pub mod classifier;
|
||||||
pub mod rules;
|
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(
|
pub async fn index(
|
||||||
State(db): State<Arc<SqlitePool>>,
|
State(db): State<Arc<SqlitePool>>,
|
||||||
|
|
@ -21,7 +61,65 @@ pub async fn index(
|
||||||
let mut ctx = Context::new();
|
let mut ctx = Context::new();
|
||||||
|
|
||||||
let accounts = Account::list(db.as_ref(), uid.user_id).await.unwrap();
|
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)
|
let transactions = Transaction::list_by_user(db.as_ref(), uid.user_id, 10, 0, false)
|
||||||
.await
|
.await
|
||||||
|
|
@ -37,7 +135,7 @@ pub async fn index(
|
||||||
Err(e) => (
|
Err(e) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
[(CONTENT_TYPE, "text/plain")],
|
[(CONTENT_TYPE, "text/plain")],
|
||||||
format!("{e}"),
|
format!("{e:?}"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::sync::Arc;
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
|
|
@ -12,10 +12,7 @@ use sqlx::SqlitePool;
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
use crate::users::UserToken;
|
use crate::users::UserToken;
|
||||||
use accounters::models::{
|
use accounters::models::{account::Account, categories::Category, transaction::Transaction};
|
||||||
account::Account,
|
|
||||||
transaction::{Transaction, TxConflictResolutionMode},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct AccountViewParams {
|
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 n_entries = entries.unwrap_or(10).max(10);
|
||||||
let page = page.unwrap_or(0).max(0);
|
let page = page.unwrap_or(0).max(0);
|
||||||
|
|
||||||
|
|
@ -146,7 +151,6 @@ pub async fn add_transactions_action(
|
||||||
&tx.date,
|
&tx.date,
|
||||||
None,
|
None,
|
||||||
(tx.amount * 100.0).round() as i32,
|
(tx.amount * 100.0).round() as i32,
|
||||||
TxConflictResolutionMode::Nothing,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
|
||||||
99
webserver/src/routes/ui/transaction.rs
Normal file
99
webserver/src/routes/ui/transaction.rs
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue