invest
All checks were successful
ci/woodpecker/push/build Pipeline was successful

This commit is contained in:
JMARyA 2025-02-23 04:37:13 +01:00
parent d44246a483
commit 197e4cfde9
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
8 changed files with 302 additions and 50 deletions

View file

@ -12,6 +12,7 @@ pub struct Project {
pub description: String,
pub icon: Option<String>,
pub website: Option<String>,
pub repositories: Option<Vec<String>>,
pub documentation: Option<String>,
pub since: Option<String>,
pub contact: Option<ContactInfo>,

View file

@ -1,10 +1,97 @@
// TODO : Investment feature
// TODO : Reoccuring investments
use based::get_pg;
use rocket::{form::Form, post, response::Redirect, FromForm};
use sqlx::prelude::FromRow;
#[derive(Debug, Clone, FromRow)]
pub struct Investment {
pub id: i64,
pub project: String,
pub title: String,
pub description: String,
pub amount: f64,
pub what: String,
pub why: String,
pub when: String,
pub payed: bool,
}
impl Investment {
pub async fn new_investment(
project: &str,
amount: f64,
title: &str,
description: &str,
) -> Investment {
let project = project.trim_end_matches("/");
sqlx::query_as(
"INSERT INTO investment (project, title, description, amount)
VALUES ($1, $2, $3, $4)
RETURNING *",
)
.bind(project)
.bind(title)
.bind(description)
.bind(amount)
.fetch_one(get_pg!())
.await
.unwrap()
}
pub async fn pay(id: i64) -> String {
let res: (String,) =
sqlx::query_as("UPDATE investment SET payed = true WHERE id = $1 RETURNING project")
.bind(id)
.fetch_one(get_pg!())
.await
.unwrap();
res.0
}
pub async fn get_all_investments_of(prj: &str) -> Vec<Investment> {
let prj = prj.trim_end_matches("/");
sqlx::query_as("SELECT * FROM investment WHERE project = $1 ORDER BY \"when\" DESC")
.bind(prj)
.fetch_all(get_pg!())
.await
.unwrap()
}
pub async fn get_total_of(prj: &str) -> (f64, f64) {
let prj = prj.trim_end_matches("/");
let payed: Result<(f64,), sqlx::Error> = sqlx::query_as(
"SELECT SUM(amount) FROM investment WHERE project = $1 AND payed = true",
)
.bind(prj)
.fetch_one(get_pg!())
.await;
let total: Result<(f64,), sqlx::Error> =
sqlx::query_as("SELECT SUM(amount) FROM investment WHERE project = $1")
.bind(prj)
.fetch_one(get_pg!())
.await;
if let (Ok(payed), Ok(total)) = (payed, total) {
(payed.0, total.0)
} else {
(0.00, 0.00)
}
}
}
#[derive(FromForm)]
pub struct NewInvestForm {
title: String,
amount: String,
description: String,
}
#[post("/prj/<root>/invest", data = "<form>")]
pub async fn post_new_invest(root: String, form: Form<NewInvestForm>) -> Redirect {
Investment::new_investment(
&root,
form.amount.parse().unwrap(),
&form.title,
&form.description,
)
.await;
Redirect::to(format!("/prj/{}?tab=invest", root))
}

View file

@ -1,17 +1,25 @@
use std::path::Path;
use based::asset::AssetRoutes;
use based::page;
use based::request::{RequestContext, StringResponse};
use based::ui::components::{Breadcrumb, Shell, Tabs};
use based::ui::prelude::*;
use based::ui::primitives::flex::Row;
use based::ui::components::prelude::{
ColoredMaterialIcon, MaterialIcon, Modal, ModalOpenButton, Shell, Timeline, TimelineElement,
Tooltip,
};
use based::ui::components::{Breadcrumb, Card, ProgressBar, Tabs};
use based::ui::primitives::div::Center;
use based::ui::primitives::flex::{Column, Row};
use based::ui::primitives::list::{CheckIcon, CheckIconRounded, CheckIconRoundedGray};
use based::ui::primitives::Optional;
use based::ui::{prelude::*, AttrExtendable};
use based::{get_pg, page};
use config::{get_prj, Project, ProjectConfig};
use invest::Investment;
use maud::{PreEscaped, Render};
use rocket::fs::NamedFile;
use rocket::request::FromSegments;
use rocket::{get, launch, routes, State};
use rocket::response::Redirect;
use rocket::{get, launch, post, routes, State};
use site::gen_shell;
mod config;
@ -52,19 +60,20 @@ impl<'r> FromSegments<'r> for PathSegment {
}
}
#[get("/prj/<path..>")]
#[get("/prj/<path..>?<tab>")]
pub async fn project_page(
path: PathSegment,
ctx: RequestContext,
shell: &State<Shell>,
c: &State<ProjectConfig>,
tab: Option<&str>,
) -> Option<StringResponse> {
let mut prj = c.get(path.segments.first()?);
for p in &path.segments[1..] {
prj = prj.as_ref()?.sub.as_ref()?.get(p);
}
Some(render_project(shell, ctx, prj?, c, path.to_str()).await)
Some(render_project(shell, ctx, prj?, c, path.to_str(), tab.unwrap_or_default()).await)
}
#[get("/static/<file>")]
@ -84,7 +93,7 @@ pub async fn main_page(
let first = keys.first().unwrap();
let prj = c.get(*first).unwrap();
render_project(shell, ctx, prj, c, format!("{first}/")).await
render_project(shell, ctx, prj, c, format!("{first}/"), "").await
} else {
// TODO : root project overview
site::gen_site(c, ctx).await
@ -105,6 +114,120 @@ pub fn DetailLink(text: &str, reference: &str) -> PreEscaped<String> {
.render()
}
pub fn build_invest_timeline(investments: &[Investment]) -> PreEscaped<String> {
let mut t = Timeline();
for i in investments {
t = t.add_element(TimelineElement::new(
Text(&i.when),
Row(vec![
Text(&i.title).render(),
Text(&format!("{:.2} $", i.amount)).render(),
if i.payed {
Tooltip(CheckIconRounded(), Text("Payed")).render()
} else {
CheckIconRoundedGray()
},
])
.gap(ScreenValue::_2)
.items_center(),
Column(vec![
Text(&i.description).render(),
if !i.payed {
Form::new(&format!("/invest/pay?id={}", i.id))
.add_input(FormSubmitButton(Text("Pay")))
.render()
} else {
Nothing()
},
])
.gap(ScreenValue::_2),
))
}
t.render()
}
pub async fn invest_tab(prj: &Project, root: &str) -> PreEscaped<String> {
let (add_invest_id, add_invest_modal) = Modal(
"Add Investment",
Form::new(&format!("/prj/{root}invest"))
.id("new_invest")
.add_input(TextInput("title").required().placeholder("Title"))
.add_input(
TextInput("amount")
.icon(MaterialIcon("attach_money"))
.required()
.placeholder("Amount")
.pattern(r#"^\d+(\.\d+)?$"#),
)
.add_input(
TextArea()
.name("description")
.required()
.placeholder("Description"),
),
|_| {
Center(Context(
FormSubmitButton("Create").add_attr("form", "new_invest"),
))
},
);
let investments = Investment::get_all_investments_of(root).await;
let t = build_invest_timeline(&investments);
let inv = Investment::get_total_of(root).await;
let percentage = inv.0 / inv.1;
let percentage = (percentage * 100.0) as u8;
Column(vec![
add_invest_modal,
Margin(
Row(vec![
Margin(Width(
ScreenValue::full,
Row(vec![
ColoredMaterialIcon("paid", Green::_600),
Text(&format!(
"Total Investment: {:.2} $ / {:.2} $",
inv.0, inv.1
))
.color(&Green::_600)
.render(),
Width(
ScreenValue::full,
Margin(ProgressBar(percentage, true, Green::_600)).x(ScreenValue::_12),
)
.render(),
])
.gap(ScreenValue::_2)
.full_center(),
))
.x(ScreenValue::auto)
.render(),
ModalOpenButton(
&add_invest_id,
Padding(
Rounded(Background(MaterialIcon("add")).color(Blue::_600))
.size(Size::Medium),
)
.x(ScreenValue::_2)
.top(ScreenValue::_2),
)
.render(),
])
.justify(Justify::Between),
)
.left(ScreenValue::_4)
.render(),
t,
])
.render()
}
pub fn info_tab(prj: &Project, root: &str) -> PreEscaped<String> {
Flex(
Div()
@ -119,6 +242,14 @@ pub fn info_tab(prj: &Project, root: &str) -> PreEscaped<String> {
.push(Optional(prj.documentation.as_deref(), |docs| {
DetailLink("Documentation:", docs)
}))
.push(Optional(prj.repositories.as_deref(), |repos| {
Column(vec![
Text("Repositories:").xl().semibold().render(),
UnorderedList()
.push_for_each(repos, |repo: &_| Link(repo, Text(repo)))
.render(),
])
}))
.push(Optional(prj.since.as_deref(), |since| {
Text(&format!("Since: {since}"))
}))
@ -193,6 +324,7 @@ pub async fn render_project(
prj: &Project,
c: &State<ProjectConfig>,
mut root: String,
tab: &str,
) -> StringResponse {
let title = format!("{} - Umbrella ☂️", prj.name);
@ -214,6 +346,8 @@ pub async fn render_project(
})
.collect();
let invest_tab = invest_tab(prj, &root).await;
page!(
shell,
ctx,
@ -230,12 +364,9 @@ pub async fn render_project(
.push(
Padding(
Tabs()
.active(tab)
.add_tab("info", Text("📜 Info").medium(), info_tab(prj, &root))
.add_tab(
"invest",
Text("💵 Invest").medium(),
Div().vanish().push(Text("TODO"))
)
.add_tab("invest", Text("💵 Invest").medium(), invest_tab)
)
.x(ScreenValue::_6)
)
@ -254,9 +385,26 @@ async fn rocket() -> _ {
let shell = gen_shell();
sqlx::migrate!("./migrations").run(get_pg!()).await.unwrap();
rocket::build()
.mount_assets()
.mount("/", routes![main_page, icon_res, project_page])
.mount(
"/",
routes![
main_page,
icon_res,
project_page,
invest::post_new_invest,
pay_investment
],
)
.manage(conf)
.manage(shell)
}
#[post("/invest/pay?<id>")]
pub async fn pay_investment(id: i64) -> Redirect {
let prj = Investment::pay(id).await;
Redirect::to(format!("/prj/{}?tab=invest", prj))
}

View file

@ -1,6 +1,6 @@
use based::{
request::{RequestContext, StringResponse},
ui::components::Shell,
ui::components::prelude::Shell,
};
use maud::{html, Render};