From 13687748e01ce2ca01e68fd43e7b53a9f4dd1f40 Mon Sep 17 00:00:00 2001 From: Miroito Date: Sat, 2 Dec 2023 18:01:38 +0100 Subject: [PATCH] feat followed companies page --- client/src/api/routes/authenticated/mod.rs | 46 +++++++++- client/src/api/types/paginated_response.rs | 99 +++++++++++++++++++++- client/src/api/types/transaction.rs | 16 ++++ client/src/capsules/mod.rs | 1 + client/src/capsules/user_header.rs | 40 +++++++++ client/src/capsules/user_icon.rs | 1 + client/src/components/base_table.rs | 10 +-- client/src/components/the_header.rs | 9 +- client/src/main.rs | 3 + client/src/templates/mod.rs | 1 + client/src/templates/user_transactions.rs | 89 +++++++++++++++++++ client/static/tailwind.css | 2 +- server/src/db/paginate.rs | 17 ++-- server/src/main.rs | 8 +- server/src/route/authenticated.rs | 80 ++++++++++++++++- 15 files changed, 397 insertions(+), 25 deletions(-) create mode 100644 client/src/capsules/user_header.rs create mode 100644 client/src/templates/user_transactions.rs diff --git a/client/src/api/routes/authenticated/mod.rs b/client/src/api/routes/authenticated/mod.rs index 943b9a3..cbe10c4 100644 --- a/client/src/api/routes/authenticated/mod.rs +++ b/client/src/api/routes/authenticated/mod.rs @@ -2,6 +2,7 @@ use crate::api::{ types::{ company::Company, paginated_response::PaginatedResponse, + transaction::UserTransaction, user::{FollowCompany, UserProfile}, }, FastInsidersApi, @@ -108,6 +109,21 @@ impl FastInsidersApi { Ok(()) } + pub async fn unfollow_company(&self, company_id: i32) -> Result<(), ()> { + let route = &format!("{}/auth/unfollow_company", self.url); + + #[cfg(client)] + let res = { + let body = FollowCompany { company_id }; + let resp = post_auth_route(route, body).await?; + }; + + #[cfg(engine)] + return Err(()); + + Ok(()) + } + pub async fn get_followed_companies( &self, page: i64, @@ -126,7 +142,6 @@ impl FastInsidersApi { .map_err(|_| ())? }; - #[cfg(engine)] #[cfg(engine)] let res = reqwest::get(route) .await @@ -137,4 +152,33 @@ impl FastInsidersApi { Ok(res) } + + pub async fn get_user_transactions( + &self, + page: i64, + size: i64, + ) -> Result, ()> { + let route = &format!( + "{}/auth/user_transactions?page={}&size={}", + self.url, page, size + ); + + #[cfg(client)] + let res = { + let resp = get_auth_route(route).await?; + resp.json::>() + .await + .map_err(|_| ())? + }; + + #[cfg(engine)] + let res = reqwest::get(route) + .await + .map_err(|_| ())? + .json::>() + .await + .map_err(|_| ())?; + + Ok(res) + } } diff --git a/client/src/api/types/paginated_response.rs b/client/src/api/types/paginated_response.rs index 2407b9a..cd389bd 100644 --- a/client/src/api/types/paginated_response.rs +++ b/client/src/api/types/paginated_response.rs @@ -1,9 +1,10 @@ +use perseus::{reactor::Reactor, web_log, prelude::navigate}; use serde::{Deserialize, Serialize}; -use sycamore::prelude::*; +use sycamore::{prelude::*, futures::spawn_local_scoped, rt::Event}; -use crate::components::base_table::TableContent; +use crate::{components::base_table::TableContent, global_state::AppStateRx}; -use super::{transaction::{TransactionCompany, TransactionsAggregated, LatestTransaction, MajorTransactions}, company::Company}; +use super::{transaction::{TransactionCompany, TransactionsAggregated, LatestTransaction, MajorTransactions, UserTransaction}, company::Company}; pub trait IntoTableData where @@ -193,13 +194,43 @@ where impl IntoTableData for PaginatedResponse where - G: GenericNode, + G: GenericNode + perseus::prelude::Html, { fn into_table_data(self, cx: Scope) -> TableContent { let headers_view = vec![ view! {cx, "Company" }, + view! {cx, "Unfollow" }, ]; + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + let api = global_state.api.get(); + let api_scope_ref = create_ref(cx, api); + + /// This function returns a function that knows wich company id to remove + let unfollow_company = move |company_id: i32| { + #[cfg(client)] + return { + move |e: Event| { + spawn_local_scoped(cx, async move { + match api_scope_ref.unfollow_company(company_id).await { + Ok(()) => { + // TODO find a way to only ask for the table to refresh instead of + // the complete page + navigate("/profile") + } + Err(e) => (), + }; + }) + } + }; + + #[cfg(engine)] + return { + move |_| {} + }; + }; + let data_view: Vec>> = self .list .into_iter() @@ -212,6 +243,66 @@ where (t.name.to_owned()) } }); + res.push(view!{cx, + svg (class="m-auto cursor-pointer", on:click=unfollow_company(t.id), xmlns="http://www.w3.org/2000/svg", width="24", height="24", viewBox="0 0 24 24", fill="none", stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round") { + circle (cx="12", cy="12", r="10") {} + line (x1="8", y1="12", x2="16", y2="12") {} + } + }); + + res + }) + .collect(); + + TableContent { + headers_view, + data_view, + } + } +} + + +impl IntoTableData for PaginatedResponse +where + G: GenericNode, +{ + fn into_table_data(self, cx: Scope) -> TableContent { + let headers_view = vec![ + view! {cx, "Company" }, + view! {cx, "Date published" }, + view! {cx, "Date executed" }, + view! {cx, "Person" }, + view! {cx, "Nature" }, + view! {cx, "ISIN" }, + view! {cx, "Instrument" }, + view! {cx, "Exchange" }, + view! {cx, "Volume" }, + view! {cx, "Unit price" }, + view! {cx, "Total" }, + ]; + + let data_view: Vec>> = self + .list + .into_iter() + .map(|t| { + let mut res = vec![]; + res.push(view! {cx, + a (href=format!("transactions/{}", t.company_slug), + class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 hover:underline dark:hover:text-indigo-600", + ) { + (t.company_name.to_owned()) + } + }); + res.push(view! {cx, (t.date_published.to_owned()) }); + res.push(view! {cx, (t.date_executed.to_owned()) }); + res.push(view! {cx, (t.person.to_owned()) }); + res.push(view! {cx, (t.nature.to_owned()) }); + res.push(view! {cx, (t.isin.to_owned().unwrap_or_else(|| "-".to_string())) }); + res.push(view! {cx, (t.instrument.to_owned()) }); + res.push(view! {cx, (t.exchange.to_owned()) }); + res.push(view! {cx, (t.volume.to_owned()) }); + res.push(view! {cx, (t.unit_price.to_owned()) }); + res.push(view! {cx, ((t.volume as f32 * t.unit_price).to_string()) }); res }) diff --git a/client/src/api/types/transaction.rs b/client/src/api/types/transaction.rs index 223ea91..d7ea1a5 100644 --- a/client/src/api/types/transaction.rs +++ b/client/src/api/types/transaction.rs @@ -65,3 +65,19 @@ pub struct MajorTransactions { pub unit_price: f32, pub total: f32, } + +#[derive(Clone, Deserialize)] +pub struct UserTransaction { + pub company_name: String, + pub company_slug: String, + pub date_published: NaiveDate, + pub date_executed: NaiveDate, + pub person: String, + pub exchange: String, + pub nature: String, + pub isin: Option, + pub instrument: String, + pub volume: i32, + pub unit_price: f32, + pub total: f32, +} diff --git a/client/src/capsules/mod.rs b/client/src/capsules/mod.rs index dfd8573..23101c8 100644 --- a/client/src/capsules/mod.rs +++ b/client/src/capsules/mod.rs @@ -1,2 +1,3 @@ pub mod dark_mode_btn; +pub mod user_header; pub mod user_icon; diff --git a/client/src/capsules/user_header.rs b/client/src/capsules/user_header.rs new file mode 100644 index 0000000..0275619 --- /dev/null +++ b/client/src/capsules/user_header.rs @@ -0,0 +1,40 @@ +use lazy_static::lazy_static; +use perseus::prelude::*; +use sycamore::{prelude::*, rt::Event}; + +use crate::global_state::AppStateRx; + +lazy_static! { + pub static ref USER_HEADER: Capsule = get_capsule(); +} + +fn user_header(cx: Scope, _props: ()) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + + view! { cx, + (if *global_state.logged_in.get() { + view! { cx, + div (class="px-6 text-left border-l border-slate-700 dark:border-slate-300") { + a (id="header-followed-companies", href="/user_transactions", class="hover:underline") { + "Followed companies" + } + } + } + } else { + view!{cx, } + }) + div (class="grow") {} + } +} + +fn fallback(cx: Scope, _props: ()) -> View { + view! { cx, + } +} + +pub fn get_capsule() -> Capsule { + Capsule::build(Template::build("user_header")) + .fallback(fallback) + .view(user_header) + .build() +} diff --git a/client/src/capsules/user_icon.rs b/client/src/capsules/user_icon.rs index b6f2802..1c213e8 100644 --- a/client/src/capsules/user_icon.rs +++ b/client/src/capsules/user_icon.rs @@ -32,6 +32,7 @@ fn user_icon(cx: Scope, _props: ()) -> View { let logout = move |e: Event| { wasm_cookies::delete("token"); global_state.logged_in.set(false); + navigate("/") }; #[cfg(engine)] diff --git a/client/src/components/base_table.rs b/client/src/components/base_table.rs index 0eba683..d88f9d4 100644 --- a/client/src/components/base_table.rs +++ b/client/src/components/base_table.rs @@ -16,7 +16,7 @@ where { pub headers_view: &'a Signal>>, pub data_view: &'a Signal>>>, - pub table_class: &'a String, + pub table_class: &'a str, } #[derive(Debug, Clone)] @@ -51,7 +51,7 @@ where for (idx, row) in v_table.iter().enumerate() { let views = PEqView(idx, View::new_fragment( row.iter().map(|cell| { - view!{cx, th (class="m-2 p-2 border-slate-500 border-x border-dashed") { (cell) } } + view!{cx, th (class="p-2 m-2 border-dashed border-slate-500 border-x") { (cell) } } } ).collect() )); @@ -64,12 +64,12 @@ where view! { cx, table (class=format!("{} table-auto bg-slate-200 text-left dark:bg-slate-800 rounded-lg mx-auto my-2", props.table_class)) { thead { - tr (class="border-b-2 border-slate-500 text-center") { + tr (class="text-center border-b-2 border-slate-500") { Keyed( iterable=headers, view=|cx, v| { view! {cx, - th (class="m-2 p-2") { (v.1) } + th (class="p-2 m-2") { (v.1) } } }, key=|v| v.0, @@ -81,7 +81,7 @@ where iterable=data, view=|cx, t| { view! {cx, - tr (class="m-2 p-2 border-slate-500 border") { + tr (class="p-2 m-2 border border-slate-500") { (t.1) } } diff --git a/client/src/components/the_header.rs b/client/src/components/the_header.rs index 73f43d0..349e0c8 100644 --- a/client/src/components/the_header.rs +++ b/client/src/components/the_header.rs @@ -1,23 +1,26 @@ use perseus::prelude::*; use sycamore::prelude::*; -use crate::capsules::{dark_mode_btn::DARK_MODE_BTN, user_icon::USER_ICON}; +use crate::capsules::{ + dark_mode_btn::DARK_MODE_BTN, user_header::USER_HEADER, user_icon::USER_ICON, +}; #[component] pub fn TheHeader(cx: Scope) -> View { view! { cx, header (class="p-2 w-full h-11 align-middle bg-gray-100 shadow-md backdrop-blur-lg dark:bg-slate-500/30") { div (class="flex") { - div (class="flex-none mr-12") { + div (class="flex-none mx-6") { a (href="/", class="hover:underline") { "Fast Insiders" } } - div (class="text-left grow") { + div (class="px-6 text-left") { a (id="header-all-transactions", href="/transactions", class="hover:underline") { "All transactions" } } + (USER_HEADER.widget(cx, "",())) div (class="flex items-center pl-3 border-l border-slate-700 dark:border-slate-300") { div(class="mx-1") { (DARK_MODE_BTN.widget(cx,"",())) diff --git a/client/src/main.rs b/client/src/main.rs index abaad89..9714e96 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -17,7 +17,10 @@ pub fn main() -> PerseusApp { .template(crate::templates::login::get_template()) .template(crate::templates::profile::get_template()) .template(crate::templates::register::get_template()) + .template(crate::templates::user_transactions::get_template()) .capsule_ref(&*crate::capsules::dark_mode_btn::DARK_MODE_BTN) + .capsule_ref(&*crate::capsules::user_icon::USER_ICON) + .capsule_ref(&*crate::capsules::user_header::USER_HEADER) .global_state_creator(crate::global_state::get_global_state_creator()) .error_views(crate::error_pages::get_error_views()) .index_view(|cx| { diff --git a/client/src/templates/mod.rs b/client/src/templates/mod.rs index 744a102..8274f5d 100644 --- a/client/src/templates/mod.rs +++ b/client/src/templates/mod.rs @@ -3,3 +3,4 @@ pub mod login; pub mod profile; pub mod register; pub mod transactions; +pub mod user_transactions; diff --git a/client/src/templates/user_transactions.rs b/client/src/templates/user_transactions.rs new file mode 100644 index 0000000..370d0cc --- /dev/null +++ b/client/src/templates/user_transactions.rs @@ -0,0 +1,89 @@ +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +use crate::{ + components::{ + base_async_select::{AsyncSelectRx, BaseAsyncSelect}, + base_button::{BaseButton, BaseButtonStateRx}, + main_content_container::MainContentContainer, + paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + the_header::TheHeader, + }, + global_state::AppStateRx, +}; + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "TransactionsPageStateRx")] +pub struct TransactionsPageState { + pub company_slug: Option, +} + +fn user_transactions_page<'a, G: Html>(cx: Scope) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + let api = global_state.api.get(); + let api_scope_ref = create_ref(cx, api); + + let expand = create_signal(cx, false); + let filter_expand = BaseButtonStateRx { + label: create_signal(cx, "Filters".to_string()), + disabled: create_signal(cx, false), + clicked: create_signal(cx, false), + }; + + create_effect(cx, move || { + if *filter_expand.clicked.get() { + filter_expand.clicked.set(false); + expand.set(!*expand.get()); + } + }); + + let route_ref = create_ref(cx, move |_, p, s| api_scope_ref.get_user_transactions(p, s)); + let paginated_table_state: PaginatedTableStateRx<_, _, _> = PaginatedTableStateRx { + record_label: "transactions".to_owned(), + route: route_ref, + filter: None, + table_class: create_ref(cx, "".to_string()), + refresh: create_signal(cx, true), + }; + + let dark_mode_class = create_memo(cx, || { + if *global_state.dark_mode.get() { + "dark" + } else { + "" + } + }); + + view! {cx, + main (class=format!("{} flex flex-1", dark_mode_class)) { + div (class="flex-1 font-sans bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100") { + TheHeader() + MainContentContainer(useless_prop=1) { + a (class="hover:underline", href="/user_transactions") { + h1 ( + class="text-lg text-center" + ) { + "Latest transactions from your followed companies" + } + } + PaginatedTable(paginated_table_state) + } + } + } + } +} + +pub fn get_template() -> Template { + Template::build("user_transactions") + .head(head) + .view(user_transactions_page) + .build() +} + +#[engine_only_fn] +fn head(cx: Scope) -> View { + view! {cx, + title { "Fast Insiders" } + } +} diff --git a/client/static/tailwind.css b/client/static/tailwind.css index fbe347c..eb24b81 100644 --- a/client/static/tailwind.css +++ b/client/static/tailwind.css @@ -1 +1 @@ -/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.absolute{position:absolute}.relative{position:relative}.-top-1{top:-.25rem}.z-0{z-index:0}.m-1{margin:.25rem}.m-10{margin:2.5rem}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-bottom:.5rem;margin-top:.5rem}.mx-10{margin-left:2.5rem;margin-right:2.5rem}.my-auto{margin-bottom:auto;margin-top:auto}.mb-1{margin-bottom:.25rem}.mr-12{margin-right:3rem}.mt-0{margin-top:0}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.flex{display:flex}.table{display:table}.contents{display:contents}.h-0{height:0}.h-11{height:2.75rem}.h-40{height:10rem}.h-6{height:1.5rem}.w-1\/3{width:33.333333%}.w-2\/5{width:40%}.w-4\/5{width:80%}.w-6{width:1.5rem}.w-80{width:20rem}.w-full{width:100%}.max-w-xs{max-width:20rem}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.max-w-2xl{max-width:42rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-grow,.grow{flex-grow:1}.table-auto{table-layout:auto}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-4{gap:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b-md{border-bottom-left-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-x{border-left-width:1px;border-right-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-dashed{border-style:dashed}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-3{padding:.75rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.pl-3{padding-left:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-lg{font-size:1.125rem;line-height:1.75rem}.font-bold{font-weight:700}.italic{font-style:italic}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-lg{--tw-backdrop-blur:blur(16px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}html{height:100%;width:100%}body{display:flex;flex-direction:column;min-height:100%}div#root{display:flex;flex:1;flex-direction:column}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-slate-400:hover{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity))}.hover\:font-bold:hover{font-weight:700}.hover\:text-indigo-500:hover{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}:is(.dark .dark\:border-slate-300){--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-800){--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity))}:is(.dark .dark\:bg-slate-500\/30){background-color:#64748b4d}:is(.dark .dark\:bg-slate-600){--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-700){--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-800){--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}:is(.dark .dark\:text-indigo-300){--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity))}:is(.dark .dark\:text-rose-500){--tw-text-opacity:1;color:rgb(244 63 94/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-100){--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity))}:is(.dark .dark\:hover\:bg-slate-900:hover){--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity))}:is(.dark .dark\:hover\:text-indigo-600:hover){--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))} \ No newline at end of file +/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.absolute{position:absolute}.relative{position:relative}.-top-1{top:-.25rem}.z-0{z-index:0}.m-1{margin:.25rem}.m-10{margin:2.5rem}.m-2{margin:.5rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-6{margin-left:1.5rem;margin-right:1.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-6{margin-bottom:1.5rem;margin-top:1.5rem}.mb-1{margin-bottom:.25rem}.mr-12{margin-right:3rem}.mt-0{margin-top:0}.mt-3{margin-top:.75rem}.flex{display:flex}.table{display:table}.contents{display:contents}.h-0{height:0}.h-11{height:2.75rem}.h-40{height:10rem}.h-6{height:1.5rem}.w-1\/3{width:33.333333%}.w-4\/5{width:80%}.w-6{width:1.5rem}.w-80{width:20rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-grow,.grow{flex-grow:1}.table-auto{table-layout:auto}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-4{gap:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b-md{border-bottom-left-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-x{border-left-width:1px;border-right-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-dashed{border-style:dashed}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-3{padding:.75rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.pl-3{padding-left:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-lg{font-size:1.125rem;line-height:1.75rem}.font-bold{font-weight:700}.italic{font-style:italic}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-lg{--tw-backdrop-blur:blur(16px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}html{height:100%;width:100%}body{display:flex;flex-direction:column;min-height:100%}div#root{display:flex;flex:1;flex-direction:column}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-slate-400:hover{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity))}.hover\:font-bold:hover{font-weight:700}.hover\:text-indigo-500:hover{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}:is(.dark .dark\:border-slate-300){--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity))}:is(.dark .dark\:border-slate-800){--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity))}:is(.dark .dark\:bg-slate-500\/30){background-color:#64748b4d}:is(.dark .dark\:bg-slate-600){--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-700){--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity))}:is(.dark .dark\:bg-slate-800){--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}:is(.dark .dark\:text-indigo-300){--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity))}:is(.dark .dark\:text-rose-500){--tw-text-opacity:1;color:rgb(244 63 94/var(--tw-text-opacity))}:is(.dark .dark\:text-slate-100){--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity))}:is(.dark .dark\:hover\:bg-slate-900:hover){--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity))}:is(.dark .dark\:hover\:text-indigo-600:hover){--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))} \ No newline at end of file diff --git a/server/src/db/paginate.rs b/server/src/db/paginate.rs index 2d8abbe..8db9c54 100644 --- a/server/src/db/paginate.rs +++ b/server/src/db/paginate.rs @@ -1,5 +1,6 @@ use sea_orm::{ - error::DbErr, prelude::*, sea_query::SimpleExpr, FromQueryResult, Order, QueryOrder, + error::DbErr, prelude::*, sea_query::SimpleExpr, FromQueryResult, ItemsAndPagesNumber, Order, + QueryOrder, }; use serde::{Deserialize, Serialize}; @@ -31,9 +32,10 @@ where } let pages = selector.into_model().paginate(db, s); - let count = pages.num_items().await?; - - let num_pages = pages.num_pages().await?; + let ItemsAndPagesNumber { + number_of_items: count, + number_of_pages: num_pages, + } = pages.num_items_and_pages().await?; let p = page.unwrap_or(0).min(num_pages); @@ -82,9 +84,10 @@ where } let pages = selector.into_model().paginate(db, s); - let count = pages.num_items().await?; - - let num_pages = pages.num_pages().await?; + let ItemsAndPagesNumber { + number_of_items: count, + number_of_pages: num_pages, + } = pages.num_items_and_pages().await?; let p = page.unwrap_or(0).min(num_pages); diff --git a/server/src/main.rs b/server/src/main.rs index 518ec25..090cc91 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -47,8 +47,8 @@ use crate::{ crypto::{jwt_numeric_date, JWTSecretManager}, route::{ authenticated::{ - follow_company_route, get_followed_companies, get_profile, is_authenticated, - unfollow_company_route, + follow_company_route, get_followed_companies, get_profile, + get_user_followed_companies_transactions, is_authenticated, unfollow_company_route, }, user, }, @@ -195,6 +195,10 @@ pub async fn main() -> Result<(), Box> { .route("/is_authenticated", get(is_authenticated)) .route("/profile", get(get_profile)) .route("/get_followed_companies", get(get_followed_companies)) + .route( + "/user_transactions", + get(get_user_followed_companies_transactions), + ) .route("/follow_company", post(follow_company_route)) .route("/unfollow_company", post(unfollow_company_route)) .layer(from_fn_with_state(shared_state.clone(), authenticator)) diff --git a/server/src/route/authenticated.rs b/server/src/route/authenticated.rs index fe71518..d8f3556 100644 --- a/server/src/route/authenticated.rs +++ b/server/src/route/authenticated.rs @@ -2,8 +2,10 @@ use axum::{ extract::{Query, State}, Extension, Json, }; +use chrono::NaiveDate; use sea_orm::{ - ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, + sea_query::Expr, ColumnTrait, EntityTrait, FromQueryResult, ItemsAndPagesNumber, JoinType, + ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, }; use serde::{Deserialize, Serialize}; @@ -12,7 +14,7 @@ use crate::{ error::AppError, model::{ self, - prelude::{Company, User}, + prelude::{Company, Transaction, User}, }, repo::user::{follow_company, unfollow_company}, AppState, UserJWTClaim, @@ -103,6 +105,7 @@ pub async fn get_followed_companies( .await? .ok_or(AppError::NotFound("User not found".to_string()))?; + // Eventually switch these 2 by using cloumn_as to make a count column let (count_res, companies) = tokio::join!(user.find_related(Company).count(db), async { user.find_related(Company) .order_by(model::company::Column::Name, sea_orm::Order::Asc) @@ -122,3 +125,76 @@ pub async fn get_followed_companies( Ok(Json(res)) } + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, FromQueryResult)] +pub struct UserTransaction { + pub company_name: String, + pub company_slug: String, + pub date_published: NaiveDate, + pub date_executed: NaiveDate, + pub person: String, + pub exchange: String, + pub nature: String, + pub isin: Option, + pub instrument: String, + pub volume: i32, + pub unit_price: f32, + pub total: f32, +} + +pub async fn get_user_followed_companies_transactions( + Extension(token_data): Extension, + State(state): State, + Query(Pagination { page, size }): Query, +) -> Result>, AppError> { + let db = &state.db; + + let s = size.unwrap_or(20).min(50); + + let query = Transaction::find() + .select_only() + .join( + JoinType::InnerJoin, + model::transaction::Relation::Company.def(), + ) + .join( + JoinType::InnerJoin, + model::company::Relation::UserCompany.def(), + ) + .column_as(model::company::Column::Name, "company_name") + .column_as(model::company::Column::Slug, "company_slug") + .column(model::transaction::Column::DatePublished) + .column(model::transaction::Column::DateExecuted) + .column(model::transaction::Column::Person) + .column(model::transaction::Column::Exchange) + .column(model::transaction::Column::Nature) + .column(model::transaction::Column::Isin) + .column(model::transaction::Column::Instrument) + .column(model::transaction::Column::Volume) + .column(model::transaction::Column::UnitPrice) + .column_as( + Expr::col(model::transaction::Column::UnitPrice) + .mul(Expr::col(model::transaction::Column::Volume)), + "total", + ) + .filter(model::user_company::Column::UserId.eq(token_data.user_id)) + .order_by_desc(model::transaction::Column::DatePublished) + .into_model::() + .paginate(db, s); + + let ItemsAndPagesNumber { + number_of_pages: num_pages, + number_of_items: count, + } = query.num_items_and_pages().await?; + + let p = page.unwrap_or(0).min(num_pages); + let list = query.fetch_page(p).await?; + + let res = PaginatedResponse { + count, + num_pages, + list, + }; + + Ok(Json(res)) +}