Added the capability to upload CSV files
This commit is contained in:
parent
90b02eef79
commit
d1e736d7a7
19 changed files with 678 additions and 99 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -8,6 +8,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
|
"md-5",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "chrono"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "chrono"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
md-5 = "0.10"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
|
||||||
2
build.rs
2
build.rs
|
|
@ -10,5 +10,5 @@ fn main() {
|
||||||
))
|
))
|
||||||
.status()
|
.status()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
println!("cargo:rerun-if-changed=templates/*")
|
println!("cargo:rerun-if-changed=templates/")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,10 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||||
transaction_timestamp DATETIME,
|
transaction_timestamp DATETIME,
|
||||||
category INTEGER,
|
category INTEGER,
|
||||||
amount INTEGER,
|
amount INTEGER,
|
||||||
|
hash TEXT,
|
||||||
FOREIGN KEY (account) REFERENCES accounts(account_id),
|
FOREIGN KEY (account) REFERENCES accounts(account_id),
|
||||||
FOREIGN KEY (category) REFERENCES categories(category_id)
|
FOREIGN KEY (category) REFERENCES categories(category_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_transactions_ts ON transactions(account, transaction_timestamp);
|
CREATE INDEX idx_transactions_ts ON transactions(account, transaction_timestamp);
|
||||||
|
CREATE INDEX idx_transactions_hash ON transactions(hash);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,16 @@ use chrono::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, Result, Sqlite, SqlitePool};
|
use sqlx::{FromRow, Result, Sqlite, SqlitePool};
|
||||||
|
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
|
||||||
use crate::models::rules::Rule;
|
use crate::models::rules::Rule;
|
||||||
|
|
||||||
|
pub enum TxConflictResolutionMode {
|
||||||
|
Nothing,
|
||||||
|
Error,
|
||||||
|
Duplicate,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(FromRow, Serialize, Deserialize, Debug)]
|
#[derive(FromRow, Serialize, Deserialize, Debug)]
|
||||||
pub struct Transaction {
|
pub struct Transaction {
|
||||||
transaction_id: i32,
|
transaction_id: i32,
|
||||||
|
|
@ -12,6 +20,8 @@ pub struct Transaction {
|
||||||
transaction_timestamp: DateTime<Utc>,
|
transaction_timestamp: DateTime<Utc>,
|
||||||
category: Option<i32>,
|
category: Option<i32>,
|
||||||
amount: i32,
|
amount: i32,
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
hash: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Transaction {
|
impl Transaction {
|
||||||
|
|
@ -22,21 +32,47 @@ impl Transaction {
|
||||||
ts: &DateTime<Utc>,
|
ts: &DateTime<Utc>,
|
||||||
category: Option<i32>,
|
category: Option<i32>,
|
||||||
amount: i32,
|
amount: i32,
|
||||||
|
on_conflict: TxConflictResolutionMode,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let res = sqlx::query(concat!(
|
let hash = Transaction::get_tx_hash(account, &desc, &ts, amount);
|
||||||
|
let tx_db = match sqlx::query("SELECT * FROM transactions WHERE hash=? LIMIT 1")
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(row) => Some(Transaction::from_row(&row)?),
|
||||||
|
Err(sqlx::Error::RowNotFound) => None,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tx) = tx_db {
|
||||||
|
match on_conflict {
|
||||||
|
TxConflictResolutionMode::Nothing => {
|
||||||
|
return Ok(tx);
|
||||||
|
}
|
||||||
|
TxConflictResolutionMode::Error => {
|
||||||
|
return Err(sqlx::Error::RowNotFound);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(concat!(
|
||||||
"INSERT INTO transactions(",
|
"INSERT INTO transactions(",
|
||||||
"account, description, transaction_timestamp, category, amount",
|
"account, description, transaction_timestamp, category, amount, hash",
|
||||||
") VALUES (?,?,?,?,?) RETURNING *"
|
") VALUES (?,?,?,?,?,?) RETURNING *"
|
||||||
))
|
))
|
||||||
.bind(account)
|
.bind(account)
|
||||||
.bind(desc)
|
.bind(desc)
|
||||||
.bind(ts)
|
.bind(ts)
|
||||||
.bind(category)
|
.bind(category)
|
||||||
.bind(amount)
|
.bind(amount)
|
||||||
|
.bind(hash)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await
|
||||||
|
.map(|x| Transaction::from_row(&x).unwrap())
|
||||||
Transaction::from_row(&res)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list(
|
pub async fn list(
|
||||||
|
|
@ -165,19 +201,42 @@ impl Transaction {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_description(&mut self, pool: &SqlitePool, desc: &str) -> Result<()> {
|
pub async fn set_description(&mut self, pool: &SqlitePool, desc: &str) -> Result<()> {
|
||||||
sqlx::query("UPDATE transactions SET description=? WHERE transaction_id=?")
|
sqlx::query("UPDATE transactions SET description=?, hash=? WHERE transaction_id=?")
|
||||||
.bind(desc)
|
.bind(desc)
|
||||||
|
.bind(Transaction::get_tx_hash(
|
||||||
|
self.account,
|
||||||
|
desc,
|
||||||
|
&self.transaction_timestamp,
|
||||||
|
self.amount,
|
||||||
|
))
|
||||||
.bind(self.transaction_id)
|
.bind(self.transaction_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
self.description = desc.to_string();
|
self.description = desc.to_string();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_tx_hash(account: i32, description: &str, ts: &DateTime<Utc>, amount: i32) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(format!(
|
||||||
|
"{}/{}/{}/{}",
|
||||||
|
account,
|
||||||
|
description,
|
||||||
|
ts.to_rfc3339(),
|
||||||
|
amount
|
||||||
|
));
|
||||||
|
let mut out = String::new();
|
||||||
|
out.reserve(32);
|
||||||
|
for byte in hasher.finalize().iter() {
|
||||||
|
out.push_str(&format!("{:02x?}", byte));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::Transaction;
|
use super::{Transaction, TxConflictResolutionMode};
|
||||||
use crate::models::{account::Account, users::User};
|
use crate::models::{account::Account, users::User};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
|
@ -206,6 +265,7 @@ mod tests {
|
||||||
&chrono::Utc::now(),
|
&chrono::Utc::now(),
|
||||||
None,
|
None,
|
||||||
100,
|
100,
|
||||||
|
TxConflictResolutionMode::Nothing,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
47
static/csv.js
Normal file
47
static/csv.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
function parse(text) {
|
||||||
|
let state = 0;
|
||||||
|
let idx = 0;
|
||||||
|
let current = '';
|
||||||
|
let curr_row = [];
|
||||||
|
let rows = [];
|
||||||
|
|
||||||
|
while(idx < text.length) {
|
||||||
|
switch (text[idx]) {
|
||||||
|
case '\\':
|
||||||
|
current += text[idx++];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '"':
|
||||||
|
if(current.length == 0) {
|
||||||
|
while(text.length > idx && text[++idx] != '"')
|
||||||
|
current += text[idx];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ',':
|
||||||
|
if (/^\d+(\.\d+)?$/.test(current)) {
|
||||||
|
let asnum = parseFloat(current);
|
||||||
|
curr_row.push(asnum);
|
||||||
|
} else {
|
||||||
|
curr_row.push(current);
|
||||||
|
}
|
||||||
|
current = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '\n':
|
||||||
|
curr_row.push(current);
|
||||||
|
current = '';
|
||||||
|
rows.push(curr_row);
|
||||||
|
curr_row = [];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
current += text[idx];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default parse;
|
||||||
|
|
@ -544,6 +544,40 @@ video {
|
||||||
--tw-backdrop-sepia: ;
|
--tw-backdrop-sepia: ;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.container {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
max-width: 768px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.container {
|
||||||
|
max-width: 1024px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.container {
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1536px) {
|
||||||
|
.container {
|
||||||
|
max-width: 1536px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
@ -560,10 +594,6 @@ video {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-grow {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grow {
|
.grow {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -572,6 +602,10 @@ video {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-auto {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
@ -581,10 +615,24 @@ video {
|
||||||
background-color: rgb(214 211 209 / var(--tw-bg-opacity));
|
background-color: rgb(214 211 209 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p-2 {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:bg-stone-200:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(231 229 228 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:bg-stone-400:hover {
|
.hover\:bg-stone-400:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(168 162 158 / var(--tw-bg-opacity));
|
background-color: rgb(168 162 158 / var(--tw-bg-opacity));
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Account {{account.account_name}}{% endblock title %}
|
{% block title %}Account {{account.account_name}}{% endblock title %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div>{{account.account_name}}</div>
|
<div class="flex">
|
||||||
|
<span class="text-lg grow">{{account.account_name}}</span>
|
||||||
|
<div>
|
||||||
|
<a href="/accounts/id/{{account.account_id}}/transactions/add">+</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Descripción</th>
|
<th>Description</th>
|
||||||
<th>Fecha</th>
|
<th>Date</th>
|
||||||
<th>Cantidad</th>
|
<th>Amount</th>
|
||||||
<th>Categoría</th>
|
<th>Category</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for tx in transactions %}
|
{% for tx in transactions %}
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -19,7 +24,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
<p>Cargados {{n_txs}} movimientos</p>
|
<p>Loaded {{n_txs}} transactions</p>
|
||||||
</div>
|
</div>
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
||||||
|
|
|
||||||
176
templates/accounts_add_txs.html
Normal file
176
templates/accounts_add_txs.html
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Account {{account.account_name}}{% endblock title %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div>
|
||||||
|
<span class="text-lg grow">Add transactions to {{account.account_name}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<form id="file-form">
|
||||||
|
<div><input id="file-input" type="file" name="file"></div>
|
||||||
|
<div><input id="file-submit" type="submit" value="Upload transactions" disabled></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="file-content">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/static/csv.js"></script>
|
||||||
|
<script type="module">
|
||||||
|
import csv_parse from "/static/csv.js";
|
||||||
|
|
||||||
|
const form_elem = document.getElementById('file-form')
|
||||||
|
form_elem.onsubmit = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
console.log('Unable to send');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappers = [
|
||||||
|
['None', null],
|
||||||
|
['Date dd/mm/yyyy', el => {
|
||||||
|
let split = el.split('/');
|
||||||
|
return new Date(
|
||||||
|
parseInt(split[2], 10),
|
||||||
|
parseInt(split[1], 10),
|
||||||
|
parseInt(split[0], 10),
|
||||||
|
);
|
||||||
|
}],
|
||||||
|
['Date yyyy/mm/dd', el => {
|
||||||
|
let split = el.split('/');
|
||||||
|
return new Date(
|
||||||
|
parseInt(split[0], 10),
|
||||||
|
parseInt(split[1], 10),
|
||||||
|
parseInt(split[2], 10),
|
||||||
|
);
|
||||||
|
}],
|
||||||
|
['Description', el => el],
|
||||||
|
['Amount', el => parseFloat(el)]
|
||||||
|
];
|
||||||
|
|
||||||
|
function appendOptions(el) {
|
||||||
|
el.replaceChildren(...mappers.map((e, idx)=>{
|
||||||
|
let option = document.createElement('option');
|
||||||
|
option.setAttribute('value', idx);
|
||||||
|
option.textContent = e[0];
|
||||||
|
return option;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('file-input').onchange = (evt) => {
|
||||||
|
let files = evt.target.files;
|
||||||
|
if(files.length > 0) {
|
||||||
|
let file = files[0];
|
||||||
|
if(file.type != 'text/csv') {
|
||||||
|
window.alert("File not valid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.text().then(content => {
|
||||||
|
let line_end = content.indexOf('\n');
|
||||||
|
if(line_end == -1) {
|
||||||
|
window.alert("File is not a valid CSV");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let table_content = csv_parse(content);
|
||||||
|
let table_header = table_content.splice(0,1)[0];
|
||||||
|
|
||||||
|
let table = document.createElement('table');
|
||||||
|
let thead = document.createElement('thead');
|
||||||
|
let trhead = document.createElement('tr');
|
||||||
|
trhead.replaceChildren(...table_header.map(e =>{
|
||||||
|
let elem = document.createElement('th');
|
||||||
|
let text = document.createElement('div');
|
||||||
|
text.textContent = e;
|
||||||
|
elem.appendChild(text);
|
||||||
|
|
||||||
|
let container = document.createElement('div');
|
||||||
|
let sel_el = document.createElement('select');
|
||||||
|
sel_el.id = 'column_' + e;
|
||||||
|
appendOptions(sel_el);
|
||||||
|
container.appendChild(sel_el);
|
||||||
|
elem.appendChild(container);
|
||||||
|
|
||||||
|
return elem;
|
||||||
|
}));
|
||||||
|
thead.appendChild(trhead);
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
form_elem.onsubmit = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
console.log(table_header);
|
||||||
|
console.log(table_content);
|
||||||
|
|
||||||
|
let mapper = {
|
||||||
|
date: null,
|
||||||
|
amount: null,
|
||||||
|
description: null
|
||||||
|
};
|
||||||
|
|
||||||
|
table_header.forEach((e, idx)=>{
|
||||||
|
let option = document.getElementById('column_'+e).selectedIndex;
|
||||||
|
switch(option){
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
mapper.date = row => mappers[option][1](row[idx]);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
mapper.description = row => mappers[option][1](row[idx]);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
mapper.amount = row => mappers[option][1](row[idx]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(mapper.date == null) {
|
||||||
|
alert('Missing date mapping');
|
||||||
|
return;
|
||||||
|
} else if(mapper.amount == null) {
|
||||||
|
alert('Missing amount mapping');
|
||||||
|
return;
|
||||||
|
} else if(mapper.description == null) {
|
||||||
|
alert('Missing description mapping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let out = table_content.map(e=>{
|
||||||
|
return {
|
||||||
|
date: mapper.date(e),
|
||||||
|
amount: mapper.amount(e),
|
||||||
|
description: mapper.description(e)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
fetch('add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(out)
|
||||||
|
}).then(e=>window.location.href='..');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('file-submit').removeAttribute('disabled');
|
||||||
|
|
||||||
|
let tbody = document.createElement('tbody');
|
||||||
|
tbody.replaceChildren(...table_content.map(row => {
|
||||||
|
let row_elem = document.createElement('tr');
|
||||||
|
row_elem.replaceChildren(...row.map(cell => {
|
||||||
|
let td = document.createElement('td');
|
||||||
|
td.textContent = cell;
|
||||||
|
return td;
|
||||||
|
}));
|
||||||
|
return row_elem;
|
||||||
|
}))
|
||||||
|
|
||||||
|
table.appendChild(tbody);
|
||||||
|
|
||||||
|
let content_div = document.getElementById('file-content');
|
||||||
|
content_div.replaceChildren(table);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
th, td {
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock body %}
|
||||||
|
|
||||||
|
|
@ -13,8 +13,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
max-width: 12rem;
|
width: 12rem;
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar a {
|
.sidebar a {
|
||||||
|
|
@ -26,10 +25,11 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<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="/">Inicio</a>
|
<a class="hover:bg-stone-400" href="/">Start</a>
|
||||||
<a class="hover:bg-stone-400" href="/rules">Reglas</a>
|
<a class="hover:bg-stone-400" href="/rules">Rules</a>
|
||||||
|
<a class="hover:bg-stone-400" href="/categories">Categories</a>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="p-4 grow">
|
<div class="p-4 grow h-full overflow-auto">
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
25
templates/categories_list.html
Normal file
25
templates/categories_list.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Categories{% endblock title %}
|
||||||
|
{% block body %}
|
||||||
|
<div>
|
||||||
|
<a href="/categories/new">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>
|
||||||
|
{% endblock body %}
|
||||||
28
templates/categories_new.html
Normal file
28
templates/categories_new.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{% 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>
|
||||||
|
<style>
|
||||||
|
label {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
margin: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock body %}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Index{% endblock title %}
|
{% block title %}Index{% endblock title %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% for account in accounts %}
|
<div><h2 class="text-lg">Accounts</h2></div>
|
||||||
<a href="/accounts/id/{{account.account_id}}">{{account.account_name}}({{account.account_id}})</a>
|
<div>
|
||||||
{% endfor %}
|
{% for account in accounts %}
|
||||||
|
<a class="p-2 hover:bg-stone-200" href="/accounts/id/{{account.account_id}}">{{account.account_name}}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endblock body %}
|
{% endblock body %}
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,26 @@ async fn main() {
|
||||||
"/",
|
"/",
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(routes::ui::index))
|
.route("/", get(routes::ui::index))
|
||||||
.route("/accounts/id/:id", get(routes::ui::account))
|
.route("/accounts/id/:id", get(routes::ui::account::list))
|
||||||
|
.route(
|
||||||
|
"/accounts/id/:id/transactions/add",
|
||||||
|
get(routes::ui::account::add_transactions_view),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/accounts/id/:id/transactions/add",
|
||||||
|
post(routes::ui::account::add_transactions_action),
|
||||||
|
)
|
||||||
.route("/rules", get(routes::ui::rules::list))
|
.route("/rules", get(routes::ui::rules::list))
|
||||||
.route("/rules/new", get(routes::ui::rules::new_view))
|
.route("/rules/new", get(routes::ui::rules::new_view))
|
||||||
.route("/rules/new", post(routes::ui::rules::new_action))
|
.route("/rules/new", post(routes::ui::rules::new_action))
|
||||||
|
.route("/categories", get(routes::ui::categories::list))
|
||||||
|
.route("/categories/new", get(routes::ui::categories::new_view))
|
||||||
|
.route("/categories/new", post(routes::ui::categories::new_action))
|
||||||
.nest(
|
.nest(
|
||||||
"/static",
|
"/static",
|
||||||
Router::new().route("/styles.css", get(routes::static_routes::styles)),
|
Router::new()
|
||||||
|
.route("/styles.css", get(routes::static_routes::styles))
|
||||||
|
.route("/csv.js", get(routes::static_routes::csv)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.nest(
|
.nest(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use hyper::StatusCode;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use accounters::models::Transaction;
|
use accounters::models::{transaction::TxConflictResolutionMode, Transaction};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct TransactionContent {
|
pub struct TransactionContent {
|
||||||
|
|
@ -14,6 +14,8 @@ pub struct TransactionContent {
|
||||||
timestamp: DateTime<Utc>,
|
timestamp: DateTime<Utc>,
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
amount: i32,
|
amount: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
error_on_conflict: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
|
|
@ -21,6 +23,11 @@ pub async fn create(
|
||||||
Path(account): Path<i32>,
|
Path(account): Path<i32>,
|
||||||
Json(txcnt): Json<TransactionContent>,
|
Json(txcnt): Json<TransactionContent>,
|
||||||
) -> (StatusCode, String) {
|
) -> (StatusCode, String) {
|
||||||
|
let error_on_conflict = if txcnt.error_on_conflict.is_some_and(|x| x) {
|
||||||
|
TxConflictResolutionMode::Error
|
||||||
|
} else {
|
||||||
|
TxConflictResolutionMode::Nothing
|
||||||
|
};
|
||||||
match Transaction::new(
|
match Transaction::new(
|
||||||
db.as_ref(),
|
db.as_ref(),
|
||||||
account,
|
account,
|
||||||
|
|
@ -28,6 +35,7 @@ pub async fn create(
|
||||||
&txcnt.timestamp,
|
&txcnt.timestamp,
|
||||||
None,
|
None,
|
||||||
txcnt.amount,
|
txcnt.amount,
|
||||||
|
error_on_conflict,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,11 @@ pub async fn styles() -> impl IntoResponse {
|
||||||
fs::read_to_string("static/styles.css").unwrap(),
|
fs::read_to_string("static/styles.css").unwrap(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn csv() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(CONTENT_TYPE, "application/javascript")],
|
||||||
|
fs::read_to_string("static/csv.js").unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{extract::State, response::IntoResponse};
|
||||||
extract::{Path, Query, State},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use hyper::{header::CONTENT_TYPE, StatusCode};
|
use hyper::{header::CONTENT_TYPE, StatusCode};
|
||||||
use serde::Deserialize;
|
|
||||||
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, Transaction};
|
use accounters::models::Account;
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
|
pub mod categories;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
|
|
||||||
pub async fn index(
|
pub async fn index(
|
||||||
|
|
@ -37,66 +35,3 @@ pub async fn index(
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct AccountViewParams {
|
|
||||||
movements: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn account(
|
|
||||||
State(db): State<Arc<SqlitePool>>,
|
|
||||||
State(tmpls): State<Arc<Tera>>,
|
|
||||||
uid: UserToken,
|
|
||||||
Path(account_id): Path<i32>,
|
|
||||||
Query(AccountViewParams { movements }): Query<AccountViewParams>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let mut ctx = Context::new();
|
|
||||||
|
|
||||||
let account = match Account::get_by_id(db.as_ref(), account_id).await {
|
|
||||||
Ok(a) => a,
|
|
||||||
Err(e) => {
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
[(CONTENT_TYPE, "text/plain")],
|
|
||||||
format!("{e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if account.get_user() != uid.user_id {
|
|
||||||
return (
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
[(CONTENT_TYPE, "text/plain")],
|
|
||||||
String::from("You cannot access this resource"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let txs = match Transaction::list(
|
|
||||||
db.as_ref(),
|
|
||||||
account.get_id(),
|
|
||||||
movements.unwrap_or(10),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
[(CONTENT_TYPE, "text/plain")],
|
|
||||||
format!("Error at loading transactions: {e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.insert("account", &account);
|
|
||||||
ctx.insert("transactions", &txs);
|
|
||||||
ctx.insert("n_txs", &txs.len());
|
|
||||||
|
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
|
||||||
tmpls.render("accounts.html", &ctx).unwrap(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
147
webserver/src/routes/ui/account.rs
Normal file
147
webserver/src/routes/ui/account.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use hyper::{header::CONTENT_TYPE, StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
|
use crate::users::UserToken;
|
||||||
|
use accounters::models::{transaction::TxConflictResolutionMode, Account, Transaction};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AccountViewParams {
|
||||||
|
movements: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(db): State<Arc<SqlitePool>>,
|
||||||
|
State(tmpls): State<Arc<Tera>>,
|
||||||
|
uid: UserToken,
|
||||||
|
Path(account_id): Path<i32>,
|
||||||
|
Query(AccountViewParams { movements }): Query<AccountViewParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let mut ctx = Context::new();
|
||||||
|
|
||||||
|
let account = match Account::get_by_id(db.as_ref(), account_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
[(CONTENT_TYPE, "text/plain")],
|
||||||
|
format!("{e}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if account.get_user() != uid.user_id {
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
[(CONTENT_TYPE, "text/plain")],
|
||||||
|
String::from("You cannot access this resource"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let txs = match Transaction::list(
|
||||||
|
db.as_ref(),
|
||||||
|
account.get_id(),
|
||||||
|
movements.unwrap_or(10),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
[(CONTENT_TYPE, "text/plain")],
|
||||||
|
format!("Error at loading transactions: {e}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.insert("account", &account);
|
||||||
|
ctx.insert("transactions", &txs);
|
||||||
|
ctx.insert("n_txs", &txs.len());
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||||
|
tmpls.render("accounts.html", &ctx).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_transactions_view(
|
||||||
|
State(db): State<Arc<SqlitePool>>,
|
||||||
|
State(tmpls): State<Arc<Tera>>,
|
||||||
|
uid: UserToken,
|
||||||
|
Path(account_id): Path<i32>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let mut ctxt = Context::new();
|
||||||
|
ctxt.insert("account_id", &account_id);
|
||||||
|
|
||||||
|
let account = match Account::get_by_id(db.as_ref(), account_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
[(CONTENT_TYPE, "text/plain")],
|
||||||
|
format!("{e:?}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if account.get_user() != uid.user_id {
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
[(CONTENT_TYPE, "text/plain")],
|
||||||
|
String::from("You cannot access this resource"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxt.insert("account", &account);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||||
|
tmpls.render("accounts_add_txs.html", &ctxt).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct CreateTransactionRequest {
|
||||||
|
date: DateTime<Utc>,
|
||||||
|
description: String,
|
||||||
|
amount: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_transactions_action(
|
||||||
|
State(db): State<Arc<SqlitePool>>,
|
||||||
|
uid: UserToken,
|
||||||
|
Path(account_id): Path<i32>,
|
||||||
|
Json(body): Json<Vec<CreateTransactionRequest>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// TODO missing user id check
|
||||||
|
for tx in body.iter() {
|
||||||
|
if let Err(e) = Transaction::new(
|
||||||
|
db.as_ref(),
|
||||||
|
account_id,
|
||||||
|
&tx.description,
|
||||||
|
&tx.date,
|
||||||
|
None,
|
||||||
|
(tx.amount * 100.0).round() as i32,
|
||||||
|
TxConflictResolutionMode::Nothing,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(StatusCode::OK, String::new())
|
||||||
|
}
|
||||||
72
webserver/src/routes/ui/categories.rs
Normal file
72
webserver/src/routes/ui/categories.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use accounters::models::categories::Category;
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, State},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use hyper::{header::CONTENT_TYPE, StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
|
use crate::users::UserToken;
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(db): State<Arc<SqlitePool>>,
|
||||||
|
State(tmpl): State<Arc<Tera>>,
|
||||||
|
uid: UserToken,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match Category::list(db.as_ref()).await {
|
||||||
|
Ok(categories) => {
|
||||||
|
let mut ctx = Context::new();
|
||||||
|
ctx.insert("categories", &categories);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||||
|
tmpl.render("categories_list.html", &ctx).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
[(CONTENT_TYPE, "text/plain;charset=utf-8")],
|
||||||
|
format!("{e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_view(State(tmpl): State<Arc<Tera>>, uid: UserToken) -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||||
|
tmpl.render("categories_new.html", &Context::new()).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NewRuleParams {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_action(
|
||||||
|
State(db): State<Arc<SqlitePool>>,
|
||||||
|
State(tmpls): State<Arc<Tera>>,
|
||||||
|
uid: UserToken,
|
||||||
|
Form(params): Form<NewRuleParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match Category::new(db.as_ref(), ¶ms.name, ¶ms.description).await {
|
||||||
|
Ok(_) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[(CONTENT_TYPE, "text/html;charset=utf-8")],
|
||||||
|
tmpls
|
||||||
|
.render("rules_new_success.html", &Context::new())
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
[(CONTENT_TYPE, "text/plain;charset=utf-8")],
|
||||||
|
format!("{e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue