aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris Guyonvarch2026-04-18 11:04:47 +0200
committerJoris Guyonvarch2026-04-18 11:05:17 +0200
commit6d1300640051baa23360846197b54e1e69ae32e3 (patch)
tree46219dcf5b5c9e5da0920ffd966d49ba80947a9b
parentb35589eb90f2e5ee5521964e64eb578e9eb99032 (diff)
Add balancing capabilities
If payment are too unbalanced, it’s easier to make a transfer.
-rw-r--r--assets/main.js9
-rw-r--r--src/controller/balance.rs20
-rw-r--r--src/controller/balancing.rs189
-rw-r--r--src/controller/mod.rs1
-rw-r--r--src/db/balancing.rs260
-rw-r--r--src/db/migrations/07-create-balancing-table.sql9
-rw-r--r--src/db/mod.rs2
-rw-r--r--src/model/balancing.rs29
-rw-r--r--src/model/mod.rs1
-rw-r--r--src/queries.rs6
-rw-r--r--src/routes.rs47
-rw-r--r--src/templates.rs1
-rw-r--r--src/validation/balancing.rs34
-rw-r--r--src/validation/mod.rs1
-rw-r--r--templates/balancing/create.html79
-rw-r--r--templates/balancing/table.html56
-rw-r--r--templates/balancing/update.html105
-rw-r--r--templates/base.html10
18 files changed, 858 insertions, 1 deletions
diff --git a/assets/main.js b/assets/main.js
index c83e4fc..7ca4306 100644
--- a/assets/main.js
+++ b/assets/main.js
@@ -41,6 +41,15 @@ if (path == '/login') {
trim_inputs_on_blur()
control_remove_button()
+} else if (path == '/balancing') { // Balancing creation
+
+ trim_inputs_on_blur()
+
+} else if (path.startsWith('/balancing/')) { // Balancing modification
+
+ trim_inputs_on_blur()
+ control_remove_button()
+
} else if (path == '/balance') {
} else if (path == '/statistics') {
diff --git a/src/controller/balance.rs b/src/controller/balance.rs
index 309f15c..6cb937b 100644
--- a/src/controller/balance.rs
+++ b/src/controller/balance.rs
@@ -7,6 +7,7 @@ use crate::controller::utils;
use crate::controller::wallet::Wallet;
use crate::db;
use crate::model::user::User;
+use crate::model::balancing::Balancing;
use crate::payer;
use crate::templates;
@@ -22,7 +23,10 @@ pub async fn get(wallet: &Wallet) -> Response<Full<Bytes>> {
get_template_user_incomes(&users, &user_incomes);
let total_income: i64 = user_incomes.values().sum();
- let user_payments = db::payments::repartition(&wallet.db_conn).await;
+ let user_payments = with_balancing(
+ db::payments::repartition(&wallet.db_conn).await,
+ db::balancing::list(&wallet.db_conn).await
+ );
let template_user_payments =
get_template_user_payments(&users, &user_payments);
let total_payments: i64 = user_payments.iter().map(|p| p.1).sum();
@@ -67,3 +71,17 @@ fn get_template_user_incomes(
user_incomes.sort_by_key(|i| i.1);
user_incomes
}
+
+fn with_balancing(
+ user_payments: HashMap<i64, i64>,
+ balancings: Vec<Balancing>
+) -> HashMap<i64, i64> {
+ let mut user_payments = user_payments;
+ for balancing in balancings {
+ let src = user_payments.entry(balancing.source).or_insert(0);
+ *src += balancing.amount;
+ let dest = user_payments.entry(balancing.destination).or_insert(0);
+ *dest -= balancing.amount;
+ }
+ user_payments
+}
diff --git a/src/controller/balancing.rs b/src/controller/balancing.rs
new file mode 100644
index 0000000..718358c
--- /dev/null
+++ b/src/controller/balancing.rs
@@ -0,0 +1,189 @@
+use http_body_util::Full;
+use hyper::Response;
+use hyper::body::Bytes;
+use std::collections::HashMap;
+
+use crate::controller::utils;
+use crate::controller::wallet::Wallet;
+use crate::db;
+use crate::queries;
+use crate::templates;
+use crate::validation;
+
+static PER_PAGE: i64 = 10;
+
+pub async fn table(
+ wallet: &Wallet,
+ query: queries::Balancing,
+) -> Response<Full<Bytes>> {
+ let page = query.page.unwrap_or(1);
+ let count = db::balancing::count(&wallet.db_conn).await;
+ let balancings = db::balancing::list_for_table(&wallet.db_conn, page, PER_PAGE).await;
+ let max_page = (count as f32 / PER_PAGE as f32).ceil() as i64;
+
+ let context = minijinja::context!(
+ header => templates::Header::Balancing,
+ connected_user => wallet.user,
+ balancings => balancings,
+ page => page,
+ max_page => max_page,
+ highlight => query.highlight
+ );
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "balancing/table.html",
+ context,
+ )
+}
+
+pub async fn create_form(
+ wallet: &Wallet,
+ query: queries::Balancing,
+) -> Response<Full<Bytes>> {
+ create_form_feedback(wallet, query, HashMap::new(), None).await
+}
+
+async fn create_form_feedback(
+ wallet: &Wallet,
+ query: queries::Balancing,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Full<Bytes>> {
+ let users = db::users::list(&wallet.db_conn).await;
+
+ let context = minijinja::context!(
+ header => templates::Header::Balancing,
+ connected_user => wallet.user,
+ users => users,
+ query => query,
+ form => form,
+ error => error,
+ );
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "balancing/create.html",
+ context,
+ )
+}
+
+
+pub async fn create(
+ wallet: &Wallet,
+ query: queries::Balancing,
+ form: HashMap<String, String>,
+) -> Response<Full<Bytes>> {
+ let error = |e: &str| {
+ create_form_feedback(wallet, query, form.clone(), Some(e.to_string()))
+ };
+
+ match validation::balancing::create(&form) {
+ Some(balancing) => {
+ match db::balancing::create(&wallet.db_conn, balancing).await {
+ Some(id) => {
+ let row =
+ db::balancing::get_row(&wallet.db_conn, id).await;
+ let page = (row - 1) / PER_PAGE + 1;
+ utils::redirect(&format!(
+ "/balancings?page={}&highlight={}",
+ page, id
+ ))
+ }
+ None => error("Erreur serveur").await,
+ }
+ }
+ None => error("Erreur lors de la validation du formulaire.").await,
+ }
+}
+
+pub async fn update_form(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Balancing,
+) -> Response<Full<Bytes>> {
+ update_form_feedback(id, wallet, query, HashMap::new(), None).await
+}
+
+async fn update_form_feedback(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Balancing,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Full<Bytes>> {
+ let users = db::users::list(&wallet.db_conn).await;
+ let balancing = db::balancing::get(&wallet.db_conn, id).await;
+
+ let context = minijinja::context!(
+ header => &templates::Header::Balancing,
+ connected_user => &wallet.user,
+ users => &users,
+ id => &id,
+ balancing => &balancing,
+ query => &query,
+ form => &form,
+ error => &error
+ );
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "balancing/update.html",
+ context,
+ )
+}
+
+pub async fn update(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Balancing,
+ form: HashMap<String, String>,
+) -> Response<Full<Bytes>> {
+ let error = |e: &str| {
+ update_form_feedback(
+ id,
+ wallet,
+ query,
+ form.clone(),
+ Some(e.to_string()),
+ )
+ };
+
+ match validation::balancing::update(&form) {
+ Some(balancing) => {
+ if db::balancing::update(&wallet.db_conn, id, balancing).await {
+ let row = db::balancing::get_row(&wallet.db_conn, id).await;
+ let page = (row - 1) / PER_PAGE + 1;
+ utils::redirect(&format!(
+ "/balancings?page={}&highlight={}",
+ page, id
+ ))
+ } else {
+ error("Erreur serveur").await
+ }
+ }
+ None => error("Erreur lors de la validation du formulaire.").await,
+ }
+}
+
+pub async fn delete(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Balancing,
+) -> Response<Full<Bytes>> {
+ if db::balancing::delete(&wallet.db_conn, id).await {
+ utils::redirect(&format!("/balancings?page={}", query.page.unwrap_or(1)))
+ } else {
+ update_form_feedback(
+ id,
+ wallet,
+ query,
+ HashMap::new(),
+ Some("Erreur serveur".to_string()),
+ )
+ .await
+ }
+}
diff --git a/src/controller/mod.rs b/src/controller/mod.rs
index e2ef561..bbead68 100644
--- a/src/controller/mod.rs
+++ b/src/controller/mod.rs
@@ -1,4 +1,5 @@
pub mod balance;
+pub mod balancing;
pub mod categories;
pub mod error;
pub mod incomes;
diff --git a/src/db/balancing.rs b/src/db/balancing.rs
new file mode 100644
index 0000000..1641b97
--- /dev/null
+++ b/src/db/balancing.rs
@@ -0,0 +1,260 @@
+use tokio_rusqlite::{Connection, Row, named_params};
+
+use crate::db::utils;
+use crate::model::balancing::{Balancing, Create, Update, TableRow};
+
+fn row_to_balancing(row: &Row) -> Result<Balancing, rusqlite::Error> {
+ Ok(Balancing {
+ id: row.get(0)?,
+ source: row.get(1)?,
+ destination: row.get(2)?,
+ amount: row.get(3)?,
+ })
+}
+
+fn row_to_table_row(row: &Row) -> Result<TableRow, rusqlite::Error> {
+ Ok(TableRow {
+ id: row.get(0)?,
+ source: row.get(1)?,
+ destination: row.get(2)?,
+ amount: row.get(3)?,
+ })
+}
+
+pub async fn count(conn: &Connection) -> i64 {
+ let query = r#"
+ SELECT COUNT(*)
+ FROM balancing
+ WHERE balancing.deleted_at IS NULL
+ "#;
+
+ let res = conn
+ .call(move |conn| {
+ let mut stmt = conn.prepare(query)?;
+ let mut iter = stmt.query_map([], |row| row.get(0))?;
+ utils::one::<i64, _>(&mut iter)
+ })
+ .await;
+
+ match res {
+ Ok(count) => count,
+ Err(err) => {
+ log::error!("Error counting balancing: {:?}", err);
+ 0
+ }
+ }
+}
+
+pub async fn list_for_table(conn: &Connection, page: i64, per_page: i64) -> Vec<TableRow> {
+ let query = r#"
+ SELECT
+ balancing.id,
+ users_src.name,
+ users_dest.name,
+ balancing.amount
+ FROM balancing
+ INNER JOIN users AS users_src ON users_src.id = balancing.source
+ INNER JOIN users AS users_dest ON users_dest.id = balancing.destination
+ WHERE balancing.deleted_at IS NULL
+ ORDER BY balancing.created_at DESC
+ LIMIT :limit
+ OFFSET :offset
+ "#;
+
+ let res = conn
+ .call(move |conn| {
+ let mut stmt = conn.prepare(query)?;
+
+ stmt.query_map(
+ named_params![":limit": per_page, ":offset": (page - 1) * per_page],
+ row_to_table_row
+ )?
+ .collect::<Result<Vec<TableRow>, _>>()
+ })
+ .await;
+
+ match res {
+ Ok(xs) => xs,
+ Err(err) => {
+ log::error!("Error listing balancing: {:?}", err);
+ vec![]
+ }
+ }
+}
+pub async fn list(conn: &Connection) -> Vec<Balancing> {
+ let query = r#"
+ SELECT
+ id,
+ source,
+ destination,
+ amount
+ FROM balancing
+ WHERE deleted_at IS NULL
+ ORDER BY created_at DESC
+ "#;
+
+ let res = conn
+ .call(move |conn| {
+ let mut stmt = conn.prepare(query)?;
+
+ stmt.query_map([], row_to_balancing)?
+ .collect::<Result<Vec<Balancing>, _>>()
+ })
+ .await;
+
+ match res {
+ Ok(xs) => xs,
+ Err(err) => {
+ log::error!("Error listing balancing: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+pub async fn get_row(conn: &Connection, id: i64) -> i64 {
+ let query = r#"
+ SELECT row
+ FROM (
+ SELECT
+ ROW_NUMBER () OVER (ORDER BY created_at DESC) AS row,
+ id
+ FROM balancing
+ WHERE deleted_at IS NULL
+ )
+ WHERE id = :id
+ "#;
+
+ let res = conn
+ .call(move |conn| {
+ let mut stmt = conn.prepare(query)?;
+ let mut iter =
+ stmt.query_map(named_params![":id": id], |row| row.get(0))?;
+ utils::one::<i64, _>(&mut iter)
+ })
+ .await;
+
+ match res {
+ Ok(row) => row,
+ Err(err) => {
+ log::error!("Error getting balancing row: {:?}", err);
+ 1
+ }
+ }
+}
+
+pub async fn get(conn: &Connection, id: i64) -> Option<Balancing> {
+ let query = r#"
+ SELECT
+ id,
+ source,
+ destination,
+ amount
+ FROM balancing
+ WHERE
+ id = :id
+ AND deleted_at IS NULL
+ "#;
+
+ let res = conn
+ .call(move |conn| {
+ let mut stmt = conn.prepare(query)?;
+ let mut iter =
+ stmt.query_map(named_params![":id": id], row_to_balancing)?;
+ utils::one(&mut iter)
+ })
+ .await;
+
+ match res {
+ Ok(balancing) => Some(balancing),
+ Err(err) => {
+ log::error!("Error looking for balancing {}: {:?}", id, err);
+ None
+ }
+ }
+}
+
+pub async fn create(conn: &Connection, c: Create) -> Option<i64> {
+ let query = r#"INSERT INTO balancing(source, destination, amount) VALUES (:source, :destination, :amount)"#;
+
+ let res: Result<_, tokio_rusqlite::Error<rusqlite::Error>> = conn
+ .call(move |conn| {
+ conn.execute(
+ query,
+ named_params![
+ ":source": c.source,
+ ":destination": c.destination,
+ ":amount": c.amount,
+ ],
+ )?;
+ Ok(conn.last_insert_rowid())
+ })
+ .await;
+
+ match res {
+ Ok(balancing_id) => Some(balancing_id),
+ Err(err) => {
+ log::error!("Error creating balancing: {:?}", err);
+ None
+ }
+ }
+}
+
+pub async fn update(conn: &Connection, id: i64, c: Update) -> bool {
+ let query = r#"
+ UPDATE balancing
+ SET
+ source = :source,
+ destination = :destination,
+ amount = :amount,
+ updated_at = datetime()
+ WHERE id = :id
+ "#;
+
+ let res = conn
+ .call(move |conn| {
+ conn.execute(
+ query,
+ named_params![
+ ":source": c.source,
+ ":destination": c.destination,
+ ":amount": c.amount,
+ ":id": id
+ ],
+ )
+ })
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ log::error!("Error updating balancing {}: {:?}", id, err);
+ false
+ }
+ }
+}
+
+pub async fn delete(conn: &Connection, id: i64) -> bool {
+ let res = conn
+ .call(move |conn| {
+ conn.execute(
+ r#"
+ UPDATE
+ balancing
+ SET
+ deleted_at = datetime()
+ WHERE
+ id = :id
+ "#,
+ named_params![":id": id],
+ )
+ })
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ log::error!("Error deleting balancing {}: {:?}", id, err);
+ false
+ }
+ }
+}
diff --git a/src/db/migrations/07-create-balancing-table.sql b/src/db/migrations/07-create-balancing-table.sql
new file mode 100644
index 0000000..148657e
--- /dev/null
+++ b/src/db/migrations/07-create-balancing-table.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS "balancing"(
+ "id" INTEGER PRIMARY KEY,
+ "source" INTEGER NOT NULL REFERENCES "users",
+ "destination" INTEGER NOT NULL REFERENCES "users",
+ "amount" INTEGER NOT NULL,
+ "created_at" TEXT NULL DEFAULT (datetime('now')),
+ "updated_at" TEXT NULL,
+ "deleted_at" TEXT NULL
+) STRICT;
diff --git a/src/db/mod.rs b/src/db/mod.rs
index c444995..d257bf1 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -2,6 +2,7 @@ use anyhow::{Error, Result};
use rusqlite_migration::{M, Migrations};
use tokio_rusqlite::Connection;
+pub mod balancing;
pub mod categories;
pub mod incomes;
pub mod jobs;
@@ -28,6 +29,7 @@ async fn apply_migrations(conn: &Connection) -> Result<()> {
M::up(include_str!("migrations/04-plural-naming.sql")),
M::up(include_str!("migrations/05-strict-tables.sql")),
M::up(include_str!("migrations/06-remove-weekly-report-job.sql")),
+ M::up(include_str!("migrations/07-create-balancing-table.sql")),
]);
Ok(conn.call(move |conn| migrations.to_latest(conn)).await?)
diff --git a/src/model/balancing.rs b/src/model/balancing.rs
new file mode 100644
index 0000000..fe480f2
--- /dev/null
+++ b/src/model/balancing.rs
@@ -0,0 +1,29 @@
+#[derive(Debug, serde::Serialize)]
+pub struct TableRow {
+ pub id: i64,
+ pub source: String,
+ pub destination: String,
+ pub amount: i64,
+}
+
+#[derive(serde::Serialize, Clone)]
+pub struct Balancing {
+ pub id: i64,
+ pub source: i64,
+ pub destination: i64,
+ pub amount: i64,
+}
+
+#[derive(Debug)]
+pub struct Create {
+ pub source: i64,
+ pub destination: i64,
+ pub amount: i64,
+}
+
+#[derive(Debug)]
+pub struct Update {
+ pub source: i64,
+ pub destination: i64,
+ pub amount: i64,
+}
diff --git a/src/model/mod.rs b/src/model/mod.rs
index 55adadd..9381103 100644
--- a/src/model/mod.rs
+++ b/src/model/mod.rs
@@ -1,3 +1,4 @@
+pub mod balancing;
pub mod category;
pub mod config;
pub mod frequency;
diff --git a/src/queries.rs b/src/queries.rs
index 86a8520..519b00e 100644
--- a/src/queries.rs
+++ b/src/queries.rs
@@ -79,6 +79,12 @@ pub struct Categories {
pub highlight: Option<i64>,
}
+#[derive(serde::Serialize, serde::Deserialize, Clone)]
+pub struct Balancing {
+ pub page: Option<i64>,
+ pub highlight: Option<i64>,
+}
+
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PaymentCategory {
pub payment_name: String,
diff --git a/src/routes.rs b/src/routes.rs
index 8abe1b4..3afc822 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -86,6 +86,7 @@ async fn authenticated_routes(
let query = uri.query();
match (method, path) {
+ // PAYMENTS
(&Method::GET, [""]) => {
controller::payments::table(&wallet, parse_query(query)).await
}
@@ -129,6 +130,7 @@ async fn authenticated_routes(
)
.await
}
+ // INCOMES
(&Method::GET, ["incomes"]) => {
controller::incomes::table(&wallet, parse_query(query)).await
}
@@ -168,6 +170,7 @@ async fn authenticated_routes(
)
.await
}
+ // CATEGORIES
(&Method::GET, ["categories"]) => {
controller::categories::table(&wallet, parse_query(query)).await
}
@@ -192,13 +195,57 @@ async fn authenticated_routes(
(&Method::POST, ["category", id, "delete"]) => {
controller::categories::delete(parse_id(id), &wallet).await
}
+ // BALANCING
+ (&Method::GET, ["balancings"]) => {
+ controller::balancing::table(&wallet, parse_query(query)).await
+ }
+ (&Method::GET, ["balancing"]) => {
+ controller::balancing::create_form(&wallet, parse_query(query)).await
+ }
+ (&Method::POST, ["balancing", "create"]) => {
+ controller::balancing::create(
+ &wallet,
+ parse_query(query),
+ body_form(request).await,
+ )
+ .await
+ }
+ (&Method::GET, ["balancing", id]) => {
+ controller::balancing::update_form(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ )
+ .await
+ }
+ (&Method::POST, ["balancing", id, "update"]) => {
+ controller::balancing::update(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ body_form(request).await,
+ )
+ .await
+ }
+ (&Method::POST, ["balancing", id, "delete"]) => {
+ controller::balancing::delete(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ )
+ .await
+ }
+ // BALANCE
(&Method::GET, ["balance"]) => controller::balance::get(&wallet).await,
+ // STATS
(&Method::GET, ["statistics"]) => {
controller::statistics::get(&wallet).await
}
+ // LOGOUT
(&Method::POST, ["logout"]) => {
controller::login::logout(config, &wallet).await
}
+ // ERROR
_ => controller::error::error(
&wallet,
"Page introuvable",
diff --git a/src/templates.rs b/src/templates.rs
index 5ea91b4..2c65ddb 100644
--- a/src/templates.rs
+++ b/src/templates.rs
@@ -8,6 +8,7 @@ pub enum Header {
Payments,
Categories,
Incomes,
+ Balancing,
Balance,
Statistics,
}
diff --git a/src/validation/balancing.rs b/src/validation/balancing.rs
new file mode 100644
index 0000000..8892bca
--- /dev/null
+++ b/src/validation/balancing.rs
@@ -0,0 +1,34 @@
+use std::collections::HashMap;
+
+use crate::model::balancing::{Create, Update};
+use crate::validation::utils::*;
+
+pub fn create(form: &HashMap<String, String>) -> Option<Create> {
+ let source = parse::<i64>(form, "source")?;
+ let destination = parse::<i64>(form, "destination")?;
+
+ if source == destination {
+ None
+ } else {
+ Some(Create {
+ source,
+ destination,
+ amount: parse::<i64>(form, "amount")?
+ })
+ }
+}
+
+pub fn update(form: &HashMap<String, String>) -> Option<Update> {
+ let source = parse::<i64>(form, "source")?;
+ let destination = parse::<i64>(form, "destination")?;
+
+ if source == destination {
+ None
+ } else {
+ Some(Update {
+ source,
+ destination,
+ amount: parse::<i64>(form, "amount")?
+ })
+ }
+}
diff --git a/src/validation/mod.rs b/src/validation/mod.rs
index 181abc7..291eb4f 100644
--- a/src/validation/mod.rs
+++ b/src/validation/mod.rs
@@ -1,3 +1,4 @@
+pub mod balancing;
pub mod category;
pub mod income;
pub mod login;
diff --git a/templates/balancing/create.html b/templates/balancing/create.html
new file mode 100644
index 0000000..c9bfdba
--- /dev/null
+++ b/templates/balancing/create.html
@@ -0,0 +1,79 @@
+{% extends "base.html" %}
+
+{% block title %}
+ Nouvel équilibrage
+{% endblock title %}
+
+{% block main %}
+
+ <section class="g-Section">
+ <p class="g-Paragraph">
+ <a
+ class="g-Link g-Media__Large"
+ href="/balancings?page={{ query.page or 1 }}"
+ >
+ Retour aux équilibrages
+ </a>
+ </p>
+
+ <form
+ class="g-Form"
+ action="/balancing/create?page={{ query.page or 1 }}"
+ method="POST"
+ >
+ <h1 class="g-H1">
+ Nouvel équilibrage
+ </h1>
+
+ {% if error %}
+ <div class="g-Form__Error">{{ error }}</div>
+ {% endif %}
+
+ <label class="g-Form__Label">
+ Montant
+ <input
+ name="amount"
+ type="number"
+ class="g-Form__Input"
+ value="{{ form.amount or "" }}"
+ min=1
+ required
+ {% if not form %} autofocus {% endif %}
+ />
+ </label>
+
+ {% set user_id = form.user_id or connected_user.id %}
+
+ <label class="g-Form__Label">
+ De
+ <select name="source" class="g-Form__Select" required>
+ {% for user in users %}
+ <option
+ value="{{ user.id }}"
+ {% if "" ~ user.id == "" ~ user_id %} selected {% endif %}
+ >
+ {{ user.name }}
+ </option>
+ {% endfor %}
+ </select>
+ </label>
+
+ <label class="g-Form__Label">
+ Vers
+ <select name="destination" class="g-Form__Select" required>
+ <option value="" disabled selected></option>
+ {% for user in users %}
+ <option value="{{ user.id }}">
+ {{ user.name }}
+ </option>
+ {% endfor %}
+ </select>
+ </label>
+
+ <div>
+ <input class="g-Button__Validate" type="submit" value="Créer" />
+ </div>
+ </form>
+ </section>
+
+{% endblock main %}
diff --git a/templates/balancing/table.html b/templates/balancing/table.html
new file mode 100644
index 0000000..72f3b37
--- /dev/null
+++ b/templates/balancing/table.html
@@ -0,0 +1,56 @@
+{% import "macros/paging.html" as paging %}
+
+{% extends "base.html" %}
+
+{% block title %}
+ Équilibrages
+{% endblock title %}
+
+{% block main %}
+
+ <section class="g-Section">
+
+ {% if not balancings %}
+ <div class="g-Table__NoResults">
+ Il n’y a aucun équilibrage.
+ </div>
+ {% endif %}
+
+ <a
+ class="g-Paragraph g-Button__Validate"
+ href="/balancing?page={{ page or 1 }}"
+ >
+ Ajouter un équilibrage
+ </a>
+
+ {% if balancings %}
+ <div class="g-Table">
+ <div class="g-Table__Row g-Table__Row--Header">
+ <span class="g-Table__Cell">Montant</span>
+ <span class="g-Table__Cell">De</span>
+ <span class="g-Table__Cell">Vers</span>
+ </div>
+ {% for balancing in balancings %}
+ <a
+ class="g-Table__Row {% if highlight == balancing.id %} g-Table__Row--Highlight {% endif %}"
+ href="/balancing/{{ balancing.id }}?page={{ page or 1 }}"
+ >
+ <span class="g-Table__Cell g-Table__NumericCell">
+ {{ balancing.amount | euros() }}
+ </span>
+ <span class="g-Table__Cell">{{ balancing.source }}</span>
+ <span class="g-Table__Cell">{{ balancing.destination }}</span>
+ </a>
+ {% endfor %}
+ </div>
+
+ {{ paging.view(
+ url="/balancings",
+ page=page,
+ max_page=max_page
+ ) }}
+ {% endif %}
+
+ </section>
+
+{% endblock main %}
diff --git a/templates/balancing/update.html b/templates/balancing/update.html
new file mode 100644
index 0000000..9c98e93
--- /dev/null
+++ b/templates/balancing/update.html
@@ -0,0 +1,105 @@
+{% extends "base.html" %}
+
+{% block title %}
+ Équilibrage {{ id }}
+{% endblock title %}
+
+{% block main %}
+
+ <section class="g-Section">
+ <p class="g-Paragraph">
+ <a
+ class="g-Link g-Media__Large"
+ href="/balancings?page={{ query.page or 1 }}"
+ >
+ Retour aux équilibrages
+ </a>
+ </p>
+
+ {% if error %}
+ <div class="g-Form__Error">{{ error }}</div>
+ {% endif %}
+
+ {% if not balancing %}
+
+ L’équilibrage n’a pas été trouvé.
+
+ {% else %}
+
+ <form
+ class="g-Form"
+ action="/balancing/{{ balancing.id }}/update"
+ method="POST"
+ >
+ <h1 class="g-H1">Modification</h1>
+
+ <label class="g-Form__Label">
+ Montant
+ <input
+ name="amount"
+ type="number"
+ class="g-Form__Input"
+ value="{{ form.amount or balancing.amount }}"
+ min=1
+ required
+ />
+ </label>
+
+ {% set source = form.source or balancing.source %}
+
+ <label class="g-Form__Label">
+ De
+ <select name="source" class="g-Form__Select" required>
+ {% for user in users %}
+ <option
+ value="{{ user.id }}"
+ {% if "" ~ user.id == "" ~ source %} selected {% endif %}
+ >
+ {{ user.name }}
+ </option>
+ {% endfor %}
+ </select>
+ </label>
+
+ {% set destination = form.destination or balancing.destination %}
+
+ <label class="g-Form__Label">
+ Vers
+ <select name="destination" class="g-Form__Select" required>
+ {% for user in users %}
+ <option
+ value="{{ user.id }}"
+ {% if "" ~ user.id == "" ~ destination %} selected {% endif %}
+ >
+ {{ user.name }}
+ </option>
+ {% endfor %}
+ </select>
+ </label>
+
+ <div>
+ <input class="g-Button__Validate" type="submit" value="Modifier" />
+ </div>
+ </form>
+
+ <form
+ class="g-Form"
+ action="/balancing/{{ balancing.id }}/delete"
+ method="POST"
+ >
+ <h1 class="g-H1">Suppression</h1>
+
+ <label class="g-Form__Label">
+ Veuillez recopier le montant de l’équilibrage : « {{ balancing.amount }} ».
+ <input name="remove-input" class="g-Form__Input" data-name="{{ balancing.amount }}" />
+ </label>
+
+ <div>
+ <input id="remove-button" class="g-Button__Danger" type="submit" value="Supprimer" disabled />
+ </div>
+ </form>
+
+ {% endif %}
+ </section>
+
+{% endblock main %}
diff --git a/templates/base.html b/templates/base.html
index 9865e16..e6f38aa 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -52,6 +52,16 @@
</a>
<a
+ href="/balancings"
+ class="
+ g-Nav__Link
+ {% if header == "Balancing" %} g-Nav__Link--Current {% endif %}
+ "
+ >
+ Équilibrage
+ </a>
+
+ <a
href="/balance"
class="
g-Nav__Link