initial commit
This commit is contained in:
475
platform/src/app.rs
Normal file
475
platform/src/app.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::routing::{AppView, ViewContext, HistoryManager};
|
||||
use crate::components::{Header, Sidebar, Footer, ToastContainer, ToastMessage, create_success_toast, ResidentLandingOverlay};
|
||||
use crate::views::{
|
||||
HomeView, AdministrationView, PersonAdministrationView, BusinessView, AccountingView, ContractsView,
|
||||
GovernanceView, TreasuryView, ResidenceView, EntitiesView, ResidentRegistrationView
|
||||
};
|
||||
use crate::models::company::DigitalResident;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Msg {
|
||||
SwitchView(AppView),
|
||||
SwitchContext(ViewContext),
|
||||
ToggleSidebar,
|
||||
PopStateChanged,
|
||||
ShowToast(ToastMessage),
|
||||
DismissToast(u32),
|
||||
Login,
|
||||
Logout,
|
||||
ToggleTheme,
|
||||
ShowResidentLanding,
|
||||
HideResidentLanding,
|
||||
ResidentSignIn(String, String), // email, password
|
||||
ResidentRegistrationComplete,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
current_view: AppView,
|
||||
current_context: ViewContext,
|
||||
sidebar_visible: bool,
|
||||
toasts: Vec<ToastMessage>,
|
||||
next_toast_id: u32,
|
||||
is_logged_in: bool,
|
||||
user_name: Option<String>,
|
||||
is_dark_mode: bool,
|
||||
show_resident_landing: bool,
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
type Message = Msg;
|
||||
type Properties = ();
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
wasm_logger::init(wasm_logger::Config::default());
|
||||
log::info!("Starting Zanzibar Digital Freezone WASM app");
|
||||
|
||||
// Determine initial view based on URL, default to Home
|
||||
let current_path = HistoryManager::get_current_path();
|
||||
let current_view = AppView::from_path(¤t_path);
|
||||
|
||||
// Load context from localStorage, default to Business
|
||||
let current_context = if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
.flatten()
|
||||
{
|
||||
match storage.get_item("view_context").ok().flatten().as_deref() {
|
||||
Some("person") => ViewContext::Person,
|
||||
_ => ViewContext::Business,
|
||||
}
|
||||
} else {
|
||||
ViewContext::Business
|
||||
};
|
||||
|
||||
// Load theme preference from localStorage, default to light mode
|
||||
let is_dark_mode = if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
.flatten()
|
||||
{
|
||||
storage.get_item("theme").ok().flatten().as_deref() == Some("dark")
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Check if we're coming from a payment success URL
|
||||
let mut toasts = Vec::new();
|
||||
let mut next_toast_id = 1;
|
||||
|
||||
if current_path.starts_with("/company/payment-success") {
|
||||
// Show payment success toast
|
||||
let toast = create_success_toast(
|
||||
next_toast_id,
|
||||
"Payment Successful!",
|
||||
"Your company registration payment has been processed successfully. Your company is now pending approval."
|
||||
);
|
||||
toasts.push(toast);
|
||||
next_toast_id += 1;
|
||||
|
||||
// Update URL to remove payment success parameters
|
||||
let _ = HistoryManager::replace_url("/entities");
|
||||
}
|
||||
|
||||
// Set up popstate event listener for browser back/forward navigation
|
||||
let link = _ctx.link().clone();
|
||||
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
|
||||
link.send_message(Msg::PopStateChanged);
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref());
|
||||
}
|
||||
closure.forget(); // Keep the closure alive
|
||||
|
||||
Self {
|
||||
current_view,
|
||||
current_context,
|
||||
sidebar_visible: false,
|
||||
toasts,
|
||||
next_toast_id,
|
||||
is_logged_in: false,
|
||||
user_name: None,
|
||||
is_dark_mode,
|
||||
show_resident_landing: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Msg::SwitchView(view) => {
|
||||
self.current_view = view;
|
||||
// Update URL
|
||||
let path = self.current_view.to_path();
|
||||
if let Err(e) = HistoryManager::push_url(&path) {
|
||||
log::error!("Failed to update URL: {:?}", e);
|
||||
}
|
||||
self.sidebar_visible = false; // Close sidebar on mobile after navigation
|
||||
true
|
||||
}
|
||||
Msg::SwitchContext(context) => {
|
||||
self.current_context = context;
|
||||
// Store context in localStorage for persistence
|
||||
if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
.flatten()
|
||||
{
|
||||
let context_str = match self.current_context {
|
||||
ViewContext::Business => "business",
|
||||
ViewContext::Person => "person",
|
||||
};
|
||||
let _ = storage.set_item("view_context", context_str);
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::ToggleSidebar => {
|
||||
self.sidebar_visible = !self.sidebar_visible;
|
||||
true
|
||||
}
|
||||
Msg::PopStateChanged => {
|
||||
// Handle browser back/forward navigation
|
||||
let current_path = HistoryManager::get_current_path();
|
||||
let new_view = AppView::from_path(¤t_path);
|
||||
|
||||
if self.current_view != new_view {
|
||||
self.current_view = new_view;
|
||||
log::info!("PopState: Updated to view {:?}", self.current_view);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Msg::ShowToast(toast) => {
|
||||
self.toasts.push(toast);
|
||||
true
|
||||
}
|
||||
Msg::DismissToast(toast_id) => {
|
||||
self.toasts.retain(|t| t.id != toast_id);
|
||||
true
|
||||
}
|
||||
Msg::Login => {
|
||||
// For dev purposes, automatically log in
|
||||
self.is_logged_in = true;
|
||||
self.user_name = Some("John Doe".to_string());
|
||||
true
|
||||
}
|
||||
Msg::Logout => {
|
||||
self.is_logged_in = false;
|
||||
self.user_name = None;
|
||||
true
|
||||
}
|
||||
Msg::ToggleTheme => {
|
||||
self.is_dark_mode = !self.is_dark_mode;
|
||||
// Store theme preference in localStorage
|
||||
if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
.flatten()
|
||||
{
|
||||
let theme_str = if self.is_dark_mode { "dark" } else { "light" };
|
||||
let _ = storage.set_item("theme", theme_str);
|
||||
}
|
||||
|
||||
// Apply theme to document body immediately
|
||||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(body) = document.body() {
|
||||
let theme_attr = if self.is_dark_mode { "dark" } else { "light" };
|
||||
let _ = body.set_attribute("data-bs-theme", theme_attr);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::ShowResidentLanding => {
|
||||
self.show_resident_landing = true;
|
||||
true
|
||||
}
|
||||
Msg::HideResidentLanding => {
|
||||
self.show_resident_landing = false;
|
||||
true
|
||||
}
|
||||
Msg::ResidentSignIn(email, password) => {
|
||||
// Handle resident sign in - for now just log them in
|
||||
log::info!("Resident sign in attempt: {}", email);
|
||||
self.is_logged_in = true;
|
||||
self.user_name = Some(email);
|
||||
self.show_resident_landing = false;
|
||||
true
|
||||
}
|
||||
Msg::ResidentRegistrationComplete => {
|
||||
// Handle successful resident registration
|
||||
self.show_resident_landing = false;
|
||||
self.is_logged_in = true;
|
||||
self.user_name = Some("New Resident".to_string());
|
||||
// Navigate to home or success page
|
||||
self.current_view = AppView::Home;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
// Apply theme to document body
|
||||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(body) = document.body() {
|
||||
let theme_attr = if self.is_dark_mode { "dark" } else { "light" };
|
||||
let _ = body.set_attribute("data-bs-theme", theme_attr);
|
||||
}
|
||||
}
|
||||
|
||||
// Show resident landing overlay if:
|
||||
// 1. User is not logged in AND visiting resident registration
|
||||
// 2. Or explicitly requested to show the overlay
|
||||
let should_show_overlay = self.show_resident_landing ||
|
||||
(!self.is_logged_in && matches!(self.current_view, AppView::ResidentRegister));
|
||||
|
||||
if should_show_overlay {
|
||||
return html! {
|
||||
<ResidentLandingOverlay
|
||||
on_registration_complete={link.callback(|_| Msg::ResidentRegistrationComplete)}
|
||||
on_sign_in={link.callback(|(email, password)| Msg::ResidentSignIn(email, password))}
|
||||
on_close={Some(link.callback(|_| Msg::HideResidentLanding))}
|
||||
/>
|
||||
};
|
||||
}
|
||||
|
||||
// Determine theme classes
|
||||
let theme_class = if self.is_dark_mode { "bg-dark text-light" } else { "bg-light text-dark" };
|
||||
let theme_attr = if self.is_dark_mode { "dark" } else { "light" };
|
||||
|
||||
// Main application layout - show full layout for logged in users
|
||||
html! {
|
||||
<div class={format!("d-flex flex-column min-vh-100 {}", theme_class)} data-bs-theme={theme_attr}>
|
||||
<Header
|
||||
user_name={if self.is_logged_in { self.user_name.clone() } else { None }}
|
||||
entity_name={if self.is_logged_in { Some("TechCorp Solutions".to_string()) } else { None }}
|
||||
current_context={self.current_context.clone()}
|
||||
is_dark_mode={self.is_dark_mode}
|
||||
on_sidebar_toggle={link.callback(|_: MouseEvent| Msg::ToggleSidebar)}
|
||||
on_login={link.callback(|_: MouseEvent| Msg::Login)}
|
||||
on_logout={link.callback(|_: MouseEvent| Msg::Logout)}
|
||||
on_context_change={link.callback(Msg::SwitchContext)}
|
||||
on_navigate={link.callback(Msg::SwitchView)}
|
||||
on_theme_toggle={link.callback(|_: MouseEvent| Msg::ToggleTheme)}
|
||||
/>
|
||||
|
||||
<div class="d-flex flex-grow-1">
|
||||
<Sidebar
|
||||
current_view={self.current_view.clone()}
|
||||
current_context={self.current_context.clone()}
|
||||
is_visible={self.sidebar_visible}
|
||||
on_view_change={link.callback(Msg::SwitchView)}
|
||||
/>
|
||||
|
||||
<div class="main-content flex-grow-1">
|
||||
<main class="py-3 w-100 d-block">
|
||||
<div class="container-fluid">
|
||||
{self.render_current_view(ctx)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
// Toast notifications
|
||||
<ToastContainer
|
||||
toasts={self.toasts.clone()}
|
||||
on_dismiss={link.callback(Msg::DismissToast)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn render_current_view(&self, ctx: &Context<Self>) -> Html {
|
||||
match &self.current_view {
|
||||
AppView::Login => {
|
||||
// Login is not used in this app, redirect to home
|
||||
html! { <HomeView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Home => {
|
||||
html! { <HomeView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Administration => {
|
||||
html! { <AdministrationView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::PersonAdministration => {
|
||||
html! { <PersonAdministrationView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Business => {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<BusinessView
|
||||
context={self.current_context.clone()}
|
||||
company_id={Some(1)} // Show the first company by default
|
||||
on_navigate={Some(link.callback(Msg::SwitchView))}
|
||||
/>
|
||||
}
|
||||
}
|
||||
AppView::Accounting => {
|
||||
html! { <AccountingView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Contracts => {
|
||||
html! { <ContractsView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Governance => {
|
||||
html! { <GovernanceView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Treasury => {
|
||||
html! { <TreasuryView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Residence => {
|
||||
html! { <ResidenceView context={self.current_context.clone()} /> }
|
||||
}
|
||||
AppView::Entities => {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<EntitiesView
|
||||
on_navigate={Some(link.callback(Msg::SwitchView))}
|
||||
/>
|
||||
}
|
||||
}
|
||||
AppView::EntitiesRegister => {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<EntitiesView
|
||||
on_navigate={Some(link.callback(Msg::SwitchView))}
|
||||
show_registration={true}
|
||||
/>
|
||||
}
|
||||
}
|
||||
AppView::EntitiesRegisterSuccess(company_id) => {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<EntitiesView
|
||||
on_navigate={Some(link.callback(Msg::SwitchView))}
|
||||
show_registration={true}
|
||||
registration_success={Some(*company_id)}
|
||||
/>
|
||||
}
|
||||
}
|
||||
AppView::EntitiesRegisterFailure => {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<EntitiesView
|
||||
on_navigate={Some(link.callback(Msg::SwitchView))}
|
||||
show_registration={true}
|
||||
registration_failure={true}
|
||||
/>
|
||||
}
|
||||
}
|
||||
AppView::CompanyView(company_id) => {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<BusinessView
|
||||
context={self.current_context.clone()}
|
||||
company_id={Some(*company_id)}
|
||||
on_navigate={Some(link.callback(Msg::SwitchView))}
|
||||
/>
|
||||
}
|
||||
}
|
||||
AppView::ResidentRegister => {
|
||||
let link = ctx.link();
|
||||
html! {
|
||||
<ResidentRegistrationView
|
||||
on_registration_complete={link.callback(|_resident: DigitalResident| {
|
||||
Msg::SwitchView(AppView::ResidentRegisterSuccess)
|
||||
})}
|
||||
on_navigate={link.callback(Msg::SwitchView)}
|
||||
/>
|
||||
}
|
||||
}
|
||||
AppView::ResidentRegisterSuccess => {
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-check-circle text-success" style="font-size: 4rem;"></i>
|
||||
<h2 class="mt-3 mb-3">{"Registration Successful!"}</h2>
|
||||
<p class="lead text-muted mb-4">
|
||||
{"Your digital resident registration has been completed successfully. Welcome to the community!"}
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={ctx.link().callback(|_| Msg::SwitchView(AppView::Home))}
|
||||
>
|
||||
{"Continue to Dashboard"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AppView::ResidentRegisterFailure => {
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-x-circle text-danger" style="font-size: 4rem;"></i>
|
||||
<h2 class="mt-3 mb-3">{"Registration Failed"}</h2>
|
||||
<p class="lead text-muted mb-4">
|
||||
{"There was an issue with your digital resident registration. Please try again."}
|
||||
</p>
|
||||
<div class="d-flex gap-2 justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={ctx.link().callback(|_| Msg::SwitchView(AppView::ResidentRegister))}
|
||||
>
|
||||
{"Try Again"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={ctx.link().callback(|_| Msg::SwitchView(AppView::Home))}
|
||||
>
|
||||
{"Back to Home"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AppView::ResidentLanding => {
|
||||
// This should never be reached since ResidentLanding is handled by the overlay
|
||||
// But we need this match arm to satisfy the compiler
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class="text-center py-5">
|
||||
<p>{"Loading..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
526
platform/src/bin/server.rs
Normal file
526
platform/src/bin/server.rs
Normal file
@@ -0,0 +1,526 @@
|
||||
use axum::{
|
||||
extract::{Json, Query},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json as ResponseJson,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use dotenv::dotenv;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, env};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
services::ServeDir,
|
||||
};
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreatePaymentIntentRequest {
|
||||
company_name: String,
|
||||
company_type: String,
|
||||
company_email: Option<String>,
|
||||
company_phone: Option<String>,
|
||||
company_website: Option<String>,
|
||||
company_address: Option<String>,
|
||||
company_industry: Option<String>,
|
||||
company_purpose: Option<String>,
|
||||
fiscal_year_end: Option<String>,
|
||||
shareholders: Option<String>,
|
||||
payment_plan: String,
|
||||
agreements: Vec<String>,
|
||||
final_agreement: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateResidentPaymentIntentRequest {
|
||||
resident_name: String,
|
||||
email: String,
|
||||
phone: Option<String>,
|
||||
date_of_birth: Option<String>,
|
||||
nationality: Option<String>,
|
||||
passport_number: Option<String>,
|
||||
address: Option<String>,
|
||||
payment_plan: String,
|
||||
amount: f64,
|
||||
#[serde(rename = "type")]
|
||||
request_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreatePaymentIntentResponse {
|
||||
client_secret: String,
|
||||
payment_intent_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WebhookQuery {
|
||||
#[serde(rename = "payment_intent")]
|
||||
payment_intent_id: Option<String>,
|
||||
#[serde(rename = "payment_intent_client_secret")]
|
||||
client_secret: Option<String>,
|
||||
}
|
||||
|
||||
// Calculate pricing based on company type and payment plan
|
||||
fn calculate_amount(company_type: &str, payment_plan: &str) -> Result<i64, String> {
|
||||
let base_amounts = match company_type {
|
||||
"Single FZC" => (20, 20), // (setup, monthly)
|
||||
"Startup FZC" => (50, 50),
|
||||
"Growth FZC" => (1000, 100),
|
||||
"Global FZC" => (2000, 200),
|
||||
"Cooperative FZC" => (2000, 200),
|
||||
_ => return Err("Invalid company type".to_string()),
|
||||
};
|
||||
|
||||
let (setup_fee, monthly_fee) = base_amounts;
|
||||
let twin_fee = 2; // ZDFZ Twin fee
|
||||
let total_monthly = monthly_fee + twin_fee;
|
||||
|
||||
let amount_cents = match payment_plan {
|
||||
"monthly" => (setup_fee + total_monthly) * 100,
|
||||
"yearly" => (setup_fee + (total_monthly * 12 * 80 / 100)) * 100, // 20% discount
|
||||
"two_year" => (setup_fee + (total_monthly * 24 * 60 / 100)) * 100, // 40% discount
|
||||
_ => return Err("Invalid payment plan".to_string()),
|
||||
};
|
||||
|
||||
Ok(amount_cents as i64)
|
||||
}
|
||||
|
||||
// Create payment intent with Stripe
|
||||
async fn create_payment_intent(
|
||||
Json(payload): Json<CreatePaymentIntentRequest>,
|
||||
) -> Result<ResponseJson<CreatePaymentIntentResponse>, (StatusCode, ResponseJson<ErrorResponse>)> {
|
||||
info!("Creating payment intent for company: {}", payload.company_name);
|
||||
|
||||
// Validate required fields
|
||||
if !payload.final_agreement {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Final agreement must be accepted".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Calculate amount based on company type and payment plan
|
||||
let amount = match calculate_amount(&payload.company_type, &payload.payment_plan) {
|
||||
Ok(amount) => amount,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: e,
|
||||
details: None,
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Get Stripe secret key from environment
|
||||
let stripe_secret_key = env::var("STRIPE_SECRET_KEY").map_err(|_| {
|
||||
error!("STRIPE_SECRET_KEY not found in environment");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Server configuration error".to_string(),
|
||||
details: Some("Stripe not configured".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create Stripe client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Prepare payment intent data
|
||||
let mut form_data = HashMap::new();
|
||||
form_data.insert("amount", amount.to_string());
|
||||
form_data.insert("currency", "usd".to_string());
|
||||
form_data.insert("automatic_payment_methods[enabled]", "true".to_string());
|
||||
|
||||
// Add metadata
|
||||
form_data.insert("metadata[company_name]", payload.company_name.clone());
|
||||
form_data.insert("metadata[company_type]", payload.company_type.clone());
|
||||
form_data.insert("metadata[payment_plan]", payload.payment_plan.clone());
|
||||
if let Some(email) = &payload.company_email {
|
||||
form_data.insert("metadata[company_email]", email.clone());
|
||||
}
|
||||
|
||||
// Add description
|
||||
let description = format!(
|
||||
"Company Registration: {} ({})",
|
||||
payload.company_name, payload.company_type
|
||||
);
|
||||
form_data.insert("description", description);
|
||||
|
||||
// Call Stripe API
|
||||
let response = client
|
||||
.post("https://api.stripe.com/v1/payment_intents")
|
||||
.header("Authorization", format!("Bearer {}", stripe_secret_key))
|
||||
.form(&form_data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to call Stripe API: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Failed to create payment intent".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
error!("Stripe API error: {}", error_text);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Stripe payment intent creation failed".to_string(),
|
||||
details: Some(error_text),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let stripe_response: serde_json::Value = response.json().await.map_err(|e| {
|
||||
error!("Failed to parse Stripe response: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Invalid response from payment processor".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client_secret = stripe_response["client_secret"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
error!("No client_secret in Stripe response");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Invalid payment intent response".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let payment_intent_id = stripe_response["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
error!("No id in Stripe response");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Invalid payment intent response".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Payment intent created successfully: {}", payment_intent_id);
|
||||
|
||||
Ok(ResponseJson(CreatePaymentIntentResponse {
|
||||
client_secret: client_secret.to_string(),
|
||||
payment_intent_id: payment_intent_id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// Create payment intent for resident registration
|
||||
async fn create_resident_payment_intent(
|
||||
Json(payload): Json<CreateResidentPaymentIntentRequest>,
|
||||
) -> Result<ResponseJson<CreatePaymentIntentResponse>, (StatusCode, ResponseJson<ErrorResponse>)> {
|
||||
info!("Creating payment intent for resident: {}", payload.resident_name);
|
||||
|
||||
// Convert amount from dollars to cents
|
||||
let amount_cents = (payload.amount * 100.0) as i64;
|
||||
|
||||
// Get Stripe secret key from environment
|
||||
let stripe_secret_key = env::var("STRIPE_SECRET_KEY").map_err(|_| {
|
||||
error!("STRIPE_SECRET_KEY not found in environment");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Server configuration error".to_string(),
|
||||
details: Some("Stripe not configured".to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create Stripe client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Prepare payment intent data
|
||||
let mut form_data = HashMap::new();
|
||||
form_data.insert("amount", amount_cents.to_string());
|
||||
form_data.insert("currency", "usd".to_string());
|
||||
form_data.insert("automatic_payment_methods[enabled]", "true".to_string());
|
||||
|
||||
// Add metadata
|
||||
form_data.insert("metadata[resident_name]", payload.resident_name.clone());
|
||||
form_data.insert("metadata[email]", payload.email.clone());
|
||||
form_data.insert("metadata[payment_plan]", payload.payment_plan.clone());
|
||||
form_data.insert("metadata[type]", payload.request_type.clone());
|
||||
if let Some(phone) = &payload.phone {
|
||||
form_data.insert("metadata[phone]", phone.clone());
|
||||
}
|
||||
if let Some(nationality) = &payload.nationality {
|
||||
form_data.insert("metadata[nationality]", nationality.clone());
|
||||
}
|
||||
|
||||
// Add description
|
||||
let description = format!(
|
||||
"Resident Registration: {} ({})",
|
||||
payload.resident_name, payload.payment_plan
|
||||
);
|
||||
form_data.insert("description", description);
|
||||
|
||||
// Call Stripe API
|
||||
let response = client
|
||||
.post("https://api.stripe.com/v1/payment_intents")
|
||||
.header("Authorization", format!("Bearer {}", stripe_secret_key))
|
||||
.form(&form_data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to call Stripe API: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Failed to create payment intent".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
error!("Stripe API error: {}", error_text);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Stripe payment intent creation failed".to_string(),
|
||||
details: Some(error_text),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let stripe_response: serde_json::Value = response.json().await.map_err(|e| {
|
||||
error!("Failed to parse Stripe response: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Invalid response from payment processor".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client_secret = stripe_response["client_secret"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
error!("No client_secret in Stripe response");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Invalid payment intent response".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let payment_intent_id = stripe_response["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
error!("No id in Stripe response");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Invalid payment intent response".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("Resident payment intent created successfully: {}", payment_intent_id);
|
||||
|
||||
Ok(ResponseJson(CreatePaymentIntentResponse {
|
||||
client_secret: client_secret.to_string(),
|
||||
payment_intent_id: payment_intent_id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle Stripe webhooks
|
||||
async fn handle_webhook(
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<StatusCode, (StatusCode, ResponseJson<ErrorResponse>)> {
|
||||
let stripe_signature = headers
|
||||
.get("stripe-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
warn!("Missing Stripe signature header");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Missing signature".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let _webhook_secret = env::var("STRIPE_WEBHOOK_SECRET").map_err(|_| {
|
||||
error!("STRIPE_WEBHOOK_SECRET not found in environment");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Webhook not configured".to_string(),
|
||||
details: None,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// In a real implementation, you would verify the webhook signature here
|
||||
// For now, we'll just log the event
|
||||
info!("Received webhook with signature: {}", stripe_signature);
|
||||
info!("Webhook body: {}", body);
|
||||
|
||||
// Parse the webhook event
|
||||
let event: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
error!("Failed to parse webhook body: {}", e);
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
ResponseJson(ErrorResponse {
|
||||
error: "Invalid webhook body".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let event_type = event["type"].as_str().unwrap_or("unknown");
|
||||
info!("Processing webhook event: {}", event_type);
|
||||
|
||||
match event_type {
|
||||
"payment_intent.succeeded" => {
|
||||
let payment_intent = &event["data"]["object"];
|
||||
let payment_intent_id = payment_intent["id"].as_str().unwrap_or("unknown");
|
||||
info!("Payment succeeded: {}", payment_intent_id);
|
||||
|
||||
// Here you would typically:
|
||||
// 1. Update your database to mark the company as registered
|
||||
// 2. Send confirmation emails
|
||||
// 3. Trigger any post-payment workflows
|
||||
}
|
||||
"payment_intent.payment_failed" => {
|
||||
let payment_intent = &event["data"]["object"];
|
||||
let payment_intent_id = payment_intent["id"].as_str().unwrap_or("unknown");
|
||||
warn!("Payment failed: {}", payment_intent_id);
|
||||
|
||||
// Handle failed payment
|
||||
}
|
||||
_ => {
|
||||
info!("Unhandled webhook event type: {}", event_type);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// Payment success redirect
|
||||
async fn payment_success(Query(params): Query<WebhookQuery>) -> axum::response::Redirect {
|
||||
info!("Payment success page accessed");
|
||||
|
||||
if let Some(ref payment_intent_id) = params.payment_intent_id {
|
||||
info!("Payment intent ID: {}", payment_intent_id);
|
||||
|
||||
// In a real implementation, you would:
|
||||
// 1. Verify the payment intent with Stripe
|
||||
// 2. Get the company ID from your database
|
||||
// 3. Redirect to the success page with the actual company ID
|
||||
|
||||
// For now, we'll use a mock company ID (in real app, get from database)
|
||||
let company_id = 1; // This should be retrieved from your database based on payment_intent_id
|
||||
|
||||
axum::response::Redirect::to(&format!("/entities/register/success/{}", company_id))
|
||||
} else {
|
||||
// If no payment intent ID, redirect to entities page
|
||||
axum::response::Redirect::to("/entities")
|
||||
}
|
||||
}
|
||||
|
||||
// Payment failure redirect
|
||||
async fn payment_failure() -> axum::response::Redirect {
|
||||
info!("Payment failure page accessed");
|
||||
axum::response::Redirect::to("/entities/register/failure")
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
async fn health_check() -> ResponseJson<serde_json::Value> {
|
||||
ResponseJson(serde_json::json!({
|
||||
"status": "healthy",
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"service": "freezone-platform-server"
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load environment variables
|
||||
dotenv().ok();
|
||||
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Check required environment variables
|
||||
let required_vars = ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY"];
|
||||
for var in &required_vars {
|
||||
if env::var(var).is_err() {
|
||||
warn!("Environment variable {} not set", var);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the application router
|
||||
let app = Router::new()
|
||||
// API routes
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/company/create-payment-intent", post(create_payment_intent))
|
||||
.route("/resident/create-payment-intent", post(create_resident_payment_intent))
|
||||
.route("/company/payment-success", get(payment_success))
|
||||
.route("/company/payment-failure", get(payment_failure))
|
||||
.route("/webhooks/stripe", post(handle_webhook))
|
||||
// Serve static files (WASM, HTML, CSS, JS)
|
||||
.nest_service("/", ServeDir::new("."))
|
||||
// Add middleware
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
)
|
||||
);
|
||||
|
||||
// Get server configuration from environment
|
||||
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string());
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
info!("Starting server on {}", addr);
|
||||
info!("Health check: http://{}/api/health", addr);
|
||||
info!("Payment endpoint: http://{}/company/create-payment-intent", addr);
|
||||
|
||||
// Start the server
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
801
platform/src/components/accounting/expenses_tab.rs
Normal file
801
platform/src/components/accounting/expenses_tab.rs
Normal file
@@ -0,0 +1,801 @@
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::components::accounting::models::*;
|
||||
use js_sys;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ExpensesTabProps {
|
||||
pub state: UseStateHandle<AccountingState>,
|
||||
}
|
||||
|
||||
#[function_component(ExpensesTab)]
|
||||
pub fn expenses_tab(props: &ExpensesTabProps) -> Html {
|
||||
let state = &props.state;
|
||||
|
||||
html! {
|
||||
<div class="animate-fade-in-up">
|
||||
// Expense Form Modal
|
||||
{if state.show_expense_form {
|
||||
html! {
|
||||
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Add New Expense"}</h5>
|
||||
<button type="button" class="btn-close" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_expense_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Receipt Number"}</label>
|
||||
<input type="text" class="form-control" value={state.expense_form.receipt_number.clone()} readonly=true />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Date"}</label>
|
||||
<input type="date" class="form-control" value={state.expense_form.date.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_form.date = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Vendor Name"}</label>
|
||||
<input type="text" class="form-control" value={state.expense_form.vendor_name.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_form.vendor_name = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Vendor Email"}</label>
|
||||
<input type="email" class="form-control" value={state.expense_form.vendor_email.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_form.vendor_email = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Description"}</label>
|
||||
<textarea class="form-control" rows="3" value={state.expense_form.description.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_form.description = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
}></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Amount"}</label>
|
||||
<input type="number" step="0.01" class="form-control" value={state.expense_form.amount.to_string()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_form.amount = input.value().parse().unwrap_or(0.0);
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Tax Amount"}</label>
|
||||
<input type="number" step="0.01" class="form-control" value={state.expense_form.tax_amount.to_string()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_form.tax_amount = input.value().parse().unwrap_or(0.0);
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Tax Deductible"}</label>
|
||||
<select class="form-select" onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_form.is_deductible = select.value() == "true";
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<option value="true" selected={state.expense_form.is_deductible}>{"Yes"}</option>
|
||||
<option value="false" selected={!state.expense_form.is_deductible}>{"No"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Project Code (Optional)"}</label>
|
||||
<input type="text" class="form-control" value={state.expense_form.project_code.clone().unwrap_or_default()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
let value = input.value();
|
||||
new_state.expense_form.project_code = if value.is_empty() { None } else { Some(value) };
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_expense_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Cancel"}</button>
|
||||
<button type="button" class="btn btn-danger" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
// Calculate total
|
||||
new_state.expense_form.total_amount = new_state.expense_form.amount + new_state.expense_form.tax_amount;
|
||||
|
||||
// Add to entries
|
||||
new_state.expense_entries.push(new_state.expense_form.clone());
|
||||
|
||||
// Reset form
|
||||
new_state.show_expense_form = false;
|
||||
new_state.expense_form = AccountingState::default().expense_form;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Add Expense"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
// Expense Detail Modal
|
||||
{if state.show_expense_detail {
|
||||
if let Some(expense_id) = &state.selected_expense_id {
|
||||
if let Some(expense) = state.expense_entries.iter().find(|e| &e.id == expense_id) {
|
||||
let expense_transactions: Vec<&PaymentTransaction> = state.payment_transactions.iter()
|
||||
.filter(|t| t.expense_id.as_ref() == Some(expense_id))
|
||||
.collect();
|
||||
let total_paid: f64 = expense_transactions.iter().map(|t| t.amount).sum();
|
||||
let remaining_balance = expense.total_amount - total_paid;
|
||||
|
||||
html! {
|
||||
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{format!("Expense Details - {}", expense.receipt_number)}</h5>
|
||||
<button type="button" class="btn-close" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_expense_detail = false;
|
||||
new_state.selected_expense_id = None;
|
||||
state.set(new_state);
|
||||
})
|
||||
}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-4">
|
||||
// Expense Information
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">{"Expense Information"}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><strong>{"Receipt #:"}</strong></div>
|
||||
<div class="col-6">{&expense.receipt_number}</div>
|
||||
<div class="col-6"><strong>{"Date:"}</strong></div>
|
||||
<div class="col-6">{&expense.date}</div>
|
||||
<div class="col-6"><strong>{"Category:"}</strong></div>
|
||||
<div class="col-6">{expense.category.to_string()}</div>
|
||||
<div class="col-6"><strong>{"Status:"}</strong></div>
|
||||
<div class="col-6">
|
||||
<span class={format!("badge bg-{}", expense.payment_status.get_color())}>
|
||||
{expense.payment_status.to_string()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-6"><strong>{"Total Amount:"}</strong></div>
|
||||
<div class="col-6 fw-bold text-danger">{format!("${:.2}", expense.total_amount)}</div>
|
||||
<div class="col-6"><strong>{"Amount Paid:"}</strong></div>
|
||||
<div class="col-6 fw-bold text-primary">{format!("${:.2}", total_paid)}</div>
|
||||
<div class="col-6"><strong>{"Remaining:"}</strong></div>
|
||||
<div class="col-6 fw-bold text-warning">{format!("${:.2}", remaining_balance)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Vendor Information
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">{"Vendor Information"}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><strong>{"Name:"}</strong></div>
|
||||
<div class="col-8">{&expense.vendor_name}</div>
|
||||
<div class="col-4"><strong>{"Email:"}</strong></div>
|
||||
<div class="col-8">{&expense.vendor_email}</div>
|
||||
<div class="col-4"><strong>{"Address:"}</strong></div>
|
||||
<div class="col-8">{&expense.vendor_address}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Transactions
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">{"Payment Transactions"}</h6>
|
||||
<button class="btn btn-sm btn-primary" onclick={
|
||||
let state = state.clone();
|
||||
let expense_id = expense.id.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = true;
|
||||
new_state.transaction_form.expense_id = Some(expense_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-plus-circle me-1"></i>{"Record Payment"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{if expense_transactions.is_empty() {
|
||||
html! {
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-credit-card fs-1 mb-2 d-block"></i>
|
||||
<p class="mb-0">{"No payments recorded yet"}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="border-0 py-3">{"Date"}</th>
|
||||
<th class="border-0 py-3">{"Amount"}</th>
|
||||
<th class="border-0 py-3">{"Method"}</th>
|
||||
<th class="border-0 py-3">{"Reference"}</th>
|
||||
<th class="border-0 py-3">{"Status"}</th>
|
||||
<th class="border-0 py-3">{"Notes"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for expense_transactions.iter().map(|transaction| {
|
||||
html! {
|
||||
<tr>
|
||||
<td class="py-3">{&transaction.date}</td>
|
||||
<td class="py-3 fw-bold text-danger">{format!("${:.2}", transaction.amount)}</td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class={format!("bi bi-{} text-{} me-2", transaction.payment_method.get_icon(), transaction.payment_method.get_color())}></i>
|
||||
{transaction.payment_method.to_string()}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{if let Some(hash) = &transaction.transaction_hash {
|
||||
html! { <code class="small">{&hash[..12]}{"..."}</code> }
|
||||
} else if let Some(ref_num) = &transaction.reference_number {
|
||||
html! { <span>{ref_num}</span> }
|
||||
} else {
|
||||
html! { <span class="text-muted">{"-"}</span> }
|
||||
}}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class={format!("badge bg-{}", transaction.status.get_color())}>
|
||||
{transaction.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">{&transaction.notes}</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_expense_detail = false;
|
||||
new_state.selected_expense_id = None;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Close"}</button>
|
||||
<button type="button" class="btn btn-primary" onclick={
|
||||
let state = state.clone();
|
||||
let expense_id = expense.id.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = true;
|
||||
new_state.transaction_form.expense_id = Some(expense_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-credit-card me-2"></i>{"Record Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
// Transaction Form Modal (for expense payments)
|
||||
{if state.show_transaction_form && state.transaction_form.expense_id.is_some() {
|
||||
html! {
|
||||
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Record Expense Payment"}</h5>
|
||||
<button type="button" class="btn-close" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Expense Receipt Number"}</label>
|
||||
<input type="text" class="form-control" value={state.transaction_form.expense_id.clone().unwrap_or_default()} readonly=true />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Payment Amount"}</label>
|
||||
<input type="number" step="0.01" class="form-control" value={state.transaction_form.amount.to_string()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.amount = input.value().parse().unwrap_or(0.0);
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Payment Method"}</label>
|
||||
<select class="form-select" onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.payment_method = match select.value().as_str() {
|
||||
"BankTransfer" => PaymentMethod::BankTransfer,
|
||||
"CreditCard" => PaymentMethod::CreditCard,
|
||||
"CryptoBitcoin" => PaymentMethod::CryptoBitcoin,
|
||||
"CryptoEthereum" => PaymentMethod::CryptoEthereum,
|
||||
"CryptoUSDC" => PaymentMethod::CryptoUSDC,
|
||||
"Cash" => PaymentMethod::Cash,
|
||||
"Check" => PaymentMethod::Check,
|
||||
_ => PaymentMethod::BankTransfer,
|
||||
};
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<option value="BankTransfer" selected={matches!(state.transaction_form.payment_method, PaymentMethod::BankTransfer)}>{"Bank Transfer"}</option>
|
||||
<option value="CreditCard" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CreditCard)}>{"Credit Card"}</option>
|
||||
<option value="CryptoBitcoin" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CryptoBitcoin)}>{"Bitcoin"}</option>
|
||||
<option value="CryptoEthereum" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CryptoEthereum)}>{"Ethereum"}</option>
|
||||
<option value="CryptoUSDC" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CryptoUSDC)}>{"USDC"}</option>
|
||||
<option value="Cash" selected={matches!(state.transaction_form.payment_method, PaymentMethod::Cash)}>{"Cash"}</option>
|
||||
<option value="Check" selected={matches!(state.transaction_form.payment_method, PaymentMethod::Check)}>{"Check"}</option>
|
||||
</select>
|
||||
</div>
|
||||
{if matches!(state.transaction_form.payment_method, PaymentMethod::CryptoBitcoin | PaymentMethod::CryptoEthereum | PaymentMethod::CryptoUSDC | PaymentMethod::CryptoOther) {
|
||||
html! {
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Transaction Hash"}</label>
|
||||
<input type="text" class="form-control" placeholder="0x..." value={state.transaction_form.transaction_hash.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.transaction_hash = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Reference Number"}</label>
|
||||
<input type="text" class="form-control" placeholder="REF-2024-001" value={state.transaction_form.reference_number.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.reference_number = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Notes"}</label>
|
||||
<textarea class="form-control" rows="3" value={state.transaction_form.notes.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.notes = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
}></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Attach Files"}</label>
|
||||
<input type="file" class="form-control" multiple=true accept=".pdf,.jpg,.jpeg,.png" />
|
||||
<small class="text-muted">{"Upload receipts, confirmations, or other supporting documents"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Cancel"}</button>
|
||||
<button type="button" class="btn btn-success" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
|
||||
// Create new transaction
|
||||
let transaction_count = new_state.payment_transactions.len() + 1;
|
||||
let new_transaction = PaymentTransaction {
|
||||
id: format!("TXN-2024-{:03}", transaction_count),
|
||||
invoice_id: None,
|
||||
expense_id: new_state.transaction_form.expense_id.clone(),
|
||||
date: js_sys::Date::new_0().to_iso_string().as_string().unwrap()[..10].to_string(),
|
||||
amount: new_state.transaction_form.amount,
|
||||
payment_method: new_state.transaction_form.payment_method.clone(),
|
||||
transaction_hash: if new_state.transaction_form.transaction_hash.is_empty() { None } else { Some(new_state.transaction_form.transaction_hash.clone()) },
|
||||
reference_number: if new_state.transaction_form.reference_number.is_empty() { None } else { Some(new_state.transaction_form.reference_number.clone()) },
|
||||
notes: new_state.transaction_form.notes.clone(),
|
||||
attached_files: new_state.transaction_form.attached_files.clone(),
|
||||
status: TransactionStatus::Confirmed,
|
||||
};
|
||||
|
||||
new_state.payment_transactions.push(new_transaction);
|
||||
new_state.show_transaction_form = false;
|
||||
new_state.transaction_form = TransactionForm::default();
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Record Payment"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
// Expense Actions and Table
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-soft border-0">
|
||||
<div class="card-header bg-white border-bottom-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{"Expense Entries"}</h5>
|
||||
<small class="text-muted">{"Click on any row to view details"}</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick={
|
||||
Callback::from(move |_| {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Expense filter feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-funnel me-2"></i>{"Filter"}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick={
|
||||
let expense_entries = state.expense_entries.clone();
|
||||
Callback::from(move |_| {
|
||||
// Create CSV content
|
||||
let mut csv_content = "Receipt Number,Date,Vendor Name,Vendor Email,Description,Amount,Tax Amount,Total Amount,Category,Payment Method,Payment Status,Tax Deductible,Approval Status,Approved By,Notes,Project Code,Currency\n".to_string();
|
||||
|
||||
for entry in &expense_entries {
|
||||
csv_content.push_str(&format!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||
entry.receipt_number,
|
||||
entry.date,
|
||||
entry.vendor_name,
|
||||
entry.vendor_email,
|
||||
entry.description.replace(",", ";"),
|
||||
entry.amount,
|
||||
entry.tax_amount,
|
||||
entry.total_amount,
|
||||
entry.category.to_string(),
|
||||
entry.payment_method.to_string(),
|
||||
entry.payment_status.to_string(),
|
||||
entry.is_deductible,
|
||||
entry.approval_status.to_string(),
|
||||
entry.approved_by.as_ref().unwrap_or(&"".to_string()),
|
||||
entry.notes.replace(",", ";"),
|
||||
entry.project_code.as_ref().unwrap_or(&"".to_string()),
|
||||
entry.currency
|
||||
));
|
||||
}
|
||||
|
||||
// Create and download file
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let element = document.create_element("a").unwrap();
|
||||
element.set_attribute("href", &format!("data:text/csv;charset=utf-8,{}", js_sys::encode_uri_component(&csv_content))).unwrap();
|
||||
element.set_attribute("download", "expenses_export.csv").unwrap();
|
||||
element.set_attribute("style", "display: none").unwrap();
|
||||
document.body().unwrap().append_child(&element).unwrap();
|
||||
let html_element: web_sys::HtmlElement = element.clone().dyn_into().unwrap();
|
||||
html_element.click();
|
||||
document.body().unwrap().remove_child(&element).unwrap();
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-download me-2"></i>{"Export"}
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_expense_form = true;
|
||||
let expense_count = new_state.expense_entries.len() + 1;
|
||||
new_state.expense_form.receipt_number = format!("EXP-2024-{:03}", expense_count);
|
||||
new_state.expense_form.id = new_state.expense_form.receipt_number.clone();
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-plus-circle me-2"></i>{"Add Expense"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="border-0 py-3 px-4">{"Receipt #"}</th>
|
||||
<th class="border-0 py-3">{"Vendor"}</th>
|
||||
<th class="border-0 py-3">{"Description"}</th>
|
||||
<th class="border-0 py-3">{"Amount"}</th>
|
||||
<th class="border-0 py-3">{"Payment Method"}</th>
|
||||
<th class="border-0 py-3">{"Status"}</th>
|
||||
<th class="border-0 py-3">{"Approval"}</th>
|
||||
<th class="border-0 py-3">{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for state.expense_entries.iter().map(|entry| {
|
||||
html! {
|
||||
<tr class="border-bottom">
|
||||
<td class="py-3 px-4 cursor-pointer" style="cursor: pointer;" onclick={
|
||||
let state = state.clone();
|
||||
let expense_id = entry.id.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_expense_detail = true;
|
||||
new_state.selected_expense_id = Some(expense_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<div class="fw-bold text-primary">{&entry.receipt_number}</div>
|
||||
<small class="text-muted">{&entry.date}</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-semibold">{&entry.vendor_name}</div>
|
||||
<small class="text-muted">{&entry.vendor_email}</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-semibold">{&entry.description}</div>
|
||||
<small class="text-muted">
|
||||
<span class={format!("badge bg-{} bg-opacity-10 text-{} me-1", entry.category.get_color(), entry.category.get_color())}>
|
||||
{entry.category.to_string()}
|
||||
</span>
|
||||
{if entry.is_deductible { "• Tax Deductible" } else { "" }}
|
||||
{if let Some(project) = &entry.project_code {
|
||||
html! { <span class="ms-1">{format!("• {}", project)}</span> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-bold text-danger">{format!("${:.2}", entry.total_amount)}</div>
|
||||
<small class="text-muted">{format!("${:.2} + ${:.2} tax", entry.amount, entry.tax_amount)}</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class={format!("bi bi-{} text-{} me-2", entry.payment_method.get_icon(), entry.payment_method.get_color())}></i>
|
||||
<span class="small">{entry.payment_method.to_string()}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class={format!("badge bg-{} bg-opacity-10 text-{}", entry.payment_status.get_color(), entry.payment_status.get_color())}>
|
||||
{entry.payment_status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class={format!("badge bg-{} bg-opacity-10 text-{}", entry.approval_status.get_color(), entry.approval_status.get_color())}>
|
||||
{entry.approval_status.to_string()}
|
||||
</span>
|
||||
{
|
||||
if let Some(approver) = &entry.approved_by {
|
||||
html! { <small class="d-block text-muted">{format!("by {}", approver)}</small> }
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
let state = state.clone();
|
||||
let expense_id = entry.id.clone();
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_expense_detail = true;
|
||||
new_state.selected_expense_id = Some(expense_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}><i class="bi bi-eye me-2"></i>{"View Details"}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
let state = state.clone();
|
||||
let expense_id = entry.id.clone();
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = true;
|
||||
new_state.transaction_form.expense_id = Some(expense_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}><i class="bi bi-credit-card me-2"></i>{"Record Payment"}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Edit expense feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}><i class="bi bi-pencil me-2"></i>{"Edit"}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
let receipt_url = entry.receipt_url.clone();
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
if let Some(url) = &receipt_url {
|
||||
web_sys::window().unwrap().open_with_url(url).unwrap();
|
||||
} else {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("No receipt available for this expense")
|
||||
.unwrap();
|
||||
}
|
||||
})
|
||||
}><i class="bi bi-file-earmark me-2"></i>{"View Receipt"}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
let state = state.clone();
|
||||
let expense_id = entry.id.clone();
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
let mut new_state = (*state).clone();
|
||||
// Find and update the expense approval status
|
||||
if let Some(expense) = new_state.expense_entries.iter_mut().find(|e| e.id == expense_id) {
|
||||
expense.approval_status = ApprovalStatus::Approved;
|
||||
expense.approved_by = Some("Current User".to_string());
|
||||
}
|
||||
state.set(new_state);
|
||||
})
|
||||
}><i class="bi bi-check-circle me-2"></i>{"Approve"}</a></li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Duplicate expense feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}><i class="bi bi-files me-2"></i>{"Duplicate"}</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick={
|
||||
let state = state.clone();
|
||||
let expense_id = entry.id.clone();
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
if web_sys::window().unwrap().confirm_with_message("Are you sure you want to delete this expense?").unwrap() {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.expense_entries.retain(|e| e.id != expense_id);
|
||||
state.set(new_state);
|
||||
}
|
||||
})
|
||||
}><i class="bi bi-trash me-2"></i>{"Delete"}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
261
platform/src/components/accounting/financial_reports_tab.rs
Normal file
261
platform/src/components/accounting/financial_reports_tab.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::components::accounting::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct FinancialReportsTabProps {
|
||||
pub state: UseStateHandle<AccountingState>,
|
||||
}
|
||||
|
||||
#[function_component(FinancialReportsTab)]
|
||||
pub fn financial_reports_tab(props: &FinancialReportsTabProps) -> Html {
|
||||
let state = &props.state;
|
||||
let show_report_modal = use_state(|| false);
|
||||
let report_type = use_state(|| ReportType::ProfitLoss);
|
||||
let start_date = use_state(|| "".to_string());
|
||||
let end_date = use_state(|| "".to_string());
|
||||
|
||||
let on_generate_report = {
|
||||
let state = state.clone();
|
||||
let show_report_modal = show_report_modal.clone();
|
||||
let report_type = report_type.clone();
|
||||
let start_date = start_date.clone();
|
||||
let end_date = end_date.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if start_date.is_empty() || end_date.is_empty() {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Please select both start and end dates")
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let new_report = FinancialReport {
|
||||
id: state.financial_reports.len() + 1,
|
||||
report_type: (*report_type).clone(),
|
||||
period_start: (*start_date).clone(),
|
||||
period_end: (*end_date).clone(),
|
||||
generated_date: js_sys::Date::new_0().to_iso_string().as_string().unwrap()[..10].to_string(),
|
||||
status: "Generated".to_string(),
|
||||
};
|
||||
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.financial_reports.push(new_report);
|
||||
state.set(new_state);
|
||||
show_report_modal.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
let on_export_report = {
|
||||
Callback::from(move |report_id: usize| {
|
||||
// Create CSV content for the report
|
||||
let csv_content = format!(
|
||||
"Financial Report Export\nReport ID: {}\nGenerated: {}\n\nThis is a placeholder for the actual report data.",
|
||||
report_id,
|
||||
js_sys::Date::new_0().to_iso_string().as_string().unwrap()
|
||||
);
|
||||
|
||||
// Create and download the file
|
||||
let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&csv_content.into())).unwrap();
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).unwrap();
|
||||
|
||||
let document = web_sys::window().unwrap().document().unwrap();
|
||||
let a = document.create_element("a").unwrap();
|
||||
a.set_attribute("href", &url).unwrap();
|
||||
a.set_attribute("download", &format!("financial_report_{}.csv", report_id)).unwrap();
|
||||
a.dyn_ref::<web_sys::HtmlElement>().unwrap().click();
|
||||
|
||||
web_sys::Url::revoke_object_url(&url).unwrap();
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="animate-fade-in-up">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">{"Financial Reports"}</h4>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={
|
||||
let show_report_modal = show_report_modal.clone();
|
||||
Callback::from(move |_| show_report_modal.set(true))
|
||||
}
|
||||
>
|
||||
<i class="bi bi-plus-lg me-2"></i>
|
||||
{"Generate Report"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-soft border-0">
|
||||
<div class="card-body">
|
||||
if state.financial_reports.is_empty() {
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-earmark-text display-1 text-muted mb-3"></i>
|
||||
<h5 class="text-muted">{"No reports generated yet"}</h5>
|
||||
<p class="text-muted">{"Generate your first financial report to get started"}</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{"Report Type"}</th>
|
||||
<th>{"Period"}</th>
|
||||
<th>{"Generated"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for state.financial_reports.iter().map(|report| {
|
||||
let report_id = report.id;
|
||||
let on_export = on_export_report.clone();
|
||||
html! {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge bg-primary">{format!("{:?}", report.report_type)}</span>
|
||||
</td>
|
||||
<td>{format!("{} to {}", report.period_start, report.period_end)}</td>
|
||||
<td>{&report.generated_date}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{&report.status}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
class="btn btn-outline-primary"
|
||||
onclick={
|
||||
Callback::from(move |_| {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Report preview feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-success"
|
||||
onclick={move |_| on_export.emit(report_id)}
|
||||
>
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Report Generation Modal
|
||||
if *show_report_modal {
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Generate Financial Report"}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
onclick={
|
||||
let show_report_modal = show_report_modal.clone();
|
||||
Callback::from(move |_| show_report_modal.set(false))
|
||||
}
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"Report Type"}</label>
|
||||
<select
|
||||
class="form-select"
|
||||
onchange={
|
||||
let report_type = report_type.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<HtmlInputElement>().unwrap();
|
||||
let value = match target.value().as_str() {
|
||||
"ProfitLoss" => ReportType::ProfitLoss,
|
||||
"BalanceSheet" => ReportType::BalanceSheet,
|
||||
"CashFlow" => ReportType::CashFlow,
|
||||
"TaxSummary" => ReportType::TaxSummary,
|
||||
_ => ReportType::ProfitLoss,
|
||||
};
|
||||
report_type.set(value);
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="ProfitLoss">{"Profit & Loss"}</option>
|
||||
<option value="BalanceSheet">{"Balance Sheet"}</option>
|
||||
<option value="CashFlow">{"Cash Flow"}</option>
|
||||
<option value="TaxSummary">{"Tax Summary"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"Start Date"}</label>
|
||||
<input
|
||||
type="date"
|
||||
class="form-control"
|
||||
value={(*start_date).clone()}
|
||||
onchange={
|
||||
let start_date = start_date.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<HtmlInputElement>().unwrap();
|
||||
start_date.set(target.value());
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"End Date"}</label>
|
||||
<input
|
||||
type="date"
|
||||
class="form-control"
|
||||
value={(*end_date).clone()}
|
||||
onchange={
|
||||
let end_date = end_date.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<HtmlInputElement>().unwrap();
|
||||
end_date.set(target.value());
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={
|
||||
let show_report_modal = show_report_modal.clone();
|
||||
Callback::from(move |_| show_report_modal.set(false))
|
||||
}
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={on_generate_report}
|
||||
>
|
||||
{"Generate Report"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
13
platform/src/components/accounting/mod.rs
Normal file
13
platform/src/components/accounting/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod models;
|
||||
pub mod overview_tab;
|
||||
pub mod revenue_tab;
|
||||
pub mod expenses_tab;
|
||||
pub mod tax_tab;
|
||||
pub mod financial_reports_tab;
|
||||
|
||||
pub use models::*;
|
||||
pub use overview_tab::*;
|
||||
pub use revenue_tab::*;
|
||||
pub use expenses_tab::*;
|
||||
pub use tax_tab::*;
|
||||
pub use financial_reports_tab::*;
|
||||
632
platform/src/components/accounting/models.rs
Normal file
632
platform/src/components/accounting/models.rs
Normal file
@@ -0,0 +1,632 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GeneratedReport {
|
||||
pub id: String,
|
||||
pub report_type: ReportType,
|
||||
pub title: String,
|
||||
pub date_generated: String,
|
||||
pub period_start: String,
|
||||
pub period_end: String,
|
||||
pub file_url: String,
|
||||
pub file_size: String,
|
||||
pub status: ReportStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum ReportType {
|
||||
ProfitLoss,
|
||||
BalanceSheet,
|
||||
CashFlow,
|
||||
TaxSummary,
|
||||
ExpenseReport,
|
||||
RevenueReport,
|
||||
}
|
||||
|
||||
impl ReportType {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
ReportType::ProfitLoss => "Profit & Loss",
|
||||
ReportType::BalanceSheet => "Balance Sheet",
|
||||
ReportType::CashFlow => "Cash Flow",
|
||||
ReportType::TaxSummary => "Tax Summary",
|
||||
ReportType::ExpenseReport => "Expense Report",
|
||||
ReportType::RevenueReport => "Revenue Report",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_icon(&self) -> &str {
|
||||
match self {
|
||||
ReportType::ProfitLoss => "graph-up",
|
||||
ReportType::BalanceSheet => "pie-chart",
|
||||
ReportType::CashFlow => "arrow-left-right",
|
||||
ReportType::TaxSummary => "receipt",
|
||||
ReportType::ExpenseReport => "graph-down",
|
||||
ReportType::RevenueReport => "graph-up-arrow",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
ReportType::ProfitLoss => "primary",
|
||||
ReportType::BalanceSheet => "success",
|
||||
ReportType::CashFlow => "info",
|
||||
ReportType::TaxSummary => "warning",
|
||||
ReportType::ExpenseReport => "danger",
|
||||
ReportType::RevenueReport => "success",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ReportStatus {
|
||||
Generating,
|
||||
Ready,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ReportStatus {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
ReportStatus::Generating => "Generating",
|
||||
ReportStatus::Ready => "Ready",
|
||||
ReportStatus::Failed => "Failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
ReportStatus::Generating => "warning",
|
||||
ReportStatus::Ready => "success",
|
||||
ReportStatus::Failed => "danger",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FinancialReport {
|
||||
pub id: usize,
|
||||
pub report_type: ReportType,
|
||||
pub period_start: String,
|
||||
pub period_end: String,
|
||||
pub generated_date: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PaymentTransaction {
|
||||
pub id: String,
|
||||
pub invoice_id: Option<String>, // For revenue transactions
|
||||
pub expense_id: Option<String>, // For expense transactions
|
||||
pub date: String,
|
||||
pub amount: f64,
|
||||
pub payment_method: PaymentMethod,
|
||||
pub transaction_hash: Option<String>, // For crypto payments
|
||||
pub reference_number: Option<String>, // For bank transfers, checks, etc.
|
||||
pub notes: String,
|
||||
pub attached_files: Vec<String>, // File URLs/paths
|
||||
pub status: TransactionStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TransactionStatus {
|
||||
Pending,
|
||||
Confirmed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl TransactionStatus {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
TransactionStatus::Pending => "Pending",
|
||||
TransactionStatus::Confirmed => "Confirmed",
|
||||
TransactionStatus::Failed => "Failed",
|
||||
TransactionStatus::Cancelled => "Cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
TransactionStatus::Pending => "warning",
|
||||
TransactionStatus::Confirmed => "success",
|
||||
TransactionStatus::Failed => "danger",
|
||||
TransactionStatus::Cancelled => "secondary",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RevenueEntry {
|
||||
pub id: String,
|
||||
pub date: String,
|
||||
pub invoice_number: String,
|
||||
pub client_name: String,
|
||||
pub client_email: String,
|
||||
pub client_address: String,
|
||||
pub description: String,
|
||||
pub quantity: f64,
|
||||
pub unit_price: f64,
|
||||
pub subtotal: f64,
|
||||
pub tax_rate: f64,
|
||||
pub tax_amount: f64,
|
||||
pub total_amount: f64,
|
||||
pub category: RevenueCategory,
|
||||
pub payment_method: PaymentMethod,
|
||||
pub payment_status: PaymentStatus,
|
||||
pub due_date: String,
|
||||
pub paid_date: Option<String>,
|
||||
pub notes: String,
|
||||
pub recurring: bool,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExpenseEntry {
|
||||
pub id: String,
|
||||
pub date: String,
|
||||
pub receipt_number: String,
|
||||
pub vendor_name: String,
|
||||
pub vendor_email: String,
|
||||
pub vendor_address: String,
|
||||
pub description: String,
|
||||
pub amount: f64,
|
||||
pub tax_amount: f64,
|
||||
pub total_amount: f64,
|
||||
pub category: ExpenseCategory,
|
||||
pub payment_method: PaymentMethod,
|
||||
pub payment_status: PaymentStatus,
|
||||
pub is_deductible: bool,
|
||||
pub receipt_url: Option<String>,
|
||||
pub approval_status: ApprovalStatus,
|
||||
pub approved_by: Option<String>,
|
||||
pub notes: String,
|
||||
pub project_code: Option<String>,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum RevenueCategory {
|
||||
ProductSales,
|
||||
ServiceRevenue,
|
||||
ConsultingFees,
|
||||
LicensingRoyalties,
|
||||
SubscriptionRevenue,
|
||||
InterestIncome,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl RevenueCategory {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
RevenueCategory::ProductSales => "Product Sales",
|
||||
RevenueCategory::ServiceRevenue => "Service Revenue",
|
||||
RevenueCategory::ConsultingFees => "Consulting Fees",
|
||||
RevenueCategory::LicensingRoyalties => "Licensing & Royalties",
|
||||
RevenueCategory::SubscriptionRevenue => "Subscription Revenue",
|
||||
RevenueCategory::InterestIncome => "Interest Income",
|
||||
RevenueCategory::Other => "Other Revenue",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
RevenueCategory::ProductSales => "success",
|
||||
RevenueCategory::ServiceRevenue => "primary",
|
||||
RevenueCategory::ConsultingFees => "info",
|
||||
RevenueCategory::LicensingRoyalties => "warning",
|
||||
RevenueCategory::SubscriptionRevenue => "secondary",
|
||||
RevenueCategory::InterestIncome => "dark",
|
||||
RevenueCategory::Other => "light",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ExpenseCategory {
|
||||
OfficeSupplies,
|
||||
MarketingAdvertising,
|
||||
TravelExpenses,
|
||||
SoftwareLicenses,
|
||||
EquipmentPurchases,
|
||||
UtilitiesBills,
|
||||
RentLease,
|
||||
SalariesWages,
|
||||
ProfessionalServices,
|
||||
Insurance,
|
||||
Telecommunications,
|
||||
Maintenance,
|
||||
Training,
|
||||
Entertainment,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ExpenseCategory {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
ExpenseCategory::OfficeSupplies => "Office Supplies",
|
||||
ExpenseCategory::MarketingAdvertising => "Marketing & Advertising",
|
||||
ExpenseCategory::TravelExpenses => "Travel Expenses",
|
||||
ExpenseCategory::SoftwareLicenses => "Software Licenses",
|
||||
ExpenseCategory::EquipmentPurchases => "Equipment Purchases",
|
||||
ExpenseCategory::UtilitiesBills => "Utilities & Bills",
|
||||
ExpenseCategory::RentLease => "Rent & Lease",
|
||||
ExpenseCategory::SalariesWages => "Salaries & Wages",
|
||||
ExpenseCategory::ProfessionalServices => "Professional Services",
|
||||
ExpenseCategory::Insurance => "Insurance",
|
||||
ExpenseCategory::Telecommunications => "Telecommunications",
|
||||
ExpenseCategory::Maintenance => "Maintenance",
|
||||
ExpenseCategory::Training => "Training & Development",
|
||||
ExpenseCategory::Entertainment => "Entertainment",
|
||||
ExpenseCategory::Other => "Other Expenses",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
ExpenseCategory::OfficeSupplies => "secondary",
|
||||
ExpenseCategory::MarketingAdvertising => "primary",
|
||||
ExpenseCategory::TravelExpenses => "info",
|
||||
ExpenseCategory::SoftwareLicenses => "success",
|
||||
ExpenseCategory::EquipmentPurchases => "warning",
|
||||
ExpenseCategory::UtilitiesBills => "dark",
|
||||
ExpenseCategory::RentLease => "danger",
|
||||
ExpenseCategory::SalariesWages => "primary",
|
||||
ExpenseCategory::ProfessionalServices => "info",
|
||||
ExpenseCategory::Insurance => "secondary",
|
||||
ExpenseCategory::Telecommunications => "success",
|
||||
ExpenseCategory::Maintenance => "warning",
|
||||
ExpenseCategory::Training => "info",
|
||||
ExpenseCategory::Entertainment => "secondary",
|
||||
ExpenseCategory::Other => "light",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PaymentMethod {
|
||||
BankTransfer,
|
||||
CreditCard,
|
||||
DebitCard,
|
||||
Cash,
|
||||
Check,
|
||||
CryptoBitcoin,
|
||||
CryptoEthereum,
|
||||
CryptoUSDC,
|
||||
CryptoOther,
|
||||
PayPal,
|
||||
Stripe,
|
||||
WireTransfer,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl PaymentMethod {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
PaymentMethod::BankTransfer => "Bank Transfer",
|
||||
PaymentMethod::CreditCard => "Credit Card",
|
||||
PaymentMethod::DebitCard => "Debit Card",
|
||||
PaymentMethod::Cash => "Cash",
|
||||
PaymentMethod::Check => "Check",
|
||||
PaymentMethod::CryptoBitcoin => "Bitcoin",
|
||||
PaymentMethod::CryptoEthereum => "Ethereum",
|
||||
PaymentMethod::CryptoUSDC => "USDC",
|
||||
PaymentMethod::CryptoOther => "Other Crypto",
|
||||
PaymentMethod::PayPal => "PayPal",
|
||||
PaymentMethod::Stripe => "Stripe",
|
||||
PaymentMethod::WireTransfer => "Wire Transfer",
|
||||
PaymentMethod::Other => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_icon(&self) -> &str {
|
||||
match self {
|
||||
PaymentMethod::BankTransfer => "bank",
|
||||
PaymentMethod::CreditCard => "credit-card",
|
||||
PaymentMethod::DebitCard => "credit-card-2-front",
|
||||
PaymentMethod::Cash => "cash-stack",
|
||||
PaymentMethod::Check => "receipt",
|
||||
PaymentMethod::CryptoBitcoin => "currency-bitcoin",
|
||||
PaymentMethod::CryptoEthereum => "currency-ethereum",
|
||||
PaymentMethod::CryptoUSDC => "currency-dollar",
|
||||
PaymentMethod::CryptoOther => "coin",
|
||||
PaymentMethod::PayPal => "paypal",
|
||||
PaymentMethod::Stripe => "stripe",
|
||||
PaymentMethod::WireTransfer => "arrow-left-right",
|
||||
PaymentMethod::Other => "question-circle",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
PaymentMethod::BankTransfer => "primary",
|
||||
PaymentMethod::CreditCard => "success",
|
||||
PaymentMethod::DebitCard => "info",
|
||||
PaymentMethod::Cash => "warning",
|
||||
PaymentMethod::Check => "secondary",
|
||||
PaymentMethod::CryptoBitcoin => "warning",
|
||||
PaymentMethod::CryptoEthereum => "info",
|
||||
PaymentMethod::CryptoUSDC => "success",
|
||||
PaymentMethod::CryptoOther => "dark",
|
||||
PaymentMethod::PayPal => "primary",
|
||||
PaymentMethod::Stripe => "info",
|
||||
PaymentMethod::WireTransfer => "secondary",
|
||||
PaymentMethod::Other => "light",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PaymentStatus {
|
||||
Pending,
|
||||
Paid,
|
||||
Overdue,
|
||||
PartiallyPaid,
|
||||
Cancelled,
|
||||
Refunded,
|
||||
}
|
||||
|
||||
impl PaymentStatus {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
PaymentStatus::Pending => "Pending",
|
||||
PaymentStatus::Paid => "Paid",
|
||||
PaymentStatus::Overdue => "Overdue",
|
||||
PaymentStatus::PartiallyPaid => "Partially Paid",
|
||||
PaymentStatus::Cancelled => "Cancelled",
|
||||
PaymentStatus::Refunded => "Refunded",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
PaymentStatus::Pending => "warning",
|
||||
PaymentStatus::Paid => "success",
|
||||
PaymentStatus::Overdue => "danger",
|
||||
PaymentStatus::PartiallyPaid => "info",
|
||||
PaymentStatus::Cancelled => "secondary",
|
||||
PaymentStatus::Refunded => "dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ApprovalStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
RequiresReview,
|
||||
}
|
||||
|
||||
impl ApprovalStatus {
|
||||
pub fn to_string(&self) -> &str {
|
||||
match self {
|
||||
ApprovalStatus::Pending => "Pending",
|
||||
ApprovalStatus::Approved => "Approved",
|
||||
ApprovalStatus::Rejected => "Rejected",
|
||||
ApprovalStatus::RequiresReview => "Requires Review",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_color(&self) -> &str {
|
||||
match self {
|
||||
ApprovalStatus::Pending => "warning",
|
||||
ApprovalStatus::Approved => "success",
|
||||
ApprovalStatus::Rejected => "danger",
|
||||
ApprovalStatus::RequiresReview => "info",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ReportForm {
|
||||
pub report_type: ReportType,
|
||||
pub period_start: String,
|
||||
pub period_end: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl Default for ReportForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
report_type: ReportType::ProfitLoss,
|
||||
period_start: String::new(),
|
||||
period_end: String::new(),
|
||||
title: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct TransactionForm {
|
||||
pub invoice_id: Option<String>,
|
||||
pub expense_id: Option<String>,
|
||||
pub amount: f64,
|
||||
pub payment_method: PaymentMethod,
|
||||
pub transaction_hash: String,
|
||||
pub reference_number: String,
|
||||
pub notes: String,
|
||||
pub attached_files: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for TransactionForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
invoice_id: None,
|
||||
expense_id: None,
|
||||
amount: 0.0,
|
||||
payment_method: PaymentMethod::BankTransfer,
|
||||
transaction_hash: String::new(),
|
||||
reference_number: String::new(),
|
||||
notes: String::new(),
|
||||
attached_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct AccountingState {
|
||||
pub revenue_entries: Vec<RevenueEntry>,
|
||||
pub expense_entries: Vec<ExpenseEntry>,
|
||||
pub generated_reports: Vec<GeneratedReport>,
|
||||
pub financial_reports: Vec<FinancialReport>,
|
||||
pub payment_transactions: Vec<PaymentTransaction>,
|
||||
pub show_revenue_form: bool,
|
||||
pub show_expense_form: bool,
|
||||
pub show_report_form: bool,
|
||||
pub show_transaction_form: bool,
|
||||
pub show_invoice_detail: bool,
|
||||
pub selected_invoice_id: Option<String>,
|
||||
pub show_expense_detail: bool,
|
||||
pub selected_expense_id: Option<String>,
|
||||
pub revenue_form: RevenueEntry,
|
||||
pub expense_form: ExpenseEntry,
|
||||
pub report_form: ReportForm,
|
||||
pub transaction_form: TransactionForm,
|
||||
pub revenue_filter: String,
|
||||
pub expense_filter: String,
|
||||
pub revenue_search: String,
|
||||
pub expense_search: String,
|
||||
}
|
||||
|
||||
impl Default for AccountingState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
revenue_entries: Vec::new(),
|
||||
expense_entries: Vec::new(),
|
||||
generated_reports: Vec::new(),
|
||||
financial_reports: Vec::new(),
|
||||
payment_transactions: Vec::new(),
|
||||
show_revenue_form: false,
|
||||
show_expense_form: false,
|
||||
show_report_form: false,
|
||||
show_transaction_form: false,
|
||||
show_invoice_detail: false,
|
||||
selected_invoice_id: None,
|
||||
show_expense_detail: false,
|
||||
selected_expense_id: None,
|
||||
revenue_form: RevenueEntry {
|
||||
id: String::new(),
|
||||
date: String::new(),
|
||||
invoice_number: String::new(),
|
||||
client_name: String::new(),
|
||||
client_email: String::new(),
|
||||
client_address: String::new(),
|
||||
description: String::new(),
|
||||
quantity: 1.0,
|
||||
unit_price: 0.0,
|
||||
subtotal: 0.0,
|
||||
tax_rate: 0.20,
|
||||
tax_amount: 0.0,
|
||||
total_amount: 0.0,
|
||||
category: RevenueCategory::ServiceRevenue,
|
||||
payment_method: PaymentMethod::BankTransfer,
|
||||
payment_status: PaymentStatus::Pending,
|
||||
due_date: String::new(),
|
||||
paid_date: None,
|
||||
notes: String::new(),
|
||||
recurring: false,
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
expense_form: ExpenseEntry {
|
||||
id: String::new(),
|
||||
date: String::new(),
|
||||
receipt_number: String::new(),
|
||||
vendor_name: String::new(),
|
||||
vendor_email: String::new(),
|
||||
vendor_address: String::new(),
|
||||
description: String::new(),
|
||||
amount: 0.0,
|
||||
tax_amount: 0.0,
|
||||
total_amount: 0.0,
|
||||
category: ExpenseCategory::OfficeSupplies,
|
||||
payment_method: PaymentMethod::BankTransfer,
|
||||
payment_status: PaymentStatus::Pending,
|
||||
is_deductible: true,
|
||||
receipt_url: None,
|
||||
approval_status: ApprovalStatus::Pending,
|
||||
approved_by: None,
|
||||
notes: String::new(),
|
||||
project_code: None,
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
report_form: ReportForm::default(),
|
||||
transaction_form: TransactionForm::default(),
|
||||
revenue_filter: String::new(),
|
||||
expense_filter: String::new(),
|
||||
revenue_search: String::new(),
|
||||
expense_search: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum AccountingMsg {
|
||||
// Revenue actions
|
||||
CreateInvoice,
|
||||
EditRevenue(String),
|
||||
DeleteRevenue(String),
|
||||
ViewRevenue(String),
|
||||
PrintInvoice(String),
|
||||
SendReminder(String),
|
||||
DuplicateRevenue(String),
|
||||
|
||||
// Transaction actions
|
||||
RecordTransaction(String), // invoice_id
|
||||
ShowTransactionForm(String), // invoice_id
|
||||
HideTransactionForm,
|
||||
UpdateTransactionForm(String, String), // field, value
|
||||
SubmitTransactionForm,
|
||||
ViewTransaction(String),
|
||||
DeleteTransaction(String),
|
||||
|
||||
// Expense actions
|
||||
AddExpense,
|
||||
EditExpense(String),
|
||||
DeleteExpense(String),
|
||||
ViewExpense(String),
|
||||
ViewReceipt(String),
|
||||
ApproveExpense(String),
|
||||
DuplicateExpense(String),
|
||||
|
||||
// Filter and search
|
||||
FilterRevenue(String),
|
||||
FilterExpense(String),
|
||||
SearchRevenue(String),
|
||||
SearchExpense(String),
|
||||
|
||||
// Export actions
|
||||
ExportRevenue,
|
||||
ExportExpense,
|
||||
|
||||
// Tax actions
|
||||
GenerateTaxReport,
|
||||
OpenTaxCalculator,
|
||||
ExportForAccountant,
|
||||
|
||||
// Financial reports
|
||||
GenerateProfitLoss,
|
||||
GenerateBalanceSheet,
|
||||
GenerateCashFlow,
|
||||
|
||||
// Report generation actions
|
||||
ShowReportForm,
|
||||
HideReportForm,
|
||||
UpdateReportForm(String, String), // field, value
|
||||
SubmitReportForm,
|
||||
DownloadReport(String),
|
||||
DeleteReport(String),
|
||||
|
||||
// Form actions
|
||||
ShowRevenueForm,
|
||||
ShowExpenseForm,
|
||||
HideForm,
|
||||
UpdateRevenueForm(String, String), // field, value
|
||||
UpdateExpenseForm(String, String), // field, value
|
||||
SubmitRevenueForm,
|
||||
SubmitExpenseForm,
|
||||
|
||||
// Invoice detail view
|
||||
ShowInvoiceDetail(String), // invoice_id
|
||||
HideInvoiceDetail,
|
||||
}
|
||||
207
platform/src/components/accounting/overview_tab.rs
Normal file
207
platform/src/components/accounting/overview_tab.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use yew::prelude::*;
|
||||
use crate::components::accounting::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct OverviewTabProps {
|
||||
pub state: UseStateHandle<AccountingState>,
|
||||
}
|
||||
|
||||
#[function_component(OverviewTab)]
|
||||
pub fn overview_tab(props: &OverviewTabProps) -> Html {
|
||||
let state = &props.state;
|
||||
|
||||
// Calculate totals
|
||||
let total_revenue: f64 = state.revenue_entries.iter().map(|r| r.total_amount).sum();
|
||||
let total_expenses: f64 = state.expense_entries.iter().map(|e| e.total_amount).sum();
|
||||
let net_profit = total_revenue - total_expenses;
|
||||
let pending_revenue: f64 = state.revenue_entries.iter()
|
||||
.filter(|r| r.payment_status == PaymentStatus::Pending)
|
||||
.map(|r| r.total_amount)
|
||||
.sum();
|
||||
let pending_expenses: f64 = state.expense_entries.iter()
|
||||
.filter(|e| e.payment_status == PaymentStatus::Pending)
|
||||
.map(|e| e.total_amount)
|
||||
.sum();
|
||||
|
||||
html! {
|
||||
<div class="animate-fade-in-up">
|
||||
// Key Statistics Cards
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning shadow-soft card-hover">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-warning mb-1">{"Pending Items"}</h6>
|
||||
<h3 class="mb-0 fw-bold text-dark">{format!("${:.2}", pending_revenue + pending_expenses)}</h3>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-clock text-warning fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">{format!("${:.2} revenue, ${:.2} expenses", pending_revenue, pending_expenses)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-info shadow-soft card-hover">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-info mb-1">{"Avg Invoice Value"}</h6>
|
||||
<h3 class="mb-0 fw-bold text-dark">{format!("${:.2}", total_revenue / state.revenue_entries.len() as f64)}</h3>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-receipt text-info fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">{format!("{} invoices total", state.revenue_entries.len())}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success shadow-soft card-hover">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-success mb-1">{"Tax Deductible"}</h6>
|
||||
<h3 class="mb-0 fw-bold text-dark">{format!("${:.2}", state.expense_entries.iter().filter(|e| e.is_deductible).map(|e| e.total_amount).sum::<f64>())}</h3>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-receipt-cutoff text-success fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">{"100% of expenses deductible"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-primary shadow-soft card-hover">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-primary mb-1">{"Collection Rate"}</h6>
|
||||
<h3 class="mb-0 fw-bold text-dark">{"85.2%"}</h3>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-percent text-primary fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">{"Above industry avg"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Recent Transactions
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-soft border-0">
|
||||
<div class="card-header bg-white border-bottom-0 py-3">
|
||||
<h5 class="mb-0 fw-bold">{"Recent Transactions"}</h5>
|
||||
<small class="text-muted">{"Latest payments made and received"}</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{if state.payment_transactions.is_empty() {
|
||||
html! {
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-credit-card fs-1 text-muted mb-3 d-block"></i>
|
||||
<h6 class="text-muted">{"No transactions recorded yet"}</h6>
|
||||
<p class="text-muted mb-0">{"Transactions will appear here once you record payments"}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="border-0 py-3 px-4">{"Date"}</th>
|
||||
<th class="border-0 py-3">{"Type"}</th>
|
||||
<th class="border-0 py-3">{"Reference"}</th>
|
||||
<th class="border-0 py-3">{"Amount"}</th>
|
||||
<th class="border-0 py-3">{"Method"}</th>
|
||||
<th class="border-0 py-3">{"Status"}</th>
|
||||
<th class="border-0 py-3">{"Notes"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for state.payment_transactions.iter().take(10).map(|transaction| {
|
||||
let (transaction_type, reference, amount_color) = if let Some(invoice_id) = &transaction.invoice_id {
|
||||
("Revenue", invoice_id.clone(), "text-success")
|
||||
} else if let Some(expense_id) = &transaction.expense_id {
|
||||
("Expense", expense_id.clone(), "text-danger")
|
||||
} else {
|
||||
("Unknown", "N/A".to_string(), "text-muted")
|
||||
};
|
||||
|
||||
html! {
|
||||
<tr>
|
||||
<td class="py-3 px-4">
|
||||
<div class="fw-semibold">{&transaction.date}</div>
|
||||
<small class="text-muted">{&transaction.id}</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class={format!("badge bg-{} bg-opacity-10 text-{}",
|
||||
if transaction_type == "Revenue" { "success" } else { "danger" },
|
||||
if transaction_type == "Revenue" { "success" } else { "danger" }
|
||||
)}>
|
||||
{transaction_type}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-semibold">{&reference}</div>
|
||||
{if let Some(hash) = &transaction.transaction_hash {
|
||||
html! { <small class="text-muted"><code>{&hash[..12]}{"..."}</code></small> }
|
||||
} else if let Some(ref_num) = &transaction.reference_number {
|
||||
html! { <small class="text-muted">{ref_num}</small> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class={format!("fw-bold {}", amount_color)}>
|
||||
{if transaction_type == "Revenue" { "+" } else { "-" }}
|
||||
{format!("${:.2}", transaction.amount)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class={format!("bi bi-{} text-{} me-2", transaction.payment_method.get_icon(), transaction.payment_method.get_color())}></i>
|
||||
<span class="small">{transaction.payment_method.to_string()}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class={format!("badge bg-{}", transaction.status.get_color())}>
|
||||
{transaction.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class="text-muted">{&transaction.notes}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
724
platform/src/components/accounting/revenue_tab.rs
Normal file
724
platform/src/components/accounting/revenue_tab.rs
Normal file
@@ -0,0 +1,724 @@
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::components::accounting::models::*;
|
||||
use js_sys;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RevenueTabProps {
|
||||
pub state: UseStateHandle<AccountingState>,
|
||||
}
|
||||
|
||||
#[function_component(RevenueTab)]
|
||||
pub fn revenue_tab(props: &RevenueTabProps) -> Html {
|
||||
let state = &props.state;
|
||||
|
||||
html! {
|
||||
<div class="animate-fade-in-up">
|
||||
// Revenue Form Modal
|
||||
{if state.show_revenue_form {
|
||||
html! {
|
||||
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Create New Invoice"}</h5>
|
||||
<button type="button" class="btn-close" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_revenue_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Invoice Number"}</label>
|
||||
<input type="text" class="form-control" value={state.revenue_form.invoice_number.clone()} readonly=true />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Date"}</label>
|
||||
<input type="date" class="form-control" value={state.revenue_form.date.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.revenue_form.date = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Client Name"}</label>
|
||||
<input type="text" class="form-control" value={state.revenue_form.client_name.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.revenue_form.client_name = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Client Email"}</label>
|
||||
<input type="email" class="form-control" value={state.revenue_form.client_email.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.revenue_form.client_email = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Description"}</label>
|
||||
<textarea class="form-control" rows="3" value={state.revenue_form.description.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.revenue_form.description = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
}></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{"Quantity"}</label>
|
||||
<input type="number" class="form-control" value={state.revenue_form.quantity.to_string()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.revenue_form.quantity = input.value().parse().unwrap_or(1.0);
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{"Unit Price"}</label>
|
||||
<input type="number" step="0.01" class="form-control" value={state.revenue_form.unit_price.to_string()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.revenue_form.unit_price = input.value().parse().unwrap_or(0.0);
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{"Due Date"}</label>
|
||||
<input type="date" class="form-control" value={state.revenue_form.due_date.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.revenue_form.due_date = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_revenue_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Cancel"}</button>
|
||||
<button type="button" class="btn btn-success" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
// Calculate totals
|
||||
new_state.revenue_form.subtotal = new_state.revenue_form.quantity * new_state.revenue_form.unit_price;
|
||||
new_state.revenue_form.tax_amount = new_state.revenue_form.subtotal * new_state.revenue_form.tax_rate;
|
||||
new_state.revenue_form.total_amount = new_state.revenue_form.subtotal + new_state.revenue_form.tax_amount;
|
||||
|
||||
// Add to entries
|
||||
new_state.revenue_entries.push(new_state.revenue_form.clone());
|
||||
|
||||
// Reset form
|
||||
new_state.show_revenue_form = false;
|
||||
new_state.revenue_form = AccountingState::default().revenue_form;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Create Invoice"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
// Transaction Form Modal
|
||||
{if state.show_transaction_form {
|
||||
html! {
|
||||
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Record Payment Transaction"}</h5>
|
||||
<button type="button" class="btn-close" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Invoice Number"}</label>
|
||||
<input type="text" class="form-control" value={state.transaction_form.invoice_id.clone().unwrap_or_default()} readonly=true />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{"Payment Amount"}</label>
|
||||
<input type="number" step="0.01" class="form-control" value={state.transaction_form.amount.to_string()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.amount = input.value().parse().unwrap_or(0.0);
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Payment Method"}</label>
|
||||
<select class="form-select" onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.payment_method = match select.value().as_str() {
|
||||
"BankTransfer" => PaymentMethod::BankTransfer,
|
||||
"CreditCard" => PaymentMethod::CreditCard,
|
||||
"CryptoBitcoin" => PaymentMethod::CryptoBitcoin,
|
||||
"CryptoEthereum" => PaymentMethod::CryptoEthereum,
|
||||
"CryptoUSDC" => PaymentMethod::CryptoUSDC,
|
||||
"Cash" => PaymentMethod::Cash,
|
||||
"Check" => PaymentMethod::Check,
|
||||
_ => PaymentMethod::BankTransfer,
|
||||
};
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<option value="BankTransfer" selected={matches!(state.transaction_form.payment_method, PaymentMethod::BankTransfer)}>{"Bank Transfer"}</option>
|
||||
<option value="CreditCard" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CreditCard)}>{"Credit Card"}</option>
|
||||
<option value="CryptoBitcoin" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CryptoBitcoin)}>{"Bitcoin"}</option>
|
||||
<option value="CryptoEthereum" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CryptoEthereum)}>{"Ethereum"}</option>
|
||||
<option value="CryptoUSDC" selected={matches!(state.transaction_form.payment_method, PaymentMethod::CryptoUSDC)}>{"USDC"}</option>
|
||||
<option value="Cash" selected={matches!(state.transaction_form.payment_method, PaymentMethod::Cash)}>{"Cash"}</option>
|
||||
<option value="Check" selected={matches!(state.transaction_form.payment_method, PaymentMethod::Check)}>{"Check"}</option>
|
||||
</select>
|
||||
</div>
|
||||
{if matches!(state.transaction_form.payment_method, PaymentMethod::CryptoBitcoin | PaymentMethod::CryptoEthereum | PaymentMethod::CryptoUSDC | PaymentMethod::CryptoOther) {
|
||||
html! {
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Transaction Hash"}</label>
|
||||
<input type="text" class="form-control" placeholder="0x..." value={state.transaction_form.transaction_hash.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.transaction_hash = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Reference Number"}</label>
|
||||
<input type="text" class="form-control" placeholder="REF-2024-001" value={state.transaction_form.reference_number.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.reference_number = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
} />
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Notes"}</label>
|
||||
<textarea class="form-control" rows="3" value={state.transaction_form.notes.clone()} onchange={
|
||||
let state = state.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.transaction_form.notes = input.value();
|
||||
state.set(new_state);
|
||||
})
|
||||
}></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{"Attach Files"}</label>
|
||||
<input type="file" class="form-control" multiple=true accept=".pdf,.jpg,.jpeg,.png" />
|
||||
<small class="text-muted">{"Upload receipts, confirmations, or other supporting documents"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = false;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Cancel"}</button>
|
||||
<button type="button" class="btn btn-success" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
|
||||
// Create new transaction
|
||||
let transaction_count = new_state.payment_transactions.len() + 1;
|
||||
let new_transaction = PaymentTransaction {
|
||||
id: format!("TXN-2024-{:03}", transaction_count),
|
||||
invoice_id: new_state.transaction_form.invoice_id.clone(),
|
||||
expense_id: None,
|
||||
date: js_sys::Date::new_0().to_iso_string().as_string().unwrap()[..10].to_string(),
|
||||
amount: new_state.transaction_form.amount,
|
||||
payment_method: new_state.transaction_form.payment_method.clone(),
|
||||
transaction_hash: if new_state.transaction_form.transaction_hash.is_empty() { None } else { Some(new_state.transaction_form.transaction_hash.clone()) },
|
||||
reference_number: if new_state.transaction_form.reference_number.is_empty() { None } else { Some(new_state.transaction_form.reference_number.clone()) },
|
||||
notes: new_state.transaction_form.notes.clone(),
|
||||
attached_files: new_state.transaction_form.attached_files.clone(),
|
||||
status: TransactionStatus::Confirmed,
|
||||
};
|
||||
|
||||
new_state.payment_transactions.push(new_transaction);
|
||||
new_state.show_transaction_form = false;
|
||||
new_state.transaction_form = TransactionForm::default();
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Record Transaction"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
// Invoice Detail Modal
|
||||
{if state.show_invoice_detail {
|
||||
if let Some(invoice_id) = &state.selected_invoice_id {
|
||||
if let Some(invoice) = state.revenue_entries.iter().find(|r| &r.id == invoice_id) {
|
||||
let invoice_transactions: Vec<&PaymentTransaction> = state.payment_transactions.iter()
|
||||
.filter(|t| t.invoice_id.as_ref() == Some(invoice_id))
|
||||
.collect();
|
||||
let total_paid: f64 = invoice_transactions.iter().map(|t| t.amount).sum();
|
||||
let remaining_balance = invoice.total_amount - total_paid;
|
||||
|
||||
html! {
|
||||
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{format!("Invoice Details - {}", invoice.invoice_number)}</h5>
|
||||
<button type="button" class="btn-close" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_invoice_detail = false;
|
||||
new_state.selected_invoice_id = None;
|
||||
state.set(new_state);
|
||||
})
|
||||
}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-4">
|
||||
// Invoice Information
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">{"Invoice Information"}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><strong>{"Invoice #:"}</strong></div>
|
||||
<div class="col-6">{&invoice.invoice_number}</div>
|
||||
<div class="col-6"><strong>{"Date:"}</strong></div>
|
||||
<div class="col-6">{&invoice.date}</div>
|
||||
<div class="col-6"><strong>{"Due Date:"}</strong></div>
|
||||
<div class="col-6">{&invoice.due_date}</div>
|
||||
<div class="col-6"><strong>{"Status:"}</strong></div>
|
||||
<div class="col-6">
|
||||
<span class={format!("badge bg-{}", invoice.payment_status.get_color())}>
|
||||
{invoice.payment_status.to_string()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-6"><strong>{"Total Amount:"}</strong></div>
|
||||
<div class="col-6 fw-bold text-success">{format!("${:.2}", invoice.total_amount)}</div>
|
||||
<div class="col-6"><strong>{"Amount Paid:"}</strong></div>
|
||||
<div class="col-6 fw-bold text-primary">{format!("${:.2}", total_paid)}</div>
|
||||
<div class="col-6"><strong>{"Remaining:"}</strong></div>
|
||||
<div class="col-6 fw-bold text-danger">{format!("${:.2}", remaining_balance)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Client Information
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">{"Client Information"}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><strong>{"Name:"}</strong></div>
|
||||
<div class="col-8">{&invoice.client_name}</div>
|
||||
<div class="col-4"><strong>{"Email:"}</strong></div>
|
||||
<div class="col-8">{&invoice.client_email}</div>
|
||||
<div class="col-4"><strong>{"Address:"}</strong></div>
|
||||
<div class="col-8">{&invoice.client_address}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Transactions
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">{"Payment Transactions"}</h6>
|
||||
<button class="btn btn-sm btn-primary" onclick={
|
||||
let state = state.clone();
|
||||
let invoice_id = invoice.id.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = true;
|
||||
new_state.transaction_form.invoice_id = Some(invoice_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-plus-circle me-1"></i>{"Record Payment"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{if invoice_transactions.is_empty() {
|
||||
html! {
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-credit-card fs-1 mb-2 d-block"></i>
|
||||
<p class="mb-0">{"No payments recorded yet"}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="border-0 py-3">{"Date"}</th>
|
||||
<th class="border-0 py-3">{"Amount"}</th>
|
||||
<th class="border-0 py-3">{"Method"}</th>
|
||||
<th class="border-0 py-3">{"Reference"}</th>
|
||||
<th class="border-0 py-3">{"Status"}</th>
|
||||
<th class="border-0 py-3">{"Notes"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for invoice_transactions.iter().map(|transaction| {
|
||||
html! {
|
||||
<tr>
|
||||
<td class="py-3">{&transaction.date}</td>
|
||||
<td class="py-3 fw-bold text-success">{format!("${:.2}", transaction.amount)}</td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class={format!("bi bi-{} text-{} me-2", transaction.payment_method.get_icon(), transaction.payment_method.get_color())}></i>
|
||||
{transaction.payment_method.to_string()}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
{if let Some(hash) = &transaction.transaction_hash {
|
||||
html! { <code class="small">{&hash[..12]}{"..."}</code> }
|
||||
} else if let Some(ref_num) = &transaction.reference_number {
|
||||
html! { <span>{ref_num}</span> }
|
||||
} else {
|
||||
html! { <span class="text-muted">{"-"}</span> }
|
||||
}}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class={format!("badge bg-{}", transaction.status.get_color())}>
|
||||
{transaction.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">{&transaction.notes}</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_invoice_detail = false;
|
||||
new_state.selected_invoice_id = None;
|
||||
state.set(new_state);
|
||||
})
|
||||
}>{"Close"}</button>
|
||||
<button type="button" class="btn btn-primary" onclick={
|
||||
let state = state.clone();
|
||||
let invoice_id = invoice.id.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = true;
|
||||
new_state.transaction_form.invoice_id = Some(invoice_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-credit-card me-2"></i>{"Record Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
// Revenue Actions and Table
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-soft border-0">
|
||||
<div class="card-header bg-white border-bottom-0 py-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{"Revenue Entries"}</h5>
|
||||
<small class="text-muted">{"Click on any row to view details"}</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick={
|
||||
Callback::from(move |_| {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Revenue filter feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-funnel me-2"></i>{"Filter"}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick={
|
||||
let revenue_entries = state.revenue_entries.clone();
|
||||
Callback::from(move |_| {
|
||||
// Create CSV content
|
||||
let mut csv_content = "Invoice Number,Date,Client Name,Client Email,Description,Quantity,Unit Price,Subtotal,Tax Amount,Total Amount,Category,Payment Method,Payment Status,Due Date,Paid Date,Notes,Recurring,Currency\n".to_string();
|
||||
|
||||
for entry in &revenue_entries {
|
||||
csv_content.push_str(&format!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||
entry.invoice_number,
|
||||
entry.date,
|
||||
entry.client_name,
|
||||
entry.client_email,
|
||||
entry.description.replace(",", ";"),
|
||||
entry.quantity,
|
||||
entry.unit_price,
|
||||
entry.subtotal,
|
||||
entry.tax_amount,
|
||||
entry.total_amount,
|
||||
entry.category.to_string(),
|
||||
entry.payment_method.to_string(),
|
||||
entry.payment_status.to_string(),
|
||||
entry.due_date,
|
||||
entry.paid_date.as_ref().unwrap_or(&"".to_string()),
|
||||
entry.notes.replace(",", ";"),
|
||||
entry.recurring,
|
||||
entry.currency
|
||||
));
|
||||
}
|
||||
|
||||
// Create and download file
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let element = document.create_element("a").unwrap();
|
||||
element.set_attribute("href", &format!("data:text/csv;charset=utf-8,{}", js_sys::encode_uri_component(&csv_content))).unwrap();
|
||||
element.set_attribute("download", "revenue_export.csv").unwrap();
|
||||
element.set_attribute("style", "display: none").unwrap();
|
||||
document.body().unwrap().append_child(&element).unwrap();
|
||||
let html_element: web_sys::HtmlElement = element.clone().dyn_into().unwrap();
|
||||
html_element.click();
|
||||
document.body().unwrap().remove_child(&element).unwrap();
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-download me-2"></i>{"Export"}
|
||||
</button>
|
||||
<button class="btn btn-success btn-sm" onclick={
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_revenue_form = true;
|
||||
let invoice_count = new_state.revenue_entries.len() + 1;
|
||||
new_state.revenue_form.invoice_number = format!("INV-2024-{:03}", invoice_count);
|
||||
new_state.revenue_form.id = new_state.revenue_form.invoice_number.clone();
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-plus-circle me-2"></i>{"New Invoice"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="border-0 py-3 px-4">{"Invoice #"}</th>
|
||||
<th class="border-0 py-3">{"Client"}</th>
|
||||
<th class="border-0 py-3">{"Description"}</th>
|
||||
<th class="border-0 py-3">{"Amount"}</th>
|
||||
<th class="border-0 py-3">{"Payment Method"}</th>
|
||||
<th class="border-0 py-3">{"Status"}</th>
|
||||
<th class="border-0 py-3">{"Due Date"}</th>
|
||||
<th class="border-0 py-3">{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for state.revenue_entries.iter().map(|entry| {
|
||||
html! {
|
||||
<tr class="border-bottom">
|
||||
<td class="py-3 px-4 cursor-pointer" style="cursor: pointer;" onclick={
|
||||
let state = state.clone();
|
||||
let invoice_id = entry.id.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_invoice_detail = true;
|
||||
new_state.selected_invoice_id = Some(invoice_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}>
|
||||
<div class="fw-bold text-primary">{&entry.invoice_number}</div>
|
||||
<small class="text-muted">{&entry.date}</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-semibold">{&entry.client_name}</div>
|
||||
<small class="text-muted">{&entry.client_email}</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-semibold">{&entry.description}</div>
|
||||
<small class="text-muted">
|
||||
<span class={format!("badge bg-{} bg-opacity-10 text-{} me-1", entry.category.get_color(), entry.category.get_color())}>
|
||||
{entry.category.to_string()}
|
||||
</span>
|
||||
{if entry.recurring { "• Recurring" } else { "" }}
|
||||
</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-bold text-success">{format!("${:.2}", entry.total_amount)}</div>
|
||||
<small class="text-muted">{format!("${:.2} + ${:.2} tax", entry.subtotal, entry.tax_amount)}</small>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class={format!("bi bi-{} text-{} me-2", entry.payment_method.get_icon(), entry.payment_method.get_color())}></i>
|
||||
<span class="small">{entry.payment_method.to_string()}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class={format!("badge bg-{} bg-opacity-10 text-{}", entry.payment_status.get_color(), entry.payment_status.get_color())}>
|
||||
{entry.payment_status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="fw-semibold">{&entry.due_date}</div>
|
||||
{
|
||||
if let Some(paid_date) = &entry.paid_date {
|
||||
html! { <small class="text-success">{format!("Paid: {}", paid_date)}</small> }
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
let state = state.clone();
|
||||
let invoice_id = entry.id.clone();
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_invoice_detail = true;
|
||||
new_state.selected_invoice_id = Some(invoice_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}><i class="bi bi-eye me-2"></i>{"View Details"}</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick={
|
||||
let state = state.clone();
|
||||
let invoice_id = entry.id.clone();
|
||||
Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.show_transaction_form = true;
|
||||
new_state.transaction_form.invoice_id = Some(invoice_id.clone());
|
||||
state.set(new_state);
|
||||
})
|
||||
}><i class="bi bi-credit-card me-2"></i>{"Record Transaction"}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-pencil me-2"></i>{"Edit"}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-printer me-2"></i>{"Print Invoice"}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-envelope me-2"></i>{"Send Reminder"}</a></li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-files me-2"></i>{"Duplicate"}</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="#"><i class="bi bi-trash me-2"></i>{"Delete"}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
111
platform/src/components/accounting/tax_tab.rs
Normal file
111
platform/src/components/accounting/tax_tab.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use yew::prelude::*;
|
||||
use crate::components::accounting::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct TaxTabProps {
|
||||
pub state: UseStateHandle<AccountingState>,
|
||||
}
|
||||
|
||||
#[function_component(TaxTab)]
|
||||
pub fn tax_tab(props: &TaxTabProps) -> Html {
|
||||
let state = &props.state;
|
||||
|
||||
// Calculate totals
|
||||
let total_revenue: f64 = state.revenue_entries.iter().map(|r| r.total_amount).sum();
|
||||
let total_expenses: f64 = state.expense_entries.iter().map(|e| e.total_amount).sum();
|
||||
|
||||
html! {
|
||||
<div class="animate-fade-in-up">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"Tax calculations are automatically updated based on your revenue and expense entries. Consult with a tax professional for accurate filing."}
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-soft border-0">
|
||||
<div class="card-header bg-white border-bottom-0 py-3">
|
||||
<h5 class="mb-0 fw-bold">{"Tax Summary"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded p-3">
|
||||
<h6 class="text-muted mb-3">{"Revenue Summary"}</h6>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>{"Gross Revenue"}</span>
|
||||
<span class="fw-bold text-success">{format!("${:.2}", total_revenue)}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>{"VAT Collected"}</span>
|
||||
<span class="fw-bold">{format!("${:.2}", state.revenue_entries.iter().map(|r| r.tax_amount).sum::<f64>())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="border rounded p-3">
|
||||
<h6 class="text-muted mb-3">{"Expense Summary"}</h6>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>{"Total Expenses"}</span>
|
||||
<span class="fw-bold text-danger">{format!("${:.2}", total_expenses)}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>{"VAT Paid"}</span>
|
||||
<span class="fw-bold">{format!("${:.2}", state.expense_entries.iter().map(|e| e.tax_amount).sum::<f64>())}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-soft border-0">
|
||||
<div class="card-header bg-white border-bottom-0 py-3">
|
||||
<h5 class="mb-0 fw-bold">{"Tax Actions"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-3">
|
||||
<button class="btn btn-primary" onclick={
|
||||
Callback::from(move |_| {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Tax report generation feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-file-earmark-pdf me-2"></i>
|
||||
{"Generate Tax Report"}
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" onclick={
|
||||
Callback::from(move |_| {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Tax calculator feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-calculator me-2"></i>
|
||||
{"Tax Calculator"}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick={
|
||||
Callback::from(move |_| {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("Export for accountant feature coming soon!")
|
||||
.unwrap();
|
||||
})
|
||||
}>
|
||||
<i class="bi bi-download me-2"></i>
|
||||
{"Export for Accountant"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
31
platform/src/components/cards/feature_card.rs
Normal file
31
platform/src/components/cards/feature_card.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct FeatureCardProps {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub color_variant: String, // "primary", "success", "info", "warning", "danger"
|
||||
}
|
||||
|
||||
#[function_component(FeatureCard)]
|
||||
pub fn feature_card(props: &FeatureCardProps) -> Html {
|
||||
let header_class = format!("card-header py-2 bg-{} bg-opacity-10 border-{}",
|
||||
props.color_variant, props.color_variant);
|
||||
let title_class = format!("mb-0 text-{}", props.color_variant);
|
||||
let icon_class = format!("bi {} me-2", props.icon);
|
||||
|
||||
html! {
|
||||
<div class="card shadow mb-3" style={format!("border-color: var(--bs-{});", props.color_variant)}>
|
||||
<div class={header_class}>
|
||||
<h6 class={title_class}>
|
||||
<i class={icon_class}></i>
|
||||
{&props.title}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-2 compact-card">
|
||||
<p class="card-text small">{&props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
3
platform/src/components/cards/mod.rs
Normal file
3
platform/src/components/cards/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod feature_card;
|
||||
|
||||
pub use feature_card::*;
|
||||
3
platform/src/components/common/mod.rs
Normal file
3
platform/src/components/common/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod multi_step_form;
|
||||
|
||||
pub use multi_step_form::*;
|
||||
294
platform/src/components/common/multi_step_form.rs
Normal file
294
platform/src/components/common/multi_step_form.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::timers::callback::Timeout;
|
||||
use web_sys::console;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MultiStepFormProps<T: Clone + PartialEq + 'static> {
|
||||
pub form_data: T,
|
||||
pub current_step: u8,
|
||||
pub total_steps: u8,
|
||||
pub step_titles: Vec<String>,
|
||||
pub step_descriptions: Vec<String>,
|
||||
pub step_icons: Vec<String>,
|
||||
pub on_form_update: Callback<T>,
|
||||
pub on_step_change: Callback<u8>,
|
||||
pub on_validation_request: Callback<(u8, Callback<ValidationResult>)>,
|
||||
pub on_back_to_parent: Callback<()>,
|
||||
pub validation_errors: Vec<String>,
|
||||
pub show_validation_toast: bool,
|
||||
pub children: Children,
|
||||
#[prop_or_default]
|
||||
pub custom_footer: Option<Html>,
|
||||
#[prop_or_default]
|
||||
pub disable_navigation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ValidationResult {
|
||||
pub is_valid: bool,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
pub fn valid() -> Self {
|
||||
Self {
|
||||
is_valid: true,
|
||||
errors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid(errors: Vec<String>) -> Self {
|
||||
Self {
|
||||
is_valid: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum MultiStepFormMsg {
|
||||
NextStep,
|
||||
PrevStep,
|
||||
SetStep(u8),
|
||||
ValidationResult(ValidationResult),
|
||||
HideValidationToast,
|
||||
}
|
||||
|
||||
pub struct MultiStepForm<T: Clone + PartialEq + 'static> {
|
||||
_phantom: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T: Clone + PartialEq + 'static> Component for MultiStepForm<T> {
|
||||
type Message = MultiStepFormMsg;
|
||||
type Properties = MultiStepFormProps<T>;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
_phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
MultiStepFormMsg::NextStep => {
|
||||
let current_step = ctx.props().current_step;
|
||||
let total_steps = ctx.props().total_steps;
|
||||
|
||||
if current_step < total_steps {
|
||||
// Request validation for current step
|
||||
let validation_callback = ctx.link().callback(MultiStepFormMsg::ValidationResult);
|
||||
ctx.props().on_validation_request.emit((current_step, validation_callback));
|
||||
}
|
||||
false
|
||||
}
|
||||
MultiStepFormMsg::PrevStep => {
|
||||
let current_step = ctx.props().current_step;
|
||||
if current_step > 1 {
|
||||
ctx.props().on_step_change.emit(current_step - 1);
|
||||
}
|
||||
false
|
||||
}
|
||||
MultiStepFormMsg::SetStep(step) => {
|
||||
if step >= 1 && step <= ctx.props().total_steps {
|
||||
ctx.props().on_step_change.emit(step);
|
||||
}
|
||||
false
|
||||
}
|
||||
MultiStepFormMsg::ValidationResult(result) => {
|
||||
if result.is_valid {
|
||||
let current_step = ctx.props().current_step;
|
||||
let total_steps = ctx.props().total_steps;
|
||||
if current_step < total_steps {
|
||||
ctx.props().on_step_change.emit(current_step + 1);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
MultiStepFormMsg::HideValidationToast => {
|
||||
// This will be handled by parent component
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let props = ctx.props();
|
||||
let current_step = props.current_step;
|
||||
let total_steps = props.total_steps;
|
||||
|
||||
let (step_title, step_description, step_icon) = self.get_step_info(ctx);
|
||||
|
||||
html! {
|
||||
<div class="card" style="height: calc(100vh - 200px); display: flex; flex-direction: column;">
|
||||
<div class="card-header flex-shrink-0">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-1">
|
||||
<i class={format!("bi {} me-2", step_icon)}></i>{step_title}
|
||||
</h5>
|
||||
<p class="text-muted mb-0 small">{step_description}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm ms-3"
|
||||
onclick={props.on_back_to_parent.reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body flex-grow-1 overflow-auto">
|
||||
<form>
|
||||
{for props.children.iter()}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{if let Some(custom_footer) = &props.custom_footer {
|
||||
custom_footer.clone()
|
||||
} else {
|
||||
self.render_default_footer(ctx)
|
||||
}}
|
||||
|
||||
{if props.show_validation_toast {
|
||||
self.render_validation_toast(ctx)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + PartialEq + 'static> MultiStepForm<T> {
|
||||
fn get_step_info(&self, ctx: &Context<Self>) -> (String, String, String) {
|
||||
let props = ctx.props();
|
||||
let current_step = props.current_step as usize;
|
||||
|
||||
let title = props.step_titles.get(current_step.saturating_sub(1))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Step {}", current_step));
|
||||
|
||||
let description = props.step_descriptions.get(current_step.saturating_sub(1))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Complete this step to continue.".to_string());
|
||||
|
||||
let icon = props.step_icons.get(current_step.saturating_sub(1))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "bi-circle".to_string());
|
||||
|
||||
(title, description, icon)
|
||||
}
|
||||
|
||||
fn render_default_footer(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let props = ctx.props();
|
||||
let current_step = props.current_step;
|
||||
let total_steps = props.total_steps;
|
||||
|
||||
html! {
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
// Previous button (left)
|
||||
<div style="width: 120px;">
|
||||
{if current_step > 1 && !props.disable_navigation {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={link.callback(|_| MultiStepFormMsg::PrevStep)}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Previous"}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Step indicator (center)
|
||||
<div class="d-flex align-items-center">
|
||||
{for (1..=total_steps).map(|step| {
|
||||
let is_current = step == current_step;
|
||||
let is_completed = step < current_step;
|
||||
let step_class = if is_current {
|
||||
"bg-primary text-white"
|
||||
} else if is_completed {
|
||||
"bg-success text-white"
|
||||
} else {
|
||||
"bg-white text-muted border"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={format!("rounded-circle d-flex align-items-center justify-content-center {} fw-bold", step_class)}
|
||||
style="width: 28px; height: 28px; font-size: 12px;">
|
||||
{if is_completed {
|
||||
html! { <i class="bi bi-check"></i> }
|
||||
} else {
|
||||
html! { {step} }
|
||||
}}
|
||||
</div>
|
||||
{if step < total_steps {
|
||||
html! {
|
||||
<div class={format!("mx-1 {}", if is_completed { "bg-success" } else { "bg-secondary" })}
|
||||
style="height: 2px; width: 24px;"></div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
// Next button (right)
|
||||
<div style="width: 120px;" class="text-end">
|
||||
{if current_step < total_steps && !props.disable_navigation {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={link.callback(|_| MultiStepFormMsg::NextStep)}
|
||||
>
|
||||
{"Next"}<i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_validation_toast(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = ctx.props();
|
||||
|
||||
html! {
|
||||
<div class="position-fixed bottom-0 start-50 translate-middle-x mb-3" style="z-index: 1055; max-width: 500px;">
|
||||
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header bg-warning text-dark">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong class="me-auto">{"Required Fields Missing"}</strong>
|
||||
<button type="button" class="btn-close" onclick={ctx.link().callback(|_| MultiStepFormMsg::HideValidationToast)} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div class="mb-2">
|
||||
<strong>{"Please complete all required fields to continue:"}</strong>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{for props.validation_errors.iter().map(|error| {
|
||||
html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-dot text-danger me-1"></i>{error}
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
62
platform/src/components/empty_state.rs
Normal file
62
platform/src/components/empty_state.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EmptyStateProps {
|
||||
pub icon: String, // Bootstrap icon class (e.g., "bi-building")
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[prop_or_default]
|
||||
pub primary_action: Option<(String, String)>, // (label, href/onclick)
|
||||
#[prop_or_default]
|
||||
pub secondary_action: Option<(String, String)>, // (label, href/onclick)
|
||||
}
|
||||
|
||||
#[function_component(EmptyState)]
|
||||
pub fn empty_state(props: &EmptyStateProps) -> Html {
|
||||
html! {
|
||||
<div class="card border-0">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class={classes!("bi", props.icon.clone(), "display-1", "text-muted")}></i>
|
||||
</div>
|
||||
<h3 class="text-muted mb-3">{&props.title}</h3>
|
||||
<p class="lead text-muted mb-4">
|
||||
{&props.description}
|
||||
</p>
|
||||
|
||||
if props.primary_action.is_some() || props.secondary_action.is_some() {
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="row g-3">
|
||||
if let Some((label, action)) = &props.primary_action {
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 border-primary">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-plus-circle text-primary fs-2 mb-2"></i>
|
||||
<h6 class="card-title">{label}</h6>
|
||||
<p class="card-text small text-muted">{"Get started with your first item"}</p>
|
||||
<a href={action.clone()} class="btn btn-primary btn-sm">{"Get Started"}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
if let Some((label, action)) = &props.secondary_action {
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 border-success">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-question-circle text-success fs-2 mb-2"></i>
|
||||
<h6 class="card-title">{label}</h6>
|
||||
<p class="card-text small text-muted">{"Learn how to use the system"}</p>
|
||||
<button class="btn btn-outline-success btn-sm">{"Learn More"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
101
platform/src/components/entities/companies_list.rs
Normal file
101
platform/src/components/entities/companies_list.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use yew::prelude::*;
|
||||
use crate::models::*;
|
||||
use crate::services::CompanyService;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CompaniesListProps {
|
||||
pub companies: Vec<Company>,
|
||||
pub on_view_company: Callback<u32>,
|
||||
pub on_switch_to_entity: Callback<u32>,
|
||||
}
|
||||
|
||||
#[function_component(CompaniesList)]
|
||||
pub fn companies_list(props: &CompaniesListProps) -> Html {
|
||||
let companies = &props.companies;
|
||||
|
||||
if companies.is_empty() {
|
||||
html! {
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-building display-4 text-muted mb-3"></i>
|
||||
<h4 class="text-muted">{"No Companies Found"}</h4>
|
||||
<p class="text-muted">{"You haven't registered any companies yet. Get started by registering your first company."}</p>
|
||||
<button class="btn btn-primary" onclick={Callback::from(|_| {
|
||||
// This will be handled by the parent component to switch tabs
|
||||
})}>
|
||||
<i class="bi bi-plus-circle me-1"></i>{"Register Your First Company"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<i class="bi bi-building me-1"></i>{" Your Companies"}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Name"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Date Registered"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for companies.iter().map(|company| {
|
||||
let company_id = company.id;
|
||||
let on_view = {
|
||||
let on_view_company = props.on_view_company.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_view_company.emit(company_id);
|
||||
})
|
||||
};
|
||||
let on_switch = {
|
||||
let on_switch_to_entity = props.on_switch_to_entity.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_switch_to_entity.emit(company_id);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<tr key={company.id}>
|
||||
<td>{&company.name}</td>
|
||||
<td>{company.company_type.to_string()}</td>
|
||||
<td>
|
||||
<span class={company.status.get_badge_class()}>
|
||||
{company.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td>{&company.incorporation_date}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
onclick={on_view}
|
||||
title="View company details"
|
||||
>
|
||||
<i class="bi bi-eye"></i>{" View"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
onclick={on_switch}
|
||||
title="Switch to this entity"
|
||||
>
|
||||
<i class="bi bi-box-arrow-in-right"></i>{" Switch to Entity"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
17
platform/src/components/entities/company_registration/mod.rs
Normal file
17
platform/src/components/entities/company_registration/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
pub mod registration_wizard;
|
||||
pub mod step_one;
|
||||
pub mod step_two;
|
||||
pub mod step_two_combined;
|
||||
pub mod step_three;
|
||||
pub mod step_four;
|
||||
pub mod step_five;
|
||||
pub mod progress_indicator;
|
||||
|
||||
pub use registration_wizard::*;
|
||||
pub use step_one::*;
|
||||
pub use step_two::*;
|
||||
pub use step_two_combined::*;
|
||||
pub use step_three::*;
|
||||
pub use step_four::*;
|
||||
pub use step_five::*;
|
||||
pub use progress_indicator::*;
|
||||
@@ -0,0 +1,58 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ProgressIndicatorProps {
|
||||
pub current_step: u8,
|
||||
pub total_steps: u8,
|
||||
}
|
||||
|
||||
#[function_component(ProgressIndicator)]
|
||||
pub fn progress_indicator(props: &ProgressIndicatorProps) -> Html {
|
||||
let percentage = (props.current_step as f32 / props.total_steps as f32) * 100.0;
|
||||
|
||||
html! {
|
||||
<>
|
||||
// Progress bar
|
||||
<div class="progress mb-4">
|
||||
<div
|
||||
class="progress-bar bg-success"
|
||||
role="progressbar"
|
||||
style={format!("width: {}%", percentage)}
|
||||
aria-valuenow={percentage.to_string()}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
{format!("Step {} of {}", props.current_step, props.total_steps)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Step indicators
|
||||
<div class="d-flex justify-content-between mb-4">
|
||||
{for (1..=props.total_steps).map(|step| {
|
||||
let is_active = step == props.current_step;
|
||||
let is_completed = step < props.current_step;
|
||||
|
||||
let badge_class = if is_completed || is_active {
|
||||
"badge rounded-pill bg-success"
|
||||
} else {
|
||||
"badge rounded-pill bg-secondary"
|
||||
};
|
||||
|
||||
let step_name = match step {
|
||||
1 => "General Info",
|
||||
2 => "Company Type",
|
||||
3 => "Shareholders",
|
||||
4 => "Payment",
|
||||
_ => "Step",
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class={classes!("step-indicator", if is_active { "active" } else { "" })} id={format!("step-indicator-{}", step)}>
|
||||
<span class={badge_class}>{step}</span>{format!(" {}", step_name)}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::timers::callback::Timeout;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{console, js_sys};
|
||||
use serde_json::json;
|
||||
use crate::models::*;
|
||||
use crate::services::{CompanyService, CompanyRegistration, RegistrationStatus};
|
||||
use super::{ProgressIndicator, StepOne, StepTwoCombined, StepFive};
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = window)]
|
||||
fn createPaymentIntent(form_data: &JsValue) -> js_sys::Promise;
|
||||
|
||||
#[wasm_bindgen(js_namespace = window)]
|
||||
fn initializeStripeElements(client_secret: &str) -> js_sys::Promise;
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RegistrationWizardProps {
|
||||
pub on_registration_complete: Callback<Company>,
|
||||
pub on_back_to_companies: Callback<()>,
|
||||
#[prop_or_default]
|
||||
pub success_company_id: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub show_failure: bool,
|
||||
#[prop_or_default]
|
||||
pub force_fresh_start: bool,
|
||||
#[prop_or_default]
|
||||
pub continue_registration: Option<CompanyFormData>,
|
||||
#[prop_or_default]
|
||||
pub continue_step: Option<u8>,
|
||||
}
|
||||
|
||||
pub enum RegistrationMsg {
|
||||
NextStep,
|
||||
PrevStep,
|
||||
UpdateFormData(CompanyFormData),
|
||||
SetStep(u8),
|
||||
AutoSave,
|
||||
LoadSavedData,
|
||||
ClearSavedData,
|
||||
CreatePaymentIntent,
|
||||
PaymentIntentCreated(String),
|
||||
PaymentIntentError(String),
|
||||
ProcessPayment,
|
||||
PaymentComplete(Company),
|
||||
PaymentError(String),
|
||||
ShowValidationToast(Vec<String>),
|
||||
HideValidationToast,
|
||||
PaymentPlanChanged(PaymentPlan),
|
||||
RetryPayment,
|
||||
ConfirmationChanged(bool),
|
||||
}
|
||||
|
||||
pub struct RegistrationWizard {
|
||||
current_step: u8,
|
||||
form_data: CompanyFormData,
|
||||
validation_errors: Vec<String>,
|
||||
auto_save_timeout: Option<Timeout>,
|
||||
processing_payment: bool,
|
||||
show_validation_toast: bool,
|
||||
client_secret: Option<String>,
|
||||
confirmation_checked: bool,
|
||||
current_registration_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Component for RegistrationWizard {
|
||||
type Message = RegistrationMsg;
|
||||
type Properties = RegistrationWizardProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
// Determine initial step based on props
|
||||
let (form_data, current_step) = if ctx.props().success_company_id.is_some() {
|
||||
// Show success step
|
||||
(CompanyFormData::default(), 4)
|
||||
} else if ctx.props().show_failure {
|
||||
// Show failure, go back to payment step
|
||||
let (form_data, _) = CompanyService::load_registration_form()
|
||||
.unwrap_or_else(|| (CompanyFormData::default(), 3));
|
||||
(form_data, 3)
|
||||
} else if ctx.props().force_fresh_start {
|
||||
// Force fresh start - clear any saved data and start from step 1
|
||||
let _ = CompanyService::clear_registration_form();
|
||||
(CompanyFormData::default(), 1)
|
||||
} else if let (Some(continue_form_data), Some(continue_step)) = (&ctx.props().continue_registration, ctx.props().continue_step) {
|
||||
// Continue existing registration - adjust step numbers for merged steps
|
||||
let adjusted_step = match continue_step {
|
||||
1 => 1, // Step 1 remains the same
|
||||
2 | 3 => 2, // Steps 2 and 3 are now merged into step 2
|
||||
4 => 3, // Step 4 becomes step 3 (payment)
|
||||
_ => 1, // Default to step 1 for any other case
|
||||
};
|
||||
(continue_form_data.clone(), adjusted_step)
|
||||
} else {
|
||||
// Normal flow - try to load saved form data
|
||||
let (form_data, saved_step) = CompanyService::load_registration_form()
|
||||
.unwrap_or_else(|| (CompanyFormData::default(), 1));
|
||||
// Adjust step numbers for merged steps
|
||||
let adjusted_step = match saved_step {
|
||||
1 => 1, // Step 1 remains the same
|
||||
2 | 3 => 2, // Steps 2 and 3 are now merged into step 2
|
||||
4 => 3, // Step 4 becomes step 3 (payment)
|
||||
_ => 1, // Default to step 1 for any other case
|
||||
};
|
||||
(form_data, adjusted_step)
|
||||
};
|
||||
|
||||
// Auto-save every 2 seconds after changes
|
||||
let link = ctx.link().clone();
|
||||
let auto_save_timeout = Some(Timeout::new(2000, move || {
|
||||
link.send_message(RegistrationMsg::AutoSave);
|
||||
}));
|
||||
|
||||
Self {
|
||||
current_step,
|
||||
form_data,
|
||||
validation_errors: Vec::new(),
|
||||
auto_save_timeout,
|
||||
processing_payment: false,
|
||||
show_validation_toast: false,
|
||||
client_secret: None,
|
||||
confirmation_checked: false,
|
||||
current_registration_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
RegistrationMsg::NextStep => {
|
||||
// Validate current step
|
||||
let validation_result = CompanyService::validate_step(&self.form_data, self.current_step);
|
||||
if !validation_result.is_valid {
|
||||
self.validation_errors = validation_result.errors;
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(RegistrationMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.current_step < 4 {
|
||||
self.current_step += 1;
|
||||
self.auto_save();
|
||||
|
||||
// If moving to step 3 (payment), create payment intent
|
||||
if self.current_step == 3 {
|
||||
ctx.link().send_message(RegistrationMsg::CreatePaymentIntent);
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
RegistrationMsg::PrevStep => {
|
||||
if self.current_step > 1 {
|
||||
self.current_step -= 1;
|
||||
self.auto_save();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
RegistrationMsg::UpdateFormData(new_form_data) => {
|
||||
self.form_data = new_form_data;
|
||||
self.schedule_auto_save(ctx);
|
||||
true
|
||||
}
|
||||
RegistrationMsg::SetStep(step) => {
|
||||
if step >= 1 && step <= 4 {
|
||||
self.current_step = step;
|
||||
|
||||
// If moving to step 3 (payment), create payment intent
|
||||
if step == 3 && self.client_secret.is_none() {
|
||||
ctx.link().send_message(RegistrationMsg::CreatePaymentIntent);
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
RegistrationMsg::AutoSave => {
|
||||
self.auto_save();
|
||||
false
|
||||
}
|
||||
RegistrationMsg::LoadSavedData => {
|
||||
if let Some((form_data, step)) = CompanyService::load_registration_form() {
|
||||
self.form_data = form_data;
|
||||
self.current_step = step;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
RegistrationMsg::ClearSavedData => {
|
||||
let _ = CompanyService::clear_registration_form();
|
||||
self.form_data = CompanyFormData::default();
|
||||
self.current_step = 1;
|
||||
self.client_secret = None;
|
||||
true
|
||||
}
|
||||
RegistrationMsg::CreatePaymentIntent => {
|
||||
console::log_1(&"🔧 Creating payment intent for step 5...".into());
|
||||
self.create_payment_intent(ctx);
|
||||
false
|
||||
}
|
||||
RegistrationMsg::PaymentIntentCreated(client_secret) => {
|
||||
console::log_1(&"✅ Payment intent created, initializing Stripe Elements...".into());
|
||||
self.client_secret = Some(client_secret.clone());
|
||||
self.initialize_stripe_elements(&client_secret);
|
||||
true
|
||||
}
|
||||
RegistrationMsg::PaymentIntentError(error) => {
|
||||
console::log_1(&format!("❌ Payment intent creation failed: {}", error).into());
|
||||
self.validation_errors = vec![format!("Payment setup failed: {}", error)];
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(RegistrationMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
RegistrationMsg::ProcessPayment => {
|
||||
self.processing_payment = true;
|
||||
|
||||
// Simulate payment processing (in real app, this would integrate with Stripe)
|
||||
let link = ctx.link().clone();
|
||||
let form_data = self.form_data.clone();
|
||||
let registration_id = self.current_registration_id;
|
||||
|
||||
Timeout::new(2000, move || {
|
||||
// Create company and update registration status
|
||||
match CompanyService::create_company_from_form(&form_data) {
|
||||
Ok(company) => {
|
||||
// Update registration status to PendingApproval
|
||||
if let Some(reg_id) = registration_id {
|
||||
let mut registrations = CompanyService::get_registrations();
|
||||
if let Some(registration) = registrations.iter_mut().find(|r| r.id == reg_id) {
|
||||
registration.status = RegistrationStatus::PendingApproval;
|
||||
let _ = CompanyService::save_registrations(®istrations);
|
||||
}
|
||||
} else {
|
||||
// Create new registration if none exists
|
||||
let now = js_sys::Date::new_0();
|
||||
let created_at = format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
now.get_full_year(),
|
||||
now.get_month() + 1,
|
||||
now.get_date()
|
||||
);
|
||||
|
||||
let registration = CompanyRegistration {
|
||||
id: 0, // Will be set by save_registration
|
||||
company_name: form_data.company_name.clone(),
|
||||
company_type: form_data.company_type.clone(),
|
||||
status: RegistrationStatus::PendingApproval,
|
||||
created_at,
|
||||
form_data: form_data.clone(),
|
||||
current_step: 5, // Completed
|
||||
};
|
||||
|
||||
let _ = CompanyService::save_registration(registration);
|
||||
}
|
||||
|
||||
link.send_message(RegistrationMsg::PaymentComplete(company));
|
||||
}
|
||||
Err(error) => {
|
||||
link.send_message(RegistrationMsg::PaymentError(error));
|
||||
}
|
||||
}
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
RegistrationMsg::PaymentComplete(company) => {
|
||||
self.processing_payment = false;
|
||||
// Move to success step instead of clearing immediately
|
||||
self.current_step = 4;
|
||||
// Clear saved form data
|
||||
let _ = CompanyService::clear_registration_form();
|
||||
// Notify parent component
|
||||
ctx.props().on_registration_complete.emit(company);
|
||||
true
|
||||
}
|
||||
RegistrationMsg::PaymentError(error) => {
|
||||
self.processing_payment = false;
|
||||
// Stay on payment step and show error
|
||||
self.validation_errors = vec![format!("Payment failed: {}", error)];
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(RegistrationMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
RegistrationMsg::RetryPayment => {
|
||||
// Clear errors and try payment again
|
||||
self.validation_errors.clear();
|
||||
self.show_validation_toast = false;
|
||||
// Reset client secret to force new payment intent
|
||||
self.client_secret = None;
|
||||
ctx.link().send_message(RegistrationMsg::CreatePaymentIntent);
|
||||
true
|
||||
}
|
||||
RegistrationMsg::ShowValidationToast(errors) => {
|
||||
self.validation_errors = errors;
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(RegistrationMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
RegistrationMsg::HideValidationToast => {
|
||||
self.show_validation_toast = false;
|
||||
true
|
||||
}
|
||||
RegistrationMsg::PaymentPlanChanged(plan) => {
|
||||
console::log_1(&format!("💳 Payment plan changed to: {}", plan.get_display_name()).into());
|
||||
|
||||
// Update form data with new payment plan
|
||||
self.form_data.payment_plan = plan;
|
||||
|
||||
// Clear existing client secret to force new payment intent creation
|
||||
self.client_secret = None;
|
||||
|
||||
// Create new payment intent with updated plan
|
||||
ctx.link().send_message(RegistrationMsg::CreatePaymentIntent);
|
||||
|
||||
// Auto-save the updated form data
|
||||
self.auto_save();
|
||||
|
||||
true
|
||||
}
|
||||
RegistrationMsg::ConfirmationChanged(checked) => {
|
||||
self.confirmation_checked = checked;
|
||||
console::log_1(&format!("📋 Confirmation state updated: {}", checked).into());
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let (step_title, step_description, step_icon) = self.get_step_info();
|
||||
|
||||
html! {
|
||||
<div class="card" style="height: calc(100vh - 200px); display: flex; flex-direction: column;">
|
||||
<div class="card-header flex-shrink-0">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-1">
|
||||
<i class={format!("bi {} me-2", step_icon)}></i>{step_title}
|
||||
</h5>
|
||||
<p class="text-muted mb-0 small">{step_description}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm ms-3"
|
||||
onclick={ctx.props().on_back_to_companies.reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back to Companies"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body flex-grow-1 overflow-auto">
|
||||
<form id="companyRegistrationForm">
|
||||
{self.render_current_step(ctx)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{if self.current_step <= 3 {
|
||||
self.render_footer_navigation(ctx)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if self.show_validation_toast {
|
||||
self.render_validation_toast(ctx)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistrationWizard {
|
||||
fn render_current_step(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let form_data = self.form_data.clone();
|
||||
let on_form_update = link.callback(RegistrationMsg::UpdateFormData);
|
||||
|
||||
match self.current_step {
|
||||
1 => html! {
|
||||
<StepOne
|
||||
form_data={form_data}
|
||||
on_form_update={on_form_update}
|
||||
/>
|
||||
},
|
||||
2 => html! {
|
||||
<StepTwoCombined
|
||||
form_data={form_data}
|
||||
on_form_update={on_form_update}
|
||||
/>
|
||||
},
|
||||
3 => html! {
|
||||
<StepFive
|
||||
form_data={form_data}
|
||||
client_secret={self.client_secret.clone()}
|
||||
processing_payment={self.processing_payment}
|
||||
on_process_payment={link.callback(|_| RegistrationMsg::ProcessPayment)}
|
||||
on_payment_complete={link.callback(RegistrationMsg::PaymentComplete)}
|
||||
on_payment_error={link.callback(RegistrationMsg::PaymentError)}
|
||||
on_payment_plan_change={link.callback(RegistrationMsg::PaymentPlanChanged)}
|
||||
on_confirmation_change={link.callback(RegistrationMsg::ConfirmationChanged)}
|
||||
/>
|
||||
},
|
||||
4 => {
|
||||
// Success step
|
||||
self.render_success_step(ctx)
|
||||
},
|
||||
_ => html! { <div>{"Invalid step"}</div> }
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer_navigation(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
// Previous button (left)
|
||||
<div style="width: 120px;">
|
||||
{if self.current_step > 1 {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={link.callback(|_| RegistrationMsg::PrevStep)}
|
||||
disabled={self.processing_payment}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Previous"}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Step indicator (center)
|
||||
<div class="d-flex align-items-center">
|
||||
{for (1..=3).map(|step| {
|
||||
let is_current = step == self.current_step;
|
||||
let is_completed = step < self.current_step;
|
||||
let step_class = if is_current {
|
||||
"bg-primary text-white"
|
||||
} else if is_completed {
|
||||
"bg-success text-white"
|
||||
} else {
|
||||
"bg-white text-muted border"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={format!("rounded-circle d-flex align-items-center justify-content-center {} fw-bold", step_class)}
|
||||
style="width: 28px; height: 28px; font-size: 12px;">
|
||||
{if is_completed {
|
||||
html! { <i class="bi bi-check"></i> }
|
||||
} else {
|
||||
html! { {step} }
|
||||
}}
|
||||
</div>
|
||||
{if step < 3 {
|
||||
html! {
|
||||
<div class={format!("mx-1 {}", if is_completed { "bg-success" } else { "bg-secondary" })}
|
||||
style="height: 2px; width: 24px;"></div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
// Next/Payment button (right)
|
||||
<div style="width: 150px;" class="text-end">
|
||||
{if self.current_step < 3 {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={link.callback(|_| RegistrationMsg::NextStep)}
|
||||
disabled={self.processing_payment}
|
||||
>
|
||||
{"Next"}<i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
}
|
||||
} else if self.current_step == 3 {
|
||||
// Payment button for step 3
|
||||
let has_client_secret = self.client_secret.is_some();
|
||||
let can_process_payment = has_client_secret && !self.processing_payment && self.confirmation_checked;
|
||||
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success text-nowrap"
|
||||
id="submit-payment"
|
||||
disabled={!can_process_payment}
|
||||
onclick={link.callback(|_| RegistrationMsg::ProcessPayment)}
|
||||
>
|
||||
{if self.processing_payment {
|
||||
html! {
|
||||
<>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<span>{"Processing..."}</span>
|
||||
</>
|
||||
}
|
||||
} else if has_client_secret {
|
||||
html! {
|
||||
<>
|
||||
<i class="bi bi-credit-card me-2"></i>
|
||||
<span>{"Pay Now"}</span>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<span>{"Preparing..."}</span>
|
||||
</>
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_validation_toast(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let close_toast = link.callback(|_| RegistrationMsg::HideValidationToast);
|
||||
|
||||
html! {
|
||||
<div class="position-fixed bottom-0 start-50 translate-middle-x mb-3" style="z-index: 1055; max-width: 500px;">
|
||||
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header bg-warning text-dark">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong class="me-auto">{"Required Fields Missing"}</strong>
|
||||
<button type="button" class="btn-close" onclick={close_toast} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div class="mb-2">
|
||||
<strong>{"Please complete all required fields to continue:"}</strong>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{for self.validation_errors.iter().map(|error| {
|
||||
html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-dot text-danger me-1"></i>{error}
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_auto_save(&mut self, ctx: &Context<Self>) {
|
||||
// Cancel existing timeout
|
||||
self.auto_save_timeout = None;
|
||||
|
||||
// Schedule new auto-save
|
||||
let link = ctx.link().clone();
|
||||
self.auto_save_timeout = Some(Timeout::new(2000, move || {
|
||||
link.send_message(RegistrationMsg::AutoSave);
|
||||
}));
|
||||
}
|
||||
|
||||
fn auto_save(&mut self) {
|
||||
// Save form data to localStorage for recovery
|
||||
let _ = CompanyService::save_registration_form(&self.form_data, self.current_step);
|
||||
|
||||
// Also save as a draft registration
|
||||
let now = js_sys::Date::new_0();
|
||||
let created_at = format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
now.get_full_year(),
|
||||
now.get_month() + 1,
|
||||
now.get_date()
|
||||
);
|
||||
|
||||
let status = if self.current_step >= 3 {
|
||||
RegistrationStatus::PendingPayment
|
||||
} else {
|
||||
RegistrationStatus::Draft
|
||||
};
|
||||
|
||||
let registration = CompanyRegistration {
|
||||
id: self.current_registration_id.unwrap_or(0),
|
||||
company_name: if self.form_data.company_name.is_empty() {
|
||||
"Draft Registration".to_string()
|
||||
} else {
|
||||
self.form_data.company_name.clone()
|
||||
},
|
||||
company_type: self.form_data.company_type.clone(),
|
||||
status,
|
||||
created_at,
|
||||
form_data: self.form_data.clone(),
|
||||
current_step: self.current_step,
|
||||
};
|
||||
|
||||
if let Ok(saved_registration) = CompanyService::save_registration(registration) {
|
||||
self.current_registration_id = Some(saved_registration.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_payment_intent(&self, ctx: &Context<Self>) {
|
||||
let link = ctx.link().clone();
|
||||
let form_data = self.form_data.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match Self::setup_stripe_payment(form_data).await {
|
||||
Ok(client_secret) => {
|
||||
link.send_message(RegistrationMsg::PaymentIntentCreated(client_secret));
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(RegistrationMsg::PaymentIntentError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn setup_stripe_payment(form_data: CompanyFormData) -> Result<String, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
console::log_1(&"🔧 Setting up Stripe payment for company registration".into());
|
||||
console::log_1(&format!("📋 Company: {} ({})", form_data.company_name, form_data.company_type.to_string()).into());
|
||||
console::log_1(&format!("💳 Payment plan: {}", form_data.payment_plan.to_string()).into());
|
||||
|
||||
// Prepare form data for payment intent creation
|
||||
// Note: For payment intent creation, we set final_agreement to true since the actual
|
||||
// confirmation is now handled by the confirmation checkbox in the UI
|
||||
let payment_data = json!({
|
||||
"company_name": form_data.company_name,
|
||||
"company_type": form_data.company_type.to_string(),
|
||||
"company_email": form_data.company_email,
|
||||
"company_phone": form_data.company_phone,
|
||||
"company_website": form_data.company_website,
|
||||
"company_address": form_data.company_address,
|
||||
"company_industry": form_data.company_industry,
|
||||
"company_purpose": form_data.company_purpose,
|
||||
"fiscal_year_end": form_data.fiscal_year_end,
|
||||
"shareholders": serde_json::to_string(&form_data.shareholders).unwrap_or_default(),
|
||||
"payment_plan": form_data.payment_plan.to_string(),
|
||||
"agreements": vec!["terms", "privacy", "compliance", "articles"],
|
||||
"final_agreement": true
|
||||
});
|
||||
|
||||
console::log_1(&"📡 Calling JavaScript createPaymentIntent function".into());
|
||||
let js_value = JsValue::from_str(&payment_data.to_string());
|
||||
|
||||
// Call JavaScript function to create payment intent
|
||||
let promise = createPaymentIntent(&js_value);
|
||||
let result = JsFuture::from(promise).await
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Payment intent creation failed: {:?}", e);
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
// Extract client secret from result
|
||||
let client_secret = result.as_string()
|
||||
.ok_or_else(|| {
|
||||
let error_msg = "Invalid client secret received from server";
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
error_msg.to_string()
|
||||
})?;
|
||||
|
||||
console::log_1(&"✅ Payment intent created successfully".into());
|
||||
console::log_1(&format!("🔑 Client secret received: {}", if client_secret.len() > 10 { "Yes" } else { "No" }).into());
|
||||
Ok(client_secret)
|
||||
}
|
||||
|
||||
fn initialize_stripe_elements(&self, client_secret: &str) {
|
||||
console::log_1(&"🔧 Initializing Stripe Elements for payment form".into());
|
||||
console::log_1(&format!("🔑 Client secret length: {}", client_secret.len()).into());
|
||||
|
||||
spawn_local({
|
||||
let client_secret = client_secret.to_string();
|
||||
async move {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
// Call JavaScript function to initialize Stripe Elements
|
||||
let promise = initializeStripeElements(&client_secret);
|
||||
match JsFuture::from(promise).await {
|
||||
Ok(_) => {
|
||||
console::log_1(&"✅ Stripe Elements initialized successfully".into());
|
||||
console::log_1(&"💳 Payment form should now be visible in the UI".into());
|
||||
}
|
||||
Err(e) => {
|
||||
console::log_1(&format!("❌ Stripe Elements initialization failed: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn get_step_info(&self) -> (&'static str, &'static str, &'static str) {
|
||||
match self.current_step {
|
||||
1 => (
|
||||
"Company Information & Type",
|
||||
"Provide basic company information and select your company type and structure.",
|
||||
"bi-building"
|
||||
),
|
||||
2 => (
|
||||
"Shareholders & Documents",
|
||||
"Add shareholders, select bylaw template, and review generated legal documents.",
|
||||
"bi-people-fill"
|
||||
),
|
||||
3 => (
|
||||
"Payment Plan & Processing",
|
||||
"Select your payment plan and complete the payment to finalize your company registration.",
|
||||
"bi-credit-card"
|
||||
),
|
||||
4 => (
|
||||
"Registration Complete",
|
||||
"Your company registration has been successfully completed.",
|
||||
"bi-check-circle-fill"
|
||||
),
|
||||
_ => (
|
||||
"Company Registration",
|
||||
"Complete the registration process for your new company.",
|
||||
"bi-file-earmark-plus"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_success_step(&self, ctx: &Context<Self>) -> Html {
|
||||
let company_id = ctx.props().success_company_id.unwrap_or(1); // Default to 1 if not provided
|
||||
|
||||
html! {
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="text-success mb-3">{"Registration Successful!"}</h2>
|
||||
<p class="lead mb-4">
|
||||
{"Your company has been successfully registered and is now pending approval."}
|
||||
</p>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card border-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success">
|
||||
<i class="bi bi-info-circle me-2"></i>{"What happens next?"}
|
||||
</h5>
|
||||
<div class="text-start">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-success rounded-pill">{"1"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Document Review"}</strong>
|
||||
<p class="mb-0 text-muted">{"Our team will review your submitted documents and information."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-primary rounded-pill">{"2"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Compliance Check"}</strong>
|
||||
<p class="mb-0 text-muted">{"We'll verify compliance with local regulations and requirements."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-info rounded-pill">{"3"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Approval & Activation"}</strong>
|
||||
<p class="mb-0 text-muted">{"Once approved, your company will be activated and you'll receive your certificate."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
onclick={ctx.props().on_back_to_companies.reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-list me-2"></i>{"Back to Companies"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-envelope me-2"></i>
|
||||
{"You will receive email updates about your registration status. The approval process typically takes 1-3 business days."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{window, console, js_sys};
|
||||
use crate::models::*;
|
||||
use crate::services::CompanyService;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = window)]
|
||||
fn confirmStripePayment(client_secret: &str) -> js_sys::Promise;
|
||||
|
||||
#[wasm_bindgen(js_namespace = window)]
|
||||
fn initializeStripeElements(client_secret: &str);
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepFiveProps {
|
||||
pub form_data: CompanyFormData,
|
||||
pub client_secret: Option<String>,
|
||||
pub processing_payment: bool,
|
||||
pub on_process_payment: Callback<()>,
|
||||
pub on_payment_complete: Callback<Company>,
|
||||
pub on_payment_error: Callback<String>,
|
||||
pub on_payment_plan_change: Callback<PaymentPlan>,
|
||||
pub on_confirmation_change: Callback<bool>,
|
||||
}
|
||||
|
||||
pub enum StepFiveMsg {
|
||||
ProcessPayment,
|
||||
PaymentComplete,
|
||||
PaymentError(String),
|
||||
PaymentPlanChanged(PaymentPlan),
|
||||
ToggleConfirmation,
|
||||
}
|
||||
|
||||
pub struct StepFive {
|
||||
form_data: CompanyFormData,
|
||||
payment_error: Option<String>,
|
||||
selected_payment_plan: PaymentPlan,
|
||||
confirmation_checked: bool,
|
||||
}
|
||||
|
||||
impl Component for StepFive {
|
||||
type Message = StepFiveMsg;
|
||||
type Properties = StepFiveProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
form_data: ctx.props().form_data.clone(),
|
||||
payment_error: None,
|
||||
selected_payment_plan: ctx.props().form_data.payment_plan.clone(),
|
||||
confirmation_checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
StepFiveMsg::ProcessPayment => {
|
||||
if let Some(client_secret) = &ctx.props().client_secret {
|
||||
console::log_1(&"🔄 User clicked 'Complete Payment' - processing with Stripe".into());
|
||||
self.process_stripe_payment(ctx, client_secret.clone());
|
||||
} else {
|
||||
console::log_1(&"❌ No client secret available for payment".into());
|
||||
self.payment_error = Some("Payment not ready. Please try again.".to_string());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
StepFiveMsg::PaymentComplete => {
|
||||
console::log_1(&"✅ Payment completed successfully".into());
|
||||
// Create company from form data with current payment plan
|
||||
let mut updated_form_data = self.form_data.clone();
|
||||
updated_form_data.payment_plan = self.selected_payment_plan.clone();
|
||||
|
||||
match crate::services::CompanyService::create_company_from_form(&updated_form_data) {
|
||||
Ok(company) => {
|
||||
ctx.props().on_payment_complete.emit(company);
|
||||
}
|
||||
Err(e) => {
|
||||
console::log_1(&format!("❌ Failed to create company: {}", e).into());
|
||||
ctx.props().on_payment_error.emit(format!("Failed to create company: {}", e));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
StepFiveMsg::PaymentError(error) => {
|
||||
console::log_1(&format!("❌ Payment failed: {}", error).into());
|
||||
self.payment_error = Some(error.clone());
|
||||
ctx.props().on_payment_error.emit(error);
|
||||
}
|
||||
StepFiveMsg::PaymentPlanChanged(plan) => {
|
||||
console::log_1(&format!("💳 Payment plan changed to: {}", plan.get_display_name()).into());
|
||||
self.selected_payment_plan = plan.clone();
|
||||
self.payment_error = None; // Clear any previous errors
|
||||
|
||||
// Notify parent to create new payment intent
|
||||
ctx.props().on_payment_plan_change.emit(plan);
|
||||
return true;
|
||||
}
|
||||
StepFiveMsg::ToggleConfirmation => {
|
||||
self.confirmation_checked = !self.confirmation_checked;
|
||||
console::log_1(&format!("📋 Confirmation checkbox toggled: {}", self.confirmation_checked).into());
|
||||
// Notify parent of confirmation state change
|
||||
ctx.props().on_confirmation_change.emit(self.confirmation_checked);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
|
||||
self.form_data = ctx.props().form_data.clone();
|
||||
// Update selected payment plan if it changed from parent
|
||||
if self.selected_payment_plan != ctx.props().form_data.payment_plan {
|
||||
self.selected_payment_plan = ctx.props().form_data.payment_plan.clone();
|
||||
}
|
||||
|
||||
// Initialize Stripe Elements if client secret became available
|
||||
if old_props.client_secret.is_none() && ctx.props().client_secret.is_some() {
|
||||
if let Some(client_secret) = &ctx.props().client_secret {
|
||||
initializeStripeElements(client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
// Initialize Stripe Elements if client secret is available
|
||||
if let Some(client_secret) = &ctx.props().client_secret {
|
||||
initializeStripeElements(client_secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let has_client_secret = ctx.props().client_secret.is_some();
|
||||
let can_process_payment = has_client_secret && !ctx.props().processing_payment && self.confirmation_checked;
|
||||
let total_amount = CompanyService::calculate_payment_amount(&self.form_data.company_type, &self.selected_payment_plan);
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
// Compact Registration Summary
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h6 class="text-secondary mb-3">
|
||||
<i class="bi bi-receipt me-2"></i>{"Registration Summary"}
|
||||
</h6>
|
||||
|
||||
<div class="card border-0">
|
||||
<div class="card-body py-3">
|
||||
<div class="row g-2 small">
|
||||
// Row 1: Company basics
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-building text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{&self.form_data.company_name}</div>
|
||||
<div class="text-muted">{self.form_data.company_type.to_string()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-envelope text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{&self.form_data.company_email}</div>
|
||||
<div class="text-muted">{"Email"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-briefcase text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{
|
||||
self.form_data.company_industry.as_ref().unwrap_or(&"Not specified".to_string())
|
||||
}</div>
|
||||
<div class="text-muted">{"Industry"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Row 2: Additional details
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-people text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{format!("{} shareholders", self.form_data.shareholders.len())}</div>
|
||||
<div class="text-muted">{
|
||||
match self.form_data.shareholder_structure {
|
||||
ShareholderStructure::Equal => "Equal ownership",
|
||||
ShareholderStructure::Custom => "Custom ownership",
|
||||
}
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{if let Some(purpose) = &self.form_data.company_purpose {
|
||||
if !purpose.is_empty() {
|
||||
html! {
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-bullseye text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{purpose}</div>
|
||||
<div class="text-muted">{"Purpose"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
{if let Some(fiscal_year) = &self.form_data.fiscal_year_end {
|
||||
if !fiscal_year.is_empty() {
|
||||
html! {
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-calendar-event text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{fiscal_year}</div>
|
||||
<div class="text-muted">{"Fiscal Year End"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Shareholders details (if more than 1)
|
||||
{if self.form_data.shareholders.len() > 1 {
|
||||
html! {
|
||||
<div class="mt-2 pt-2 border-top">
|
||||
<div class="small text-muted mb-1">{"Shareholders:"}</div>
|
||||
<div class="row g-1">
|
||||
{for self.form_data.shareholders.iter().map(|shareholder| {
|
||||
html! {
|
||||
<div class="col-md-6">
|
||||
<span class="fw-bold">{&shareholder.name}</span>
|
||||
<span class="text-muted ms-1">{format!("({}%)", shareholder.percentage)}</span>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Compact Confirmation Checkbox
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning py-2 mb-0">
|
||||
<div class="form-check mb-0">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="registrationConfirmation"
|
||||
checked={self.confirmation_checked}
|
||||
onchange={link.callback(|_| StepFiveMsg::ToggleConfirmation)}
|
||||
/>
|
||||
<label class="form-check-label small" for="registrationConfirmation">
|
||||
<strong>{"I confirm the accuracy of all information and authorize company registration with the selected payment plan."}</strong>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Plans (Left) and Payment Form (Right)
|
||||
<div class="row mb-4">
|
||||
// Payment Plan Selection - Left
|
||||
<div class="col-lg-6 mb-4">
|
||||
<h5 class="text-secondary mb-3">
|
||||
{"Choose Your Payment Plan"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
<div class="row">
|
||||
{self.render_payment_plan_option(ctx, PaymentPlan::Monthly, "Monthly Plan", "Pay monthly with flexibility", "bi-calendar-month")}
|
||||
{self.render_payment_plan_option(ctx, PaymentPlan::Yearly, "Yearly Plan", "Save 20% with annual payments", "bi-calendar-check")}
|
||||
{self.render_payment_plan_option(ctx, PaymentPlan::TwoYear, "2-Year Plan", "Save 40% with 2-year commitment", "bi-calendar2-range")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Form - Right
|
||||
<div class="col-lg-6">
|
||||
<h5 class="text-secondary mb-3">
|
||||
{"Payment Information"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
|
||||
<div class="card" id="payment-information-section">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-shield-check me-2"></i>{"Secure Payment Processing"}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
// Stripe Elements will be mounted here
|
||||
<div id="payment-element" style="min-height: 40px; padding: 10px; border: 1px solid #dee2e6; border-radius: 0.375rem; background-color: #ffffff;">
|
||||
{if ctx.props().processing_payment {
|
||||
html! {
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="text-muted">{"Processing payment..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else if !has_client_secret {
|
||||
html! {
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="text-muted">{"Preparing payment form..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Payment button
|
||||
{if has_client_secret && !ctx.props().processing_payment {
|
||||
html! {
|
||||
<div class="d-grid mt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-lg"
|
||||
disabled={!can_process_payment}
|
||||
onclick={link.callback(|_| StepFiveMsg::ProcessPayment)}
|
||||
>
|
||||
{if self.confirmation_checked {
|
||||
html! {
|
||||
<>
|
||||
<i class="bi bi-credit-card me-2"></i>
|
||||
{format!("Complete Payment - ${:.0}", total_amount)}
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{"Please confirm registration details"}
|
||||
</>
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if let Some(error) = &self.payment_error {
|
||||
html! {
|
||||
<div id="payment-errors" class="alert alert-danger mt-3">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>{"Payment Error: "}</strong>{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div id="payment-errors" class="alert alert-danger mt-3" style="display: none;"></div>
|
||||
}
|
||||
}}
|
||||
|
||||
// Payment info text
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
{"Payment plan: "}{self.selected_payment_plan.get_display_name()}
|
||||
{" with "}{(self.selected_payment_plan.get_discount() * 100.0) as i32}{"% discount"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepFive {
|
||||
fn render_payment_plan_option(&self, ctx: &Context<Self>, plan: PaymentPlan, title: &str, description: &str, icon: &str) -> Html {
|
||||
let link = ctx.link();
|
||||
let is_selected = self.selected_payment_plan == plan;
|
||||
let card_class = if is_selected {
|
||||
"card border-success mb-3"
|
||||
} else {
|
||||
"card border-secondary mb-3"
|
||||
};
|
||||
|
||||
let on_select = link.callback(move |_| StepFiveMsg::PaymentPlanChanged(plan.clone()));
|
||||
|
||||
// Calculate pricing for this plan
|
||||
let total_amount = CompanyService::calculate_payment_amount(&self.form_data.company_type, &plan);
|
||||
let discount_percent = ((1.0 - plan.get_discount()) * 100.0) as i32;
|
||||
|
||||
html! {
|
||||
<div class="col-12">
|
||||
<div class={card_class} style="cursor: pointer;" onclick={on_select}>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class={format!("bi {} fs-3 text-primary me-3", icon)}></i>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="card-title mb-1">{title}</h6>
|
||||
<p class="card-text text-muted mb-0 small">{description}</p>
|
||||
<div class="mt-1">
|
||||
<span class="fw-bold text-success">{format!("${:.0}", total_amount)}</span>
|
||||
{if discount_percent > 0 {
|
||||
html! {
|
||||
<span class="badge bg-success ms-2 small">
|
||||
{format!("{}% OFF", discount_percent)}
|
||||
</span>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{if is_selected {
|
||||
html! {
|
||||
<i class="bi bi-check-circle-fill text-success fs-4"></i>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<i class="bi bi-circle text-muted fs-4"></i>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn process_stripe_payment(&mut self, ctx: &Context<Self>, client_secret: String) {
|
||||
let link = ctx.link().clone();
|
||||
|
||||
// Trigger parent to show processing state
|
||||
ctx.props().on_process_payment.emit(());
|
||||
|
||||
spawn_local(async move {
|
||||
match Self::confirm_payment(&client_secret).await {
|
||||
Ok(_) => {
|
||||
link.send_message(StepFiveMsg::PaymentComplete);
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(StepFiveMsg::PaymentError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn confirm_payment(client_secret: &str) -> Result<(), String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
console::log_1(&"🔄 Confirming payment with Stripe...".into());
|
||||
|
||||
// Call JavaScript function to confirm payment
|
||||
let promise = confirmStripePayment(client_secret);
|
||||
JsFuture::from(promise).await
|
||||
.map_err(|e| format!("Payment confirmation failed: {:?}", e))?;
|
||||
|
||||
console::log_1(&"✅ Payment confirmed successfully".into());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use yew::prelude::*;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepFourProps {
|
||||
pub form_data: CompanyFormData,
|
||||
pub on_form_update: Callback<CompanyFormData>,
|
||||
}
|
||||
|
||||
pub enum StepFourMsg {
|
||||
ToggleTermsAccepted,
|
||||
TogglePrivacyAccepted,
|
||||
ToggleComplianceAccepted,
|
||||
ToggleArticlesAccepted,
|
||||
ToggleFinalAgreementAccepted,
|
||||
}
|
||||
|
||||
pub struct StepFour {
|
||||
form_data: CompanyFormData,
|
||||
}
|
||||
|
||||
impl Component for StepFour {
|
||||
type Message = StepFourMsg;
|
||||
type Properties = StepFourProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
form_data: ctx.props().form_data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
StepFourMsg::ToggleTermsAccepted => {
|
||||
self.form_data.legal_agreements.terms = !self.form_data.legal_agreements.terms;
|
||||
}
|
||||
StepFourMsg::TogglePrivacyAccepted => {
|
||||
self.form_data.legal_agreements.privacy = !self.form_data.legal_agreements.privacy;
|
||||
}
|
||||
StepFourMsg::ToggleComplianceAccepted => {
|
||||
self.form_data.legal_agreements.compliance = !self.form_data.legal_agreements.compliance;
|
||||
}
|
||||
StepFourMsg::ToggleArticlesAccepted => {
|
||||
self.form_data.legal_agreements.articles = !self.form_data.legal_agreements.articles;
|
||||
}
|
||||
StepFourMsg::ToggleFinalAgreementAccepted => {
|
||||
self.form_data.legal_agreements.final_agreement = !self.form_data.legal_agreements.final_agreement;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent of form data changes
|
||||
ctx.props().on_form_update.emit(self.form_data.clone());
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
self.form_data = ctx.props().form_data.clone();
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
// Document Upload Section
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="text-secondary mb-3">
|
||||
{"Required Documents"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>{"Document Requirements"}</strong><br/>
|
||||
{"Please prepare the following documents for upload. All documents must be in PDF format and clearly legible."}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
{"Passport/ID Copy"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" accept=".pdf" />
|
||||
<small class="text-muted">{"Government-issued photo ID"}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
{"Proof of Address"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" accept=".pdf" />
|
||||
<small class="text-muted">{"Utility bill or bank statement (last 3 months)"}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
{"Business Plan"} <span class="text-muted">{"(Optional)"}</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" accept=".pdf" />
|
||||
<small class="text-muted">{"Detailed business plan and projections"}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
{"Financial Statements"} <span class="text-muted">{"(If applicable)"}</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" accept=".pdf" />
|
||||
<small class="text-muted">{"Previous company financial records"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
// Legal Agreements Section
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h5 class="text-secondary mb-3">
|
||||
{"Legal Agreements"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="termsAccepted"
|
||||
checked={self.form_data.legal_agreements.terms}
|
||||
onchange={link.callback(|_| StepFourMsg::ToggleTermsAccepted)}
|
||||
required=true
|
||||
/>
|
||||
<label class="form-check-label" for="termsAccepted">
|
||||
{"I agree to the "}
|
||||
<a href="#" class="text-primary">{"Terms of Service"}</a>
|
||||
{" "} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="privacyAccepted"
|
||||
checked={self.form_data.legal_agreements.privacy}
|
||||
onchange={link.callback(|_| StepFourMsg::TogglePrivacyAccepted)}
|
||||
required=true
|
||||
/>
|
||||
<label class="form-check-label" for="privacyAccepted">
|
||||
{"I acknowledge that I have read and agree to the "}
|
||||
<a href="#" class="text-primary">{"Privacy Policy"}</a>
|
||||
{" "} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="complianceAccepted"
|
||||
checked={self.form_data.legal_agreements.compliance}
|
||||
onchange={link.callback(|_| StepFourMsg::ToggleComplianceAccepted)}
|
||||
required=true
|
||||
/>
|
||||
<label class="form-check-label" for="complianceAccepted">
|
||||
{"I agree to comply with all applicable laws and regulations"}
|
||||
{" "} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="articlesAccepted"
|
||||
checked={self.form_data.legal_agreements.articles}
|
||||
onchange={link.callback(|_| StepFourMsg::ToggleArticlesAccepted)}
|
||||
required=true
|
||||
/>
|
||||
<label class="form-check-label" for="articlesAccepted">
|
||||
{"I agree to the "}
|
||||
<a href="#" class="text-primary">{"Articles of Incorporation"}</a>
|
||||
{" "} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="finalAgreementAccepted"
|
||||
checked={self.form_data.legal_agreements.final_agreement}
|
||||
onchange={link.callback(|_| StepFourMsg::ToggleFinalAgreementAccepted)}
|
||||
required=true
|
||||
/>
|
||||
<label class="form-check-label" for="finalAgreementAccepted">
|
||||
{"I agree to the "}
|
||||
<a href="#" class="text-primary">{"Final Registration Agreement"}</a>
|
||||
{" "} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepFour {
|
||||
// Step 4 is now focused on documents and legal agreements only
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepOneProps {
|
||||
pub form_data: CompanyFormData,
|
||||
pub on_form_update: Callback<CompanyFormData>,
|
||||
}
|
||||
|
||||
pub enum StepOneMsg {
|
||||
UpdateCompanyName(String),
|
||||
UpdateDescription(String),
|
||||
UpdateEmail(String),
|
||||
UpdateIndustry(String),
|
||||
SelectCompanyType(CompanyType),
|
||||
}
|
||||
|
||||
pub struct StepOne {
|
||||
form_data: CompanyFormData,
|
||||
}
|
||||
|
||||
impl Component for StepOne {
|
||||
type Message = StepOneMsg;
|
||||
type Properties = StepOneProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
form_data: ctx.props().form_data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
StepOneMsg::UpdateCompanyName(value) => {
|
||||
self.form_data.company_name = value;
|
||||
}
|
||||
StepOneMsg::UpdateDescription(value) => {
|
||||
self.form_data.company_purpose = Some(value);
|
||||
}
|
||||
StepOneMsg::UpdateEmail(value) => {
|
||||
self.form_data.company_email = value;
|
||||
}
|
||||
StepOneMsg::UpdateIndustry(value) => {
|
||||
self.form_data.company_industry = if value.is_empty() { None } else { Some(value) };
|
||||
}
|
||||
StepOneMsg::SelectCompanyType(company_type) => {
|
||||
self.form_data.company_type = company_type;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent of form data changes
|
||||
ctx.props().on_form_update.emit(self.form_data.clone());
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
self.form_data = ctx.props().form_data.clone();
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="row mb-3">
|
||||
<label for="companyName" class="col-sm-4 col-form-label" data-bs-toggle="tooltip" data-bs-placement="top" title="The official name of your company or legal entity">
|
||||
{"Company Name"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<div class="col-sm-8">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="companyName"
|
||||
placeholder="Enter company name"
|
||||
value={self.form_data.company_name.clone()}
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepOneMsg::UpdateCompanyName(input.value())
|
||||
})}
|
||||
required=true
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="The official name of your company or legal entity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="email" class="col-sm-4 col-form-label" data-bs-toggle="tooltip" data-bs-placement="top" title="Primary contact email for the company">
|
||||
{"Email Address"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<div class="col-sm-8">
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
placeholder="company@example.com"
|
||||
value={self.form_data.company_email.clone()}
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepOneMsg::UpdateEmail(input.value())
|
||||
})}
|
||||
required=true
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Primary contact email for the company"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="industry" class="col-sm-4 col-form-label" data-bs-toggle="tooltip" data-bs-placement="top" title="Primary industry sector (optional)">
|
||||
{"Industry"}
|
||||
</label>
|
||||
<div class="col-sm-8">
|
||||
<select
|
||||
class="form-select"
|
||||
id="industry"
|
||||
value={self.form_data.company_industry.clone().unwrap_or_default()}
|
||||
onchange={link.callback(|e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepOneMsg::UpdateIndustry(input.value())
|
||||
})}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Primary industry sector (optional)"
|
||||
>
|
||||
<option value="">{"Select industry"}</option>
|
||||
<option value="Technology">{"Technology"}</option>
|
||||
<option value="Finance">{"Finance"}</option>
|
||||
<option value="Healthcare">{"Healthcare"}</option>
|
||||
<option value="Education">{"Education"}</option>
|
||||
<option value="Retail">{"Retail"}</option>
|
||||
<option value="Manufacturing">{"Manufacturing"}</option>
|
||||
<option value="Real Estate">{"Real Estate"}</option>
|
||||
<option value="Consulting">{"Consulting"}</option>
|
||||
<option value="Media">{"Media"}</option>
|
||||
<option value="Transportation">{"Transportation"}</option>
|
||||
<option value="Energy">{"Energy"}</option>
|
||||
<option value="Agriculture">{"Agriculture"}</option>
|
||||
<option value="Other">{"Other"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="description"
|
||||
rows="5"
|
||||
placeholder="Describe your company's business activities and purpose..."
|
||||
value={self.form_data.company_purpose.clone().unwrap_or_default()}
|
||||
oninput={link.callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepOneMsg::UpdateDescription(input.value())
|
||||
})}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Brief description of your company's business activities and purpose"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Company Type Selection
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="row">
|
||||
{self.render_company_type_option(ctx, CompanyType::SingleFZC,
|
||||
"Single FZC",
|
||||
"Perfect for individual entrepreneurs and solo ventures. Simple structure with one shareholder.",
|
||||
vec!["1 shareholder only", "Cannot issue digital assets", "Can hold external shares", "Connect to bank", "Participate in ecosystem"],
|
||||
"$20 setup + $20/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::StartupFZC,
|
||||
"Startup FZC",
|
||||
"Ideal for small teams and early-stage startups. Allows multiple shareholders and digital asset issuance.",
|
||||
vec!["Up to 5 shareholders", "Can issue digital assets", "Hold external shares", "Connect to bank", "Full ecosystem access"],
|
||||
"$50 setup + $50/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::GrowthFZC,
|
||||
"Growth FZC",
|
||||
"Designed for growing businesses that need more flexibility and can hold physical assets.",
|
||||
vec!["Up to 20 shareholders", "Can issue digital assets", "Hold external shares", "Connect to bank", "Hold physical assets"],
|
||||
"$100 setup + $100/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::GlobalFZC,
|
||||
"Global FZC",
|
||||
"Enterprise-level structure for large organizations with unlimited shareholders and full capabilities.",
|
||||
vec!["Unlimited shareholders", "Can issue digital assets", "Hold external shares", "Connect to bank", "Hold physical assets"],
|
||||
"$2000 setup + $200/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::CooperativeFZC,
|
||||
"Cooperative FZC",
|
||||
"Democratic organization structure with collective decision-making and equitable distribution.",
|
||||
vec!["Unlimited members", "Democratic governance", "Collective decision-making", "Equitable distribution", "Full capabilities"],
|
||||
"$2000 setup + $200/month")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepOne {
|
||||
fn render_company_type_option(
|
||||
&self,
|
||||
ctx: &Context<Self>,
|
||||
company_type: CompanyType,
|
||||
title: &str,
|
||||
description: &str,
|
||||
benefits: Vec<&str>,
|
||||
price: &str,
|
||||
) -> Html {
|
||||
let link = ctx.link();
|
||||
let is_selected = self.form_data.company_type == company_type;
|
||||
let card_class = if is_selected {
|
||||
"card border-success mb-3 shadow-sm"
|
||||
} else {
|
||||
"card border-light mb-3"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="col-xl col-lg-4 col-md-6 mb-3" style="min-width: 220px; max-width: 280px;">
|
||||
<div class={card_class} style="cursor: pointer;" onclick={link.callback(move |_| StepOneMsg::SelectCompanyType(company_type.clone()))}>
|
||||
<div class="card-header">
|
||||
<div class="d-flex align-items-center">
|
||||
<input
|
||||
type="radio"
|
||||
class="form-check-input me-2"
|
||||
checked={is_selected}
|
||||
onchange={link.callback(move |_| StepOneMsg::SelectCompanyType(company_type.clone()))}
|
||||
/>
|
||||
<h6 class="mb-0">{title}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted mb-2">{description}</p>
|
||||
<div class="text-left mb-3">
|
||||
<span class="badge bg-primary">{price}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="text-success mb-2">{"Key Features:"}</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{for benefits.iter().map(|benefit| {
|
||||
html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>{benefit}
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{if is_selected {
|
||||
html! {
|
||||
<div class="card-footer bg-success text-white">
|
||||
<i class="bi bi-check-circle me-2"></i>{"Selected"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepThreeProps {
|
||||
pub form_data: CompanyFormData,
|
||||
pub on_form_update: Callback<CompanyFormData>,
|
||||
}
|
||||
|
||||
pub enum StepThreeMsg {
|
||||
AddShareholder,
|
||||
RemoveShareholder(usize),
|
||||
UpdateShareholderName(usize, String),
|
||||
UpdateShareholderPercentage(usize, String),
|
||||
UpdateShareholderStructure(ShareholderStructure),
|
||||
}
|
||||
|
||||
pub struct StepThree {
|
||||
form_data: CompanyFormData,
|
||||
}
|
||||
|
||||
impl Component for StepThree {
|
||||
type Message = StepThreeMsg;
|
||||
type Properties = StepThreeProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut form_data = ctx.props().form_data.clone();
|
||||
|
||||
// Ensure at least one shareholder exists
|
||||
if form_data.shareholders.is_empty() {
|
||||
form_data.shareholders.push(Shareholder {
|
||||
name: String::new(),
|
||||
resident_id: String::new(),
|
||||
percentage: 100.0,
|
||||
});
|
||||
}
|
||||
|
||||
Self { form_data }
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
StepThreeMsg::AddShareholder => {
|
||||
self.form_data.shareholders.push(Shareholder {
|
||||
name: String::new(),
|
||||
resident_id: String::new(),
|
||||
percentage: 0.0,
|
||||
});
|
||||
}
|
||||
StepThreeMsg::RemoveShareholder(index) => {
|
||||
if self.form_data.shareholders.len() > 1 && index < self.form_data.shareholders.len() {
|
||||
self.form_data.shareholders.remove(index);
|
||||
}
|
||||
}
|
||||
StepThreeMsg::UpdateShareholderName(index, value) => {
|
||||
if let Some(shareholder) = self.form_data.shareholders.get_mut(index) {
|
||||
shareholder.name = value;
|
||||
}
|
||||
}
|
||||
StepThreeMsg::UpdateShareholderPercentage(index, value) => {
|
||||
if let Some(shareholder) = self.form_data.shareholders.get_mut(index) {
|
||||
shareholder.percentage = value.parse().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
StepThreeMsg::UpdateShareholderStructure(structure) => {
|
||||
self.form_data.shareholder_structure = structure;
|
||||
|
||||
// If switching to equal, redistribute percentages equally
|
||||
if matches!(self.form_data.shareholder_structure, ShareholderStructure::Equal) {
|
||||
let count = self.form_data.shareholders.len() as f64;
|
||||
let equal_percentage = 100.0 / count;
|
||||
for shareholder in &mut self.form_data.shareholders {
|
||||
shareholder.percentage = equal_percentage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent of form data changes
|
||||
ctx.props().on_form_update.emit(self.form_data.clone());
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
self.form_data = ctx.props().form_data.clone();
|
||||
|
||||
// Ensure at least one shareholder exists
|
||||
if self.form_data.shareholders.is_empty() {
|
||||
self.form_data.shareholders.push(Shareholder {
|
||||
name: String::new(),
|
||||
resident_id: String::new(),
|
||||
percentage: 100.0,
|
||||
});
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let total_percentage: f64 = self.form_data.shareholders.iter()
|
||||
.map(|s| s.percentage)
|
||||
.sum();
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h5 class="text-secondary mb-3">{"Ownership Structure"}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="shareholderStructure"
|
||||
id="equalStructure"
|
||||
checked={matches!(self.form_data.shareholder_structure, ShareholderStructure::Equal)}
|
||||
onchange={link.callback(|_| StepThreeMsg::UpdateShareholderStructure(ShareholderStructure::Equal))}
|
||||
/>
|
||||
<label class="form-check-label" for="equalStructure">
|
||||
<strong>{"Equal Ownership"}</strong>
|
||||
<div class="text-muted small">{"All shareholders have equal ownership percentages"}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="shareholderStructure"
|
||||
id="customStructure"
|
||||
checked={matches!(self.form_data.shareholder_structure, ShareholderStructure::Custom)}
|
||||
onchange={link.callback(|_| StepThreeMsg::UpdateShareholderStructure(ShareholderStructure::Custom))}
|
||||
/>
|
||||
<label class="form-check-label" for="customStructure">
|
||||
<strong>{"Custom Ownership"}</strong>
|
||||
<div class="text-muted small">{"Specify individual ownership percentages"}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="text-secondary mb-0">
|
||||
{"Shareholders"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-success btn-sm"
|
||||
onclick={link.callback(|_| StepThreeMsg::AddShareholder)}
|
||||
>
|
||||
<i class="bi bi-plus-circle me-1"></i>{"Add Shareholder"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{if total_percentage != 100.0 && total_percentage > 0.0 {
|
||||
html! {
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{"Total ownership percentage is "}{total_percentage}{"%"}
|
||||
{" - it should equal 100% for proper ownership distribution."}
|
||||
</div>
|
||||
}
|
||||
} else if total_percentage == 100.0 {
|
||||
html! {
|
||||
<div class="alert alert-success">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
{"Ownership percentages total 100% ✓"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{for self.form_data.shareholders.iter().enumerate().map(|(index, shareholder)| {
|
||||
self.render_shareholder_form(ctx, index, shareholder)
|
||||
})}
|
||||
|
||||
<div class="alert alert-info mt-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-info-circle me-3 mt-1"></i>
|
||||
<div>
|
||||
<strong>{"Important Notes:"}</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>{"All shareholders must be at least 18 years old"}</li>
|
||||
<li>{"Total ownership percentages must equal 100%"}</li>
|
||||
<li>{"Each shareholder will receive official documentation"}</li>
|
||||
<li>{"Shareholder information is used for legal filings and compliance"}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepThree {
|
||||
fn render_shareholder_form(&self, ctx: &Context<Self>, index: usize, shareholder: &Shareholder) -> Html {
|
||||
let link = ctx.link();
|
||||
let can_remove = self.form_data.shareholders.len() > 1;
|
||||
let is_equal_structure = matches!(self.form_data.shareholder_structure, ShareholderStructure::Equal);
|
||||
|
||||
html! {
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-person me-2"></i>
|
||||
{"Shareholder "}{index + 1}
|
||||
</h6>
|
||||
{if can_remove {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
onclick={link.callback(move |_| StepThreeMsg::RemoveShareholder(index))}
|
||||
title="Remove this shareholder"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for={format!("shareholderName{}", index)} class="form-label">
|
||||
{"Full Name"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id={format!("shareholderName{}", index)}
|
||||
placeholder="Enter full legal name"
|
||||
value={shareholder.name.clone()}
|
||||
oninput={link.callback(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepThreeMsg::UpdateShareholderName(index, input.value())
|
||||
})}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for={format!("shareholderPercentage{}", index)} class="form-label">
|
||||
{"Ownership %"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
id={format!("shareholderPercentage{}", index)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={shareholder.percentage.to_string()}
|
||||
oninput={link.callback(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepThreeMsg::UpdateShareholderPercentage(index, input.value())
|
||||
})}
|
||||
disabled={is_equal_structure}
|
||||
required=true
|
||||
/>
|
||||
<span class="input-group-text">{"%"}</span>
|
||||
</div>
|
||||
{if is_equal_structure {
|
||||
html! {
|
||||
<div class="form-text text-info">
|
||||
{"Automatically calculated for equal ownership"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use yew::prelude::*;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepTwoProps {
|
||||
pub form_data: CompanyFormData,
|
||||
pub on_form_update: Callback<CompanyFormData>,
|
||||
}
|
||||
|
||||
pub enum StepTwoMsg {
|
||||
SelectCompanyType(CompanyType),
|
||||
}
|
||||
|
||||
pub struct StepTwo {
|
||||
form_data: CompanyFormData,
|
||||
}
|
||||
|
||||
impl Component for StepTwo {
|
||||
type Message = StepTwoMsg;
|
||||
type Properties = StepTwoProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
form_data: ctx.props().form_data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
StepTwoMsg::SelectCompanyType(company_type) => {
|
||||
self.form_data.company_type = company_type;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent of form data changes
|
||||
ctx.props().on_form_update.emit(self.form_data.clone());
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
self.form_data = ctx.props().form_data.clone();
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="row">
|
||||
{self.render_company_type_option(ctx, CompanyType::SingleFZC,
|
||||
"Single FZC",
|
||||
"Perfect for individual entrepreneurs and solo ventures. Simple structure with one shareholder.",
|
||||
vec!["1 shareholder only", "Cannot issue digital assets", "Can hold external shares", "Connect to bank", "Participate in ecosystem"],
|
||||
"$20 setup + $20/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::StartupFZC,
|
||||
"Startup FZC",
|
||||
"Ideal for small teams and early-stage startups. Allows multiple shareholders and digital asset issuance.",
|
||||
vec!["Up to 5 shareholders", "Can issue digital assets", "Hold external shares", "Connect to bank", "Full ecosystem access"],
|
||||
"$50 setup + $50/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::GrowthFZC,
|
||||
"Growth FZC",
|
||||
"Designed for growing businesses that need more flexibility and can hold physical assets.",
|
||||
vec!["Up to 20 shareholders", "Can issue digital assets", "Hold external shares", "Connect to bank", "Hold physical assets"],
|
||||
"$100 setup + $100/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::GlobalFZC,
|
||||
"Global FZC",
|
||||
"Enterprise-level structure for large organizations with unlimited shareholders and full capabilities.",
|
||||
vec!["Unlimited shareholders", "Can issue digital assets", "Hold external shares", "Connect to bank", "Hold physical assets"],
|
||||
"$2000 setup + $200/month")}
|
||||
|
||||
{self.render_company_type_option(ctx, CompanyType::CooperativeFZC,
|
||||
"Cooperative FZC",
|
||||
"Democratic organization structure with collective decision-making and equitable distribution.",
|
||||
vec!["Unlimited members", "Democratic governance", "Collective decision-making", "Equitable distribution", "Full capabilities"],
|
||||
"$2000 setup + $200/month")}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-lightbulb me-3 mt-1"></i>
|
||||
<div>
|
||||
<strong>{"Need help choosing?"}</strong> {" The choice of entity type affects your capabilities, costs, and governance structure. "}
|
||||
{"Consider your current needs and future growth plans when selecting your FZC type."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepTwo {
|
||||
fn render_company_type_option(
|
||||
&self,
|
||||
ctx: &Context<Self>,
|
||||
company_type: CompanyType,
|
||||
title: &str,
|
||||
description: &str,
|
||||
benefits: Vec<&str>,
|
||||
price: &str,
|
||||
) -> Html {
|
||||
let link = ctx.link();
|
||||
let is_selected = self.form_data.company_type == company_type;
|
||||
let card_class = if is_selected {
|
||||
"card border-success mb-3 shadow-sm"
|
||||
} else {
|
||||
"card border-light mb-3"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="col-lg-6 mb-3">
|
||||
<div class={card_class} style="cursor: pointer;" onclick={link.callback(move |_| StepTwoMsg::SelectCompanyType(company_type.clone()))}>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<input
|
||||
type="radio"
|
||||
class="form-check-input me-2"
|
||||
checked={is_selected}
|
||||
onchange={link.callback(move |_| StepTwoMsg::SelectCompanyType(company_type.clone()))}
|
||||
/>
|
||||
<h6 class="mb-0">{title}</h6>
|
||||
</div>
|
||||
<span class="badge bg-primary">{price}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted mb-3">{description}</p>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="text-success mb-2">{"Key Features:"}</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{for benefits.iter().map(|benefit| {
|
||||
html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>{benefit}
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{if is_selected {
|
||||
html! {
|
||||
<div class="card-footer bg-success text-white">
|
||||
<i class="bi bi-check-circle me-2"></i>{"Selected"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use gloo::timers::callback::Timeout;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepTwoCombinedProps {
|
||||
pub form_data: CompanyFormData,
|
||||
pub on_form_update: Callback<CompanyFormData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum BylawTemplate {
|
||||
Standard,
|
||||
Startup,
|
||||
Enterprise,
|
||||
Cooperative,
|
||||
}
|
||||
|
||||
impl BylawTemplate {
|
||||
fn get_display_name(&self) -> &'static str {
|
||||
match self {
|
||||
BylawTemplate::Standard => "Standard Bylaws",
|
||||
BylawTemplate::Startup => "Startup-Friendly Bylaws",
|
||||
BylawTemplate::Enterprise => "Enterprise Bylaws",
|
||||
BylawTemplate::Cooperative => "Cooperative Bylaws",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_description(&self) -> &'static str {
|
||||
match self {
|
||||
BylawTemplate::Standard => "Basic corporate governance structure suitable for most companies",
|
||||
BylawTemplate::Startup => "Flexible structure with provisions for equity incentives and rapid growth",
|
||||
BylawTemplate::Enterprise => "Comprehensive governance framework for larger organizations",
|
||||
BylawTemplate::Cooperative => "Democratic governance structure for cooperative organizations",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum StepTwoCombinedMsg {
|
||||
// Shareholder messages
|
||||
AddShareholder,
|
||||
RemoveShareholder(usize),
|
||||
UpdateShareholderName(usize, String),
|
||||
UpdateShareholderResidentId(usize, String),
|
||||
UpdateShareholderPercentage(usize, String),
|
||||
UpdateShareholderStructure(ShareholderStructure),
|
||||
|
||||
// Bylaw template messages
|
||||
SelectBylawTemplate(BylawTemplate),
|
||||
|
||||
// Document actions
|
||||
ViewDocument(String),
|
||||
SignDocument(String),
|
||||
CloseDocumentModal,
|
||||
DocumentGenerationComplete,
|
||||
}
|
||||
|
||||
pub struct StepTwoCombined {
|
||||
form_data: CompanyFormData,
|
||||
selected_bylaw_template: Option<BylawTemplate>,
|
||||
documents_generated: bool,
|
||||
documents_generating: bool,
|
||||
show_document_modal: bool,
|
||||
current_document: Option<String>,
|
||||
signed_documents: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl Component for StepTwoCombined {
|
||||
type Message = StepTwoCombinedMsg;
|
||||
type Properties = StepTwoCombinedProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let mut form_data = ctx.props().form_data.clone();
|
||||
|
||||
// Ensure at least one shareholder exists
|
||||
if form_data.shareholders.is_empty() {
|
||||
form_data.shareholders.push(Shareholder {
|
||||
name: String::new(),
|
||||
resident_id: String::new(),
|
||||
percentage: 100.0,
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
form_data,
|
||||
selected_bylaw_template: None,
|
||||
documents_generated: false,
|
||||
documents_generating: false,
|
||||
show_document_modal: false,
|
||||
current_document: None,
|
||||
signed_documents: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
// Shareholder handling
|
||||
StepTwoCombinedMsg::AddShareholder => {
|
||||
self.form_data.shareholders.push(Shareholder {
|
||||
name: String::new(),
|
||||
resident_id: String::new(),
|
||||
percentage: 0.0,
|
||||
});
|
||||
// Mark documents as generating due to shareholder change
|
||||
if self.documents_generated {
|
||||
self.documents_generating = true;
|
||||
self.schedule_document_generation_completion(ctx);
|
||||
}
|
||||
}
|
||||
StepTwoCombinedMsg::RemoveShareholder(index) => {
|
||||
if self.form_data.shareholders.len() > 1 && index < self.form_data.shareholders.len() {
|
||||
self.form_data.shareholders.remove(index);
|
||||
// Mark documents as generating due to shareholder change
|
||||
if self.documents_generated {
|
||||
self.documents_generating = true;
|
||||
self.schedule_document_generation_completion(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
StepTwoCombinedMsg::UpdateShareholderName(index, value) => {
|
||||
if let Some(shareholder) = self.form_data.shareholders.get_mut(index) {
|
||||
shareholder.name = value;
|
||||
// Mark documents as generating due to shareholder change
|
||||
if self.documents_generated {
|
||||
self.documents_generating = true;
|
||||
self.schedule_document_generation_completion(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
StepTwoCombinedMsg::UpdateShareholderResidentId(index, value) => {
|
||||
if let Some(shareholder) = self.form_data.shareholders.get_mut(index) {
|
||||
shareholder.resident_id = value;
|
||||
// Mark documents as generating due to shareholder change
|
||||
if self.documents_generated {
|
||||
self.documents_generating = true;
|
||||
self.schedule_document_generation_completion(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
StepTwoCombinedMsg::UpdateShareholderPercentage(index, value) => {
|
||||
if let Some(shareholder) = self.form_data.shareholders.get_mut(index) {
|
||||
shareholder.percentage = value.parse().unwrap_or(0.0);
|
||||
// Mark documents as generating due to shareholder change
|
||||
if self.documents_generated {
|
||||
self.documents_generating = true;
|
||||
self.schedule_document_generation_completion(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
StepTwoCombinedMsg::UpdateShareholderStructure(structure) => {
|
||||
self.form_data.shareholder_structure = structure;
|
||||
|
||||
// If switching to equal, redistribute percentages equally
|
||||
if matches!(self.form_data.shareholder_structure, ShareholderStructure::Equal) {
|
||||
let count = self.form_data.shareholders.len() as f64;
|
||||
let equal_percentage = 100.0 / count;
|
||||
for shareholder in &mut self.form_data.shareholders {
|
||||
shareholder.percentage = equal_percentage;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark documents as generating due to shareholder structure change
|
||||
if self.documents_generated {
|
||||
self.documents_generating = true;
|
||||
self.schedule_document_generation_completion(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Bylaw template handling
|
||||
StepTwoCombinedMsg::SelectBylawTemplate(template) => {
|
||||
self.selected_bylaw_template = Some(template);
|
||||
self.documents_generated = true;
|
||||
self.documents_generating = false; // Documents are now ready
|
||||
}
|
||||
|
||||
// Document actions
|
||||
StepTwoCombinedMsg::ViewDocument(document_name) => {
|
||||
self.current_document = Some(document_name);
|
||||
self.show_document_modal = true;
|
||||
}
|
||||
StepTwoCombinedMsg::SignDocument(document_name) => {
|
||||
self.signed_documents.insert(document_name);
|
||||
web_sys::console::log_1(&format!("Document signed: {:?}", self.signed_documents).into());
|
||||
}
|
||||
StepTwoCombinedMsg::CloseDocumentModal => {
|
||||
self.show_document_modal = false;
|
||||
self.current_document = None;
|
||||
}
|
||||
StepTwoCombinedMsg::DocumentGenerationComplete => {
|
||||
self.documents_generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent of form data changes
|
||||
ctx.props().on_form_update.emit(self.form_data.clone());
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
|
||||
self.form_data = ctx.props().form_data.clone();
|
||||
|
||||
// Ensure at least one shareholder exists
|
||||
if self.form_data.shareholders.is_empty() {
|
||||
self.form_data.shareholders.push(Shareholder {
|
||||
name: String::new(),
|
||||
resident_id: String::new(),
|
||||
percentage: 100.0,
|
||||
});
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let total_percentage: f64 = self.form_data.shareholders.iter()
|
||||
.map(|s| s.percentage)
|
||||
.sum();
|
||||
let is_equal_structure = matches!(self.form_data.shareholder_structure, ShareholderStructure::Equal);
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div class="step-content">
|
||||
// Shareholders Section
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="text-secondary mb-0">
|
||||
{"Shareholders"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="equalOwnership"
|
||||
checked={is_equal_structure}
|
||||
onchange={link.callback(move |_| {
|
||||
if is_equal_structure {
|
||||
StepTwoCombinedMsg::UpdateShareholderStructure(ShareholderStructure::Custom)
|
||||
} else {
|
||||
StepTwoCombinedMsg::UpdateShareholderStructure(ShareholderStructure::Equal)
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<label class="form-check-label" for="equalOwnership">
|
||||
{"Equal Ownership"}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-success btn-sm"
|
||||
onclick={link.callback(|_| StepTwoCombinedMsg::AddShareholder)}
|
||||
>
|
||||
<i class="bi bi-plus-circle me-1"></i>{"Add Shareholder"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if total_percentage != 100.0 && total_percentage > 0.0 {
|
||||
html! {
|
||||
<div class="alert alert-warning alert-sm mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{"Total: "}{total_percentage}{"% (should be 100%)"}
|
||||
</div>
|
||||
}
|
||||
} else if total_percentage == 100.0 {
|
||||
html! {
|
||||
<div class="alert alert-success alert-sm mb-3">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
{"Total: 100% ✓"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
// Shareholder headers
|
||||
<div class="row mb-2 text-muted small fw-bold">
|
||||
<div class="col-1">{"#"}</div>
|
||||
<div class="col-4">{"Full Legal Name"}</div>
|
||||
<div class="col-3">{"Resident ID"}</div>
|
||||
<div class="col-3">{"Ownership %"}</div>
|
||||
<div class="col-1"></div>
|
||||
</div>
|
||||
|
||||
{for self.form_data.shareholders.iter().enumerate().map(|(index, shareholder)| {
|
||||
self.render_compact_shareholder_form(ctx, index, shareholder)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Compact Bylaw Template Selection
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3">
|
||||
<label for="bylawTemplate" class="form-label mb-0">
|
||||
{"Bylaw Template"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<select
|
||||
class="form-select"
|
||||
id="bylawTemplate"
|
||||
value={match &self.selected_bylaw_template {
|
||||
Some(BylawTemplate::Standard) => "standard",
|
||||
Some(BylawTemplate::Startup) => "startup",
|
||||
Some(BylawTemplate::Enterprise) => "enterprise",
|
||||
Some(BylawTemplate::Cooperative) => "cooperative",
|
||||
None => "",
|
||||
}}
|
||||
onchange={link.callback(|e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
match input.value().as_str() {
|
||||
"standard" => StepTwoCombinedMsg::SelectBylawTemplate(BylawTemplate::Standard),
|
||||
"startup" => StepTwoCombinedMsg::SelectBylawTemplate(BylawTemplate::Startup),
|
||||
"enterprise" => StepTwoCombinedMsg::SelectBylawTemplate(BylawTemplate::Enterprise),
|
||||
"cooperative" => StepTwoCombinedMsg::SelectBylawTemplate(BylawTemplate::Cooperative),
|
||||
_ => StepTwoCombinedMsg::SelectBylawTemplate(BylawTemplate::Standard),
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="">{"Select bylaw template..."}</option>
|
||||
<option value="standard">{"Standard Bylaws - Basic corporate governance structure"}</option>
|
||||
<option value="startup">{"Startup-Friendly Bylaws - Flexible structure with equity incentives"}</option>
|
||||
<option value="enterprise">{"Enterprise Bylaws - Comprehensive governance framework"}</option>
|
||||
<option value="cooperative">{"Cooperative Bylaws - Democratic governance structure"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Generated Documents Section - Always visible
|
||||
{self.render_generated_documents(ctx)}
|
||||
</div>
|
||||
|
||||
// Document Modal
|
||||
{
|
||||
if self.show_document_modal {
|
||||
html! {
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{ self.current_document.as_ref().unwrap_or(&"Document".to_string()) }
|
||||
</h5>
|
||||
<button type="button" class="btn-close" onclick={ctx.link().callback(|_| StepTwoCombinedMsg::CloseDocumentModal)}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="bg-light p-3 rounded">
|
||||
{
|
||||
if let Some(doc_name) = &self.current_document {
|
||||
match doc_name.as_str() {
|
||||
"articles" => html! {
|
||||
<pre class="mb-0">
|
||||
{ "# Articles of Formation\n\n" }
|
||||
{ format!("**Company Name:** {}\n", self.form_data.company_name) }
|
||||
{ format!("**Company Type:** {:?}\n", self.form_data.company_type) }
|
||||
{ "**Purpose:** General business purposes\n\n" }
|
||||
{ "## Shareholders\n" }
|
||||
{
|
||||
for self.form_data.shareholders.iter().enumerate().map(|(i, shareholder)| {
|
||||
html! {
|
||||
<div>
|
||||
{ format!("{}. {} (ID: {}) - {}%\n",
|
||||
i + 1,
|
||||
shareholder.name,
|
||||
shareholder.resident_id,
|
||||
shareholder.percentage
|
||||
) }
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}
|
||||
</pre>
|
||||
},
|
||||
"bylaws" => html! {
|
||||
<pre class="mb-0">
|
||||
{ "# Company Bylaws\n\n" }
|
||||
{ format!("**Company:** {}\n", self.form_data.company_name) }
|
||||
{ format!("**Template:** {}\n\n",
|
||||
if let Some(template) = &self.selected_bylaw_template {
|
||||
template.get_display_name()
|
||||
} else {
|
||||
"Standard"
|
||||
}
|
||||
) }
|
||||
{ "## Article I - Corporate Offices\n" }
|
||||
{ "The registered office shall be located as specified in the Articles of Formation.\n\n" }
|
||||
{ "## Article II - Shareholders\n" }
|
||||
{ "The corporation is authorized to issue shares as detailed in the Articles of Formation.\n\n" }
|
||||
{ "## Article III - Board of Directors\n" }
|
||||
{ "The business and affairs of the corporation shall be managed by the board of directors.\n\n" }
|
||||
{ "## Article IV - Officers\n" }
|
||||
{ "The officers of the corporation shall consist of a President, Secretary, and Treasurer.\n" }
|
||||
</pre>
|
||||
},
|
||||
_ => html! { <p>{ "Document content not available" }</p> }
|
||||
}
|
||||
} else {
|
||||
html! { <p>{ "No document selected" }</p> }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={ctx.link().callback(|_| StepTwoCombinedMsg::CloseDocumentModal)}>
|
||||
{ "Close" }
|
||||
</button>
|
||||
{
|
||||
if let Some(doc_name) = &self.current_document {
|
||||
if !self.signed_documents.contains(doc_name) {
|
||||
let doc_name_clone = doc_name.clone();
|
||||
html! {
|
||||
<button type="button" class="btn btn-primary"
|
||||
onclick={ctx.link().callback(move |_| StepTwoCombinedMsg::SignDocument(doc_name_clone.clone()))}>
|
||||
{ "Sign Document" }
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<span class="badge bg-success">{ "✓ Signed" }</span>
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepTwoCombined {
|
||||
fn render_compact_shareholder_form(&self, ctx: &Context<Self>, index: usize, shareholder: &Shareholder) -> Html {
|
||||
let link = ctx.link();
|
||||
let can_remove = self.form_data.shareholders.len() > 1;
|
||||
let is_equal_structure = matches!(self.form_data.shareholder_structure, ShareholderStructure::Equal);
|
||||
|
||||
html! {
|
||||
<div class="row mb-2 align-items-center">
|
||||
<div class="col-1">
|
||||
<span class="badge bg-secondary">{index + 1}</span>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Full legal name"
|
||||
value={shareholder.name.clone()}
|
||||
oninput={link.callback(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepTwoCombinedMsg::UpdateShareholderName(index, input.value())
|
||||
})}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Resident ID"
|
||||
value={shareholder.resident_id.clone()}
|
||||
oninput={link.callback(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepTwoCombinedMsg::UpdateShareholderResidentId(index, input.value())
|
||||
})}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={shareholder.percentage.to_string()}
|
||||
oninput={link.callback(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
StepTwoCombinedMsg::UpdateShareholderPercentage(index, input.value())
|
||||
})}
|
||||
disabled={is_equal_structure}
|
||||
required=true
|
||||
/>
|
||||
<span class="input-group-text">{"%"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
{if can_remove {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
onclick={link.callback(move |_| StepTwoCombinedMsg::RemoveShareholder(index))}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Remove shareholder"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_generated_documents(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
// Determine document status
|
||||
let (status_icon, status_text, description_text) = if !self.documents_generated {
|
||||
("⏳", "Pending", "Documents will be generated once you select a bylaw template.")
|
||||
} else if self.documents_generating {
|
||||
("🔄", "Generating", "Documents are being regenerated based on your recent changes.")
|
||||
} else {
|
||||
("✓", "Ready", "Based on your selections, the following documents have been generated and are ready for review and signing.")
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h5 class="text-secondary mb-3">
|
||||
{"Generated Documents"} <span class="text-success">{status_icon}</span>
|
||||
</h5>
|
||||
<p class="text-muted mb-3">{description_text}</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{"Document"}</th>
|
||||
<th>{"Description"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th class="text-end">{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-file-earmark-text me-2 text-primary"></i>
|
||||
<strong>{"Articles of Formation"}</strong>
|
||||
</td>
|
||||
<td class="text-muted">{"Legal document establishing your company's existence and basic structure"}</td>
|
||||
<td>
|
||||
{if !self.documents_generated {
|
||||
html! { <span class="badge bg-secondary">{"Pending"}</span> }
|
||||
} else if self.documents_generating {
|
||||
html! { <span class="badge bg-info">{"Generating..."}</span> }
|
||||
} else {
|
||||
html! { <span class="badge bg-warning">{"Ready for Review"}</span> }
|
||||
}}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{if self.documents_generated && !self.documents_generating {
|
||||
html! {
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
onclick={link.callback(|_| StepTwoCombinedMsg::ViewDocument("articles".to_string()))}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Review document carefully before signing"
|
||||
>
|
||||
<i class="bi bi-eye me-1"></i>{"View"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={link.callback(|_| StepTwoCombinedMsg::SignDocument("articles".to_string()))}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Documents are legally binding once signed"
|
||||
>
|
||||
<i class="bi bi-pen me-1"></i>{"Sign"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<span class="text-muted small">{"Not available"}</span>
|
||||
}
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-file-earmark-ruled me-2 text-info"></i>
|
||||
<strong>{"Company Bylaws"}</strong>
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
{"Internal governance rules based on "}
|
||||
{if let Some(template) = &self.selected_bylaw_template {
|
||||
template.get_display_name()
|
||||
} else {
|
||||
"selected template"
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{if !self.documents_generated {
|
||||
html! { <span class="badge bg-secondary">{"Pending"}</span> }
|
||||
} else if self.documents_generating {
|
||||
html! { <span class="badge bg-info">{"Generating..."}</span> }
|
||||
} else {
|
||||
html! { <span class="badge bg-warning">{"Ready for Review"}</span> }
|
||||
}}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{if self.documents_generated && !self.documents_generating {
|
||||
html! {
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
onclick={link.callback(|_| StepTwoCombinedMsg::ViewDocument("bylaws".to_string()))}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="Review document carefully before signing"
|
||||
>
|
||||
<i class="bi bi-eye me-1"></i>{"View"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={link.callback(|_| StepTwoCombinedMsg::SignDocument("bylaws".to_string()))}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="You can download copies after signing"
|
||||
>
|
||||
<i class="bi bi-pen me-1"></i>{"Sign"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<span class="text-muted small">{"Not available"}</span>
|
||||
}
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_document_generation_completion(&self, ctx: &Context<Self>) {
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(2000, move || {
|
||||
link.send_message(StepTwoCombinedMsg::DocumentGenerationComplete);
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
68
platform/src/components/entities/entities_tabs.rs
Normal file
68
platform/src/components/entities/entities_tabs.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use yew::prelude::*;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EntitiesTabsProps {
|
||||
pub active_tab: ActiveTab,
|
||||
pub on_tab_change: Callback<ActiveTab>,
|
||||
}
|
||||
|
||||
#[function_component(EntitiesTabs)]
|
||||
pub fn entities_tabs(props: &EntitiesTabsProps) -> Html {
|
||||
let on_companies_click = {
|
||||
let on_tab_change = props.on_tab_change.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
on_tab_change.emit(ActiveTab::Companies);
|
||||
})
|
||||
};
|
||||
|
||||
let on_register_click = {
|
||||
let on_tab_change = props.on_tab_change.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
on_tab_change.emit(ActiveTab::RegisterCompany);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="mb-4">
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-tabs" id="companyTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class={classes!(
|
||||
"nav-link",
|
||||
if props.active_tab == ActiveTab::Companies { "active" } else { "" }
|
||||
)}
|
||||
id="manage-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="manage"
|
||||
aria-selected={if props.active_tab == ActiveTab::Companies { "true" } else { "false" }}
|
||||
onclick={on_companies_click}
|
||||
>
|
||||
<i class="bi bi-building me-1"></i>{" Manage Companies"}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class={classes!(
|
||||
"nav-link",
|
||||
if props.active_tab == ActiveTab::RegisterCompany { "active" } else { "" }
|
||||
)}
|
||||
id="register-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="register"
|
||||
aria-selected={if props.active_tab == ActiveTab::RegisterCompany { "true" } else { "false" }}
|
||||
onclick={on_register_click}
|
||||
>
|
||||
<i class="bi bi-file-earmark-plus me-1"></i>{" Register New Company"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
9
platform/src/components/entities/mod.rs
Normal file
9
platform/src/components/entities/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod entities_tabs;
|
||||
pub mod companies_list;
|
||||
pub mod company_registration;
|
||||
pub mod resident_registration;
|
||||
|
||||
pub use entities_tabs::*;
|
||||
pub use companies_list::*;
|
||||
pub use company_registration::*;
|
||||
pub use resident_registration::*;
|
||||
@@ -0,0 +1,23 @@
|
||||
pub mod step_one;
|
||||
pub mod step_two;
|
||||
pub mod step_three;
|
||||
pub mod step_four;
|
||||
pub mod step_five;
|
||||
pub mod resident_wizard;
|
||||
pub mod step_info_kyc;
|
||||
pub mod step_payment;
|
||||
pub mod step_payment_stripe;
|
||||
pub mod simple_resident_wizard;
|
||||
pub mod simple_step_info;
|
||||
|
||||
pub use step_one::*;
|
||||
pub use step_two::*;
|
||||
pub use step_three::*;
|
||||
pub use step_four::*;
|
||||
pub use step_five::*;
|
||||
pub use resident_wizard::*;
|
||||
pub use step_info_kyc::*;
|
||||
pub use step_payment::*;
|
||||
pub use step_payment_stripe::*;
|
||||
pub use simple_resident_wizard::*;
|
||||
pub use simple_step_info::*;
|
||||
@@ -0,0 +1,689 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::timers::callback::Timeout;
|
||||
use crate::models::company::{DigitalResidentFormData, DigitalResident, ResidentStatus};
|
||||
use super::{
|
||||
step_one::StepOne,
|
||||
step_two::StepTwo,
|
||||
step_three::StepThree,
|
||||
step_four::StepFour,
|
||||
step_five::StepFive,
|
||||
};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ResidentWizardProps {
|
||||
pub on_registration_complete: Callback<DigitalResident>,
|
||||
pub on_back_to_parent: Callback<()>,
|
||||
#[prop_or_default]
|
||||
pub success_resident_id: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub show_failure: bool,
|
||||
}
|
||||
|
||||
pub enum ResidentWizardMsg {
|
||||
NextStep,
|
||||
PrevStep,
|
||||
UpdateFormData(DigitalResidentFormData),
|
||||
ProcessRegistration,
|
||||
RegistrationComplete(DigitalResident),
|
||||
RegistrationError(String),
|
||||
ShowValidationToast(Vec<String>),
|
||||
HideValidationToast,
|
||||
}
|
||||
|
||||
pub struct ResidentWizard {
|
||||
current_step: u8,
|
||||
form_data: DigitalResidentFormData,
|
||||
validation_errors: Vec<String>,
|
||||
processing_registration: bool,
|
||||
show_validation_toast: bool,
|
||||
}
|
||||
|
||||
impl Component for ResidentWizard {
|
||||
type Message = ResidentWizardMsg;
|
||||
type Properties = ResidentWizardProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
// Determine initial step based on props
|
||||
let current_step = if ctx.props().success_resident_id.is_some() {
|
||||
// Show success step
|
||||
6
|
||||
} else if ctx.props().show_failure {
|
||||
// Show failure, go back to payment step
|
||||
5
|
||||
} else {
|
||||
// Normal flow - start from step 1
|
||||
1
|
||||
};
|
||||
|
||||
Self {
|
||||
current_step,
|
||||
form_data: DigitalResidentFormData::default(),
|
||||
validation_errors: Vec::new(),
|
||||
processing_registration: false,
|
||||
show_validation_toast: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ResidentWizardMsg::NextStep => {
|
||||
// Validate current step
|
||||
let validation_result = self.validate_current_step();
|
||||
if !validation_result.is_valid {
|
||||
self.validation_errors = validation_result.errors;
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(ResidentWizardMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.current_step < 6 {
|
||||
if self.current_step == 5 {
|
||||
// Process registration on final step
|
||||
ctx.link().send_message(ResidentWizardMsg::ProcessRegistration);
|
||||
} else {
|
||||
self.current_step += 1;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
ResidentWizardMsg::PrevStep => {
|
||||
if self.current_step > 1 {
|
||||
self.current_step -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
ResidentWizardMsg::UpdateFormData(new_form_data) => {
|
||||
self.form_data = new_form_data;
|
||||
true
|
||||
}
|
||||
ResidentWizardMsg::ProcessRegistration => {
|
||||
self.processing_registration = true;
|
||||
|
||||
// Simulate registration processing
|
||||
let link = ctx.link().clone();
|
||||
let form_data = self.form_data.clone();
|
||||
|
||||
Timeout::new(2000, move || {
|
||||
// Legacy wizard - create a minimal resident for compatibility
|
||||
let resident = DigitalResident {
|
||||
id: 1,
|
||||
full_name: form_data.full_name,
|
||||
email: form_data.email,
|
||||
phone: form_data.phone,
|
||||
date_of_birth: form_data.date_of_birth,
|
||||
nationality: form_data.nationality,
|
||||
passport_number: form_data.passport_number,
|
||||
passport_expiry: form_data.passport_expiry,
|
||||
current_address: form_data.current_address,
|
||||
city: form_data.city,
|
||||
country: form_data.country,
|
||||
postal_code: form_data.postal_code,
|
||||
occupation: form_data.occupation,
|
||||
employer: form_data.employer,
|
||||
annual_income: form_data.annual_income,
|
||||
education_level: form_data.education_level,
|
||||
selected_services: form_data.requested_services,
|
||||
payment_plan: form_data.payment_plan,
|
||||
registration_date: "2025-01-01".to_string(),
|
||||
status: crate::models::company::ResidentStatus::Pending,
|
||||
kyc_documents_uploaded: false,
|
||||
kyc_status: crate::models::company::KycStatus::NotStarted,
|
||||
public_key: form_data.public_key,
|
||||
};
|
||||
|
||||
link.send_message(ResidentWizardMsg::RegistrationComplete(resident));
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
ResidentWizardMsg::RegistrationComplete(resident) => {
|
||||
self.processing_registration = false;
|
||||
// Move to success step
|
||||
self.current_step = 6;
|
||||
// Notify parent component
|
||||
ctx.props().on_registration_complete.emit(resident);
|
||||
true
|
||||
}
|
||||
ResidentWizardMsg::RegistrationError(error) => {
|
||||
self.processing_registration = false;
|
||||
// Stay on payment step and show error
|
||||
self.validation_errors = vec![format!("Registration failed: {}", error)];
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(ResidentWizardMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
ResidentWizardMsg::ShowValidationToast(errors) => {
|
||||
self.validation_errors = errors;
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(ResidentWizardMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
ResidentWizardMsg::HideValidationToast => {
|
||||
self.show_validation_toast = false;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let (step_title, step_description, step_icon) = self.get_step_info();
|
||||
|
||||
html! {
|
||||
<div class="card" style="height: calc(100vh - 200px); display: flex; flex-direction: column;">
|
||||
<div class="card-header flex-shrink-0">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-1">
|
||||
<i class={format!("bi {} me-2", step_icon)}></i>{step_title}
|
||||
</h5>
|
||||
<p class="text-muted mb-0 small">{step_description}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm ms-3"
|
||||
onclick={ctx.props().on_back_to_parent.reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body flex-grow-1 overflow-auto">
|
||||
<form>
|
||||
{self.render_current_step(ctx)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{if self.current_step <= 5 {
|
||||
self.render_footer_navigation(ctx)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if self.show_validation_toast {
|
||||
self.render_validation_toast(ctx)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResidentWizard {
|
||||
fn render_current_step(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let form_data = self.form_data.clone();
|
||||
let on_form_update = link.callback(ResidentWizardMsg::UpdateFormData);
|
||||
|
||||
match self.current_step {
|
||||
1 => html! {
|
||||
<StepOne
|
||||
form_data={form_data}
|
||||
on_change={on_form_update}
|
||||
/>
|
||||
},
|
||||
2 => html! {
|
||||
<StepTwo
|
||||
form_data={form_data}
|
||||
on_change={on_form_update}
|
||||
/>
|
||||
},
|
||||
3 => html! {
|
||||
<StepThree
|
||||
form_data={form_data}
|
||||
on_change={on_form_update}
|
||||
/>
|
||||
},
|
||||
4 => html! {
|
||||
<StepFour
|
||||
form_data={form_data}
|
||||
on_change={on_form_update}
|
||||
/>
|
||||
},
|
||||
5 => html! {
|
||||
<StepFive
|
||||
form_data={form_data}
|
||||
on_change={on_form_update}
|
||||
/>
|
||||
},
|
||||
6 => {
|
||||
// Success step
|
||||
self.render_success_step(ctx)
|
||||
},
|
||||
_ => html! { <div>{"Invalid step"}</div> }
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer_navigation(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
// Previous button (left)
|
||||
<div style="width: 120px;">
|
||||
{if self.current_step > 1 {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={link.callback(|_| ResidentWizardMsg::PrevStep)}
|
||||
disabled={self.processing_registration}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Previous"}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Step indicator (center)
|
||||
<div class="d-flex align-items-center">
|
||||
{for (1..=5).map(|step| {
|
||||
let is_current = step == self.current_step;
|
||||
let is_completed = step < self.current_step;
|
||||
let step_class = if is_current {
|
||||
"bg-primary text-white"
|
||||
} else if is_completed {
|
||||
"bg-success text-white"
|
||||
} else {
|
||||
"bg-white text-muted border"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={format!("rounded-circle d-flex align-items-center justify-content-center {} fw-bold", step_class)}
|
||||
style="width: 28px; height: 28px; font-size: 12px;">
|
||||
{if is_completed {
|
||||
html! { <i class="bi bi-check"></i> }
|
||||
} else {
|
||||
html! { {step} }
|
||||
}}
|
||||
</div>
|
||||
{if step < 5 {
|
||||
html! {
|
||||
<div class={format!("mx-1 {}", if is_completed { "bg-success" } else { "bg-secondary" })}
|
||||
style="height: 2px; width: 24px;"></div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
// Next/Register button (right)
|
||||
<div style="width: 150px;" class="text-end">
|
||||
{if self.current_step < 5 {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={link.callback(|_| ResidentWizardMsg::NextStep)}
|
||||
disabled={self.processing_registration}
|
||||
>
|
||||
{"Next"}<i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
}
|
||||
} else if self.current_step == 5 {
|
||||
// Registration button for step 5
|
||||
let can_register = self.form_data.legal_agreements.all_agreed() && !self.processing_registration;
|
||||
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success text-nowrap"
|
||||
disabled={!can_register}
|
||||
onclick={link.callback(|_| ResidentWizardMsg::NextStep)}
|
||||
>
|
||||
{if self.processing_registration {
|
||||
html! {
|
||||
<>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
<span>{"Processing..."}</span>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
<span>{"Complete Registration"}</span>
|
||||
</>
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_validation_toast(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let close_toast = link.callback(|_| ResidentWizardMsg::HideValidationToast);
|
||||
|
||||
html! {
|
||||
<div class="position-fixed bottom-0 start-50 translate-middle-x mb-3" style="z-index: 1055; max-width: 500px;">
|
||||
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header bg-warning text-dark">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong class="me-auto">{"Required Fields Missing"}</strong>
|
||||
<button type="button" class="btn-close" onclick={close_toast} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div class="mb-2">
|
||||
<strong>{"Please complete all required fields to continue:"}</strong>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{for self.validation_errors.iter().map(|error| {
|
||||
html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-dot text-danger me-1"></i>{error}
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn get_step_info(&self) -> (&'static str, &'static str, &'static str) {
|
||||
match self.current_step {
|
||||
1 => (
|
||||
"Personal Information",
|
||||
"Provide your basic personal details for digital resident registration.",
|
||||
"bi-person"
|
||||
),
|
||||
2 => (
|
||||
"Address Information",
|
||||
"Enter your current and permanent address information.",
|
||||
"bi-house"
|
||||
),
|
||||
3 => (
|
||||
"Professional Information",
|
||||
"Share your professional background and qualifications.",
|
||||
"bi-briefcase"
|
||||
),
|
||||
4 => (
|
||||
"Digital Services & Preferences",
|
||||
"Select the digital services you'd like access to and set your preferences.",
|
||||
"bi-gear"
|
||||
),
|
||||
5 => (
|
||||
"Payment Plan & Legal Agreements",
|
||||
"Choose your payment plan and review the legal agreements.",
|
||||
"bi-credit-card"
|
||||
),
|
||||
6 => (
|
||||
"Registration Complete",
|
||||
"Your digital resident registration has been successfully completed.",
|
||||
"bi-check-circle-fill"
|
||||
),
|
||||
_ => (
|
||||
"Digital Resident Registration",
|
||||
"Complete the registration process to become a digital resident.",
|
||||
"bi-person-plus"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_success_step(&self, ctx: &Context<Self>) -> Html {
|
||||
let resident_id = ctx.props().success_resident_id.unwrap_or(1);
|
||||
|
||||
html! {
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="text-success mb-3">{"Registration Successful!"}</h2>
|
||||
<p class="lead mb-4">
|
||||
{"Your digital resident registration has been successfully submitted and is now pending approval."}
|
||||
</p>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card border-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success">
|
||||
<i class="bi bi-info-circle me-2"></i>{"What happens next?"}
|
||||
</h5>
|
||||
<div class="text-start">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-success rounded-pill">{"1"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Identity Verification"}</strong>
|
||||
<p class="mb-0 text-muted">{"Our team will verify your identity and submitted documents."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-primary rounded-pill">{"2"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Background Check"}</strong>
|
||||
<p class="mb-0 text-muted">{"We'll conduct necessary background checks and compliance verification."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-info rounded-pill">{"3"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Approval & Activation"}</strong>
|
||||
<p class="mb-0 text-muted">{"Once approved, your digital resident status will be activated and you'll gain access to selected services."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
onclick={ctx.props().on_back_to_parent.reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-list me-2"></i>{"View My Registrations"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-envelope me-2"></i>
|
||||
{"You will receive email updates about your registration status. The approval process typically takes 3-5 business days."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_current_step(&self) -> ValidationResult {
|
||||
match self.current_step {
|
||||
1 => validate_step_one(&self.form_data),
|
||||
2 => validate_step_two(&self.form_data),
|
||||
3 => validate_step_three(&self.form_data),
|
||||
4 => validate_step_four(&self.form_data),
|
||||
5 => validate_step_five(&self.form_data),
|
||||
_ => ValidationResult::valid(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ValidationResult {
|
||||
pub is_valid: bool,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
pub fn valid() -> Self {
|
||||
Self {
|
||||
is_valid: true,
|
||||
errors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid(errors: Vec<String>) -> Self {
|
||||
Self {
|
||||
is_valid: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation functions for each step
|
||||
fn validate_step_one(data: &DigitalResidentFormData) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if data.full_name.trim().is_empty() {
|
||||
errors.push("Full name is required".to_string());
|
||||
}
|
||||
|
||||
if data.email.trim().is_empty() {
|
||||
errors.push("Email address is required".to_string());
|
||||
} else if !data.email.contains('@') {
|
||||
errors.push("Please enter a valid email address".to_string());
|
||||
}
|
||||
|
||||
if data.phone.trim().is_empty() {
|
||||
errors.push("Phone number is required".to_string());
|
||||
}
|
||||
|
||||
if data.date_of_birth.trim().is_empty() {
|
||||
errors.push("Date of birth is required".to_string());
|
||||
}
|
||||
|
||||
if data.nationality.trim().is_empty() {
|
||||
errors.push("Nationality is required".to_string());
|
||||
}
|
||||
|
||||
if data.passport_number.trim().is_empty() {
|
||||
errors.push("Passport number is required".to_string());
|
||||
}
|
||||
|
||||
if data.passport_expiry.trim().is_empty() {
|
||||
errors.push("Passport expiry date is required".to_string());
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::valid()
|
||||
} else {
|
||||
ValidationResult::invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_step_two(data: &DigitalResidentFormData) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if data.current_address.trim().is_empty() {
|
||||
errors.push("Current address is required".to_string());
|
||||
}
|
||||
|
||||
if data.city.trim().is_empty() {
|
||||
errors.push("City is required".to_string());
|
||||
}
|
||||
|
||||
if data.country.trim().is_empty() {
|
||||
errors.push("Country is required".to_string());
|
||||
}
|
||||
|
||||
if data.postal_code.trim().is_empty() {
|
||||
errors.push("Postal code is required".to_string());
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::valid()
|
||||
} else {
|
||||
ValidationResult::invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_step_three(data: &DigitalResidentFormData) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if data.occupation.trim().is_empty() {
|
||||
errors.push("Occupation is required".to_string());
|
||||
}
|
||||
|
||||
if data.education_level.trim().is_empty() {
|
||||
errors.push("Education level is required".to_string());
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::valid()
|
||||
} else {
|
||||
ValidationResult::invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_step_four(data: &DigitalResidentFormData) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if data.requested_services.is_empty() {
|
||||
errors.push("Please select at least one digital service".to_string());
|
||||
}
|
||||
|
||||
if data.preferred_language.trim().is_empty() {
|
||||
errors.push("Preferred language is required".to_string());
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::valid()
|
||||
} else {
|
||||
ValidationResult::invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_step_five(data: &DigitalResidentFormData) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if !data.legal_agreements.all_agreed() {
|
||||
let missing = data.legal_agreements.missing_agreements();
|
||||
errors.push(format!("Please accept all required agreements: {}", missing.join(", ")));
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::valid()
|
||||
} else {
|
||||
ValidationResult::invalid(errors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::timers::callback::Timeout;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{console, js_sys};
|
||||
use serde_json::json;
|
||||
use crate::models::company::{DigitalResidentFormData, DigitalResident, ResidentPaymentPlan};
|
||||
use crate::services::{ResidentService, ResidentRegistration, ResidentRegistrationStatus};
|
||||
use super::{SimpleStepInfo, StepPaymentStripe};
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = window)]
|
||||
fn createPaymentIntent(form_data: &JsValue) -> js_sys::Promise;
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SimpleResidentWizardProps {
|
||||
pub on_registration_complete: Callback<DigitalResident>,
|
||||
pub on_back_to_parent: Callback<()>,
|
||||
#[prop_or_default]
|
||||
pub success_resident_id: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub show_failure: bool,
|
||||
}
|
||||
|
||||
pub enum SimpleResidentWizardMsg {
|
||||
NextStep,
|
||||
PrevStep,
|
||||
UpdateFormData(DigitalResidentFormData),
|
||||
ProcessRegistration,
|
||||
RegistrationComplete(DigitalResident),
|
||||
RegistrationError(String),
|
||||
HideValidationToast,
|
||||
ProcessPayment,
|
||||
PaymentPlanChanged(ResidentPaymentPlan),
|
||||
ConfirmationChanged(bool),
|
||||
CreatePaymentIntent,
|
||||
PaymentIntentCreated(String),
|
||||
PaymentIntentError(String),
|
||||
}
|
||||
|
||||
pub struct SimpleResidentWizard {
|
||||
current_step: u8,
|
||||
form_data: DigitalResidentFormData,
|
||||
validation_errors: Vec<String>,
|
||||
processing_registration: bool,
|
||||
show_validation_toast: bool,
|
||||
current_registration_id: Option<u32>,
|
||||
client_secret: Option<String>,
|
||||
processing_payment: bool,
|
||||
confirmation_checked: bool,
|
||||
}
|
||||
|
||||
impl Component for SimpleResidentWizard {
|
||||
type Message = SimpleResidentWizardMsg;
|
||||
type Properties = SimpleResidentWizardProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
// Determine initial step based on props
|
||||
let (form_data, current_step) = if ctx.props().success_resident_id.is_some() {
|
||||
// Show success step
|
||||
(DigitalResidentFormData::default(), 3)
|
||||
} else if ctx.props().show_failure {
|
||||
// Show failure, go back to payment step
|
||||
let (form_data, _) = ResidentService::load_resident_registration_form()
|
||||
.unwrap_or_else(|| (DigitalResidentFormData::default(), 2));
|
||||
(form_data, 2)
|
||||
} else {
|
||||
// Normal flow - try to load saved form data
|
||||
let (form_data, saved_step) = ResidentService::load_resident_registration_form()
|
||||
.unwrap_or_else(|| (DigitalResidentFormData::default(), 1));
|
||||
// Ensure step is within valid range for 2-step form
|
||||
let adjusted_step = if saved_step > 2 { 2 } else { saved_step };
|
||||
(form_data, adjusted_step)
|
||||
};
|
||||
|
||||
Self {
|
||||
current_step,
|
||||
form_data,
|
||||
validation_errors: Vec::new(),
|
||||
processing_registration: false,
|
||||
show_validation_toast: false,
|
||||
current_registration_id: None,
|
||||
client_secret: None,
|
||||
processing_payment: false,
|
||||
confirmation_checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
SimpleResidentWizardMsg::NextStep => {
|
||||
// Validate current step
|
||||
let validation_result = ResidentService::validate_resident_step(&self.form_data, self.current_step);
|
||||
if !validation_result.is_valid {
|
||||
self.validation_errors = validation_result.errors;
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(SimpleResidentWizardMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.current_step < 3 {
|
||||
if self.current_step == 2 {
|
||||
// Process registration on final step
|
||||
ctx.link().send_message(SimpleResidentWizardMsg::ProcessRegistration);
|
||||
} else {
|
||||
self.current_step += 1;
|
||||
// If moving to payment step, create payment intent
|
||||
if self.current_step == 2 {
|
||||
ctx.link().send_message(SimpleResidentWizardMsg::CreatePaymentIntent);
|
||||
}
|
||||
self.auto_save();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
SimpleResidentWizardMsg::PrevStep => {
|
||||
if self.current_step > 1 {
|
||||
self.current_step -= 1;
|
||||
self.auto_save();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
SimpleResidentWizardMsg::UpdateFormData(new_form_data) => {
|
||||
self.form_data = new_form_data;
|
||||
self.schedule_auto_save(ctx);
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::ProcessRegistration => {
|
||||
self.processing_registration = true;
|
||||
|
||||
// Simulate registration processing
|
||||
let link = ctx.link().clone();
|
||||
let form_data = self.form_data.clone();
|
||||
let registration_id = self.current_registration_id;
|
||||
|
||||
Timeout::new(2000, move || {
|
||||
// Create resident and update registration status
|
||||
match ResidentService::create_resident_from_form(&form_data) {
|
||||
Ok(resident) => {
|
||||
// Update registration status to PendingApproval
|
||||
if let Some(reg_id) = registration_id {
|
||||
let mut registrations = ResidentService::get_resident_registrations();
|
||||
if let Some(registration) = registrations.iter_mut().find(|r| r.id == reg_id) {
|
||||
registration.status = ResidentRegistrationStatus::PendingApproval;
|
||||
let _ = ResidentService::save_resident_registrations(®istrations);
|
||||
}
|
||||
} else {
|
||||
// Create new registration if none exists
|
||||
let now = js_sys::Date::new_0();
|
||||
let created_at = format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
now.get_full_year(),
|
||||
now.get_month() + 1,
|
||||
now.get_date()
|
||||
);
|
||||
|
||||
let registration = ResidentRegistration {
|
||||
id: 0, // Will be set by save_resident_registration
|
||||
full_name: form_data.full_name.clone(),
|
||||
email: form_data.email.clone(),
|
||||
status: ResidentRegistrationStatus::PendingApproval,
|
||||
created_at,
|
||||
form_data: form_data.clone(),
|
||||
current_step: 3, // Completed
|
||||
};
|
||||
|
||||
let _ = ResidentService::save_resident_registration(registration);
|
||||
}
|
||||
|
||||
// Clear saved form data
|
||||
let _ = ResidentService::clear_resident_registration_form();
|
||||
|
||||
link.send_message(SimpleResidentWizardMsg::RegistrationComplete(resident));
|
||||
}
|
||||
Err(error) => {
|
||||
link.send_message(SimpleResidentWizardMsg::RegistrationError(error));
|
||||
}
|
||||
}
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::RegistrationComplete(resident) => {
|
||||
self.processing_registration = false;
|
||||
// Move to success step
|
||||
self.current_step = 3;
|
||||
// Notify parent component
|
||||
ctx.props().on_registration_complete.emit(resident);
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::RegistrationError(error) => {
|
||||
self.processing_registration = false;
|
||||
// Stay on payment step and show error
|
||||
self.validation_errors = vec![format!("Registration failed: {}", error)];
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(SimpleResidentWizardMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::HideValidationToast => {
|
||||
self.show_validation_toast = false;
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::ProcessPayment => {
|
||||
self.processing_payment = true;
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::PaymentPlanChanged(plan) => {
|
||||
self.form_data.payment_plan = plan;
|
||||
self.client_secret = None; // Reset client secret when plan changes
|
||||
ctx.link().send_message(SimpleResidentWizardMsg::CreatePaymentIntent);
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::ConfirmationChanged(checked) => {
|
||||
self.confirmation_checked = checked;
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::CreatePaymentIntent => {
|
||||
console::log_1(&"🔧 Creating payment intent for resident registration...".into());
|
||||
self.create_payment_intent(ctx);
|
||||
false
|
||||
}
|
||||
SimpleResidentWizardMsg::PaymentIntentCreated(client_secret) => {
|
||||
self.client_secret = Some(client_secret);
|
||||
true
|
||||
}
|
||||
SimpleResidentWizardMsg::PaymentIntentError(error) => {
|
||||
self.validation_errors = vec![format!("Payment setup failed: {}", error)];
|
||||
self.show_validation_toast = true;
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(5000, move || {
|
||||
link.send_message(SimpleResidentWizardMsg::HideValidationToast);
|
||||
}).forget();
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let (step_title, step_description, step_icon) = self.get_step_info();
|
||||
|
||||
html! {
|
||||
<div class="card" style="height: calc(100vh - 200px); display: flex; flex-direction: column;">
|
||||
<div class="card-header flex-shrink-0">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-1">
|
||||
<i class={format!("bi {} me-2", step_icon)}></i>{step_title}
|
||||
</h5>
|
||||
<p class="text-muted mb-0 small">{step_description}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm ms-3"
|
||||
onclick={ctx.props().on_back_to_parent.reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body flex-grow-1 overflow-auto">
|
||||
<form>
|
||||
{self.render_current_step(ctx)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{if self.current_step <= 2 {
|
||||
self.render_footer_navigation(ctx)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if self.show_validation_toast {
|
||||
self.render_validation_toast(ctx)
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SimpleResidentWizard {
|
||||
fn render_current_step(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let form_data = self.form_data.clone();
|
||||
let on_form_update = link.callback(SimpleResidentWizardMsg::UpdateFormData);
|
||||
|
||||
match self.current_step {
|
||||
1 => html! {
|
||||
<SimpleStepInfo
|
||||
form_data={form_data}
|
||||
on_change={on_form_update}
|
||||
/>
|
||||
},
|
||||
2 => html! {
|
||||
<StepPaymentStripe
|
||||
form_data={form_data}
|
||||
client_secret={self.client_secret.clone()}
|
||||
processing_payment={self.processing_payment}
|
||||
on_process_payment={link.callback(|_| SimpleResidentWizardMsg::ProcessPayment)}
|
||||
on_payment_complete={link.callback(SimpleResidentWizardMsg::RegistrationComplete)}
|
||||
on_payment_error={link.callback(SimpleResidentWizardMsg::RegistrationError)}
|
||||
on_payment_plan_change={link.callback(SimpleResidentWizardMsg::PaymentPlanChanged)}
|
||||
on_confirmation_change={link.callback(SimpleResidentWizardMsg::ConfirmationChanged)}
|
||||
/>
|
||||
},
|
||||
3 => {
|
||||
// Success step
|
||||
self.render_success_step(ctx)
|
||||
},
|
||||
_ => html! { <div>{"Invalid step"}</div> }
|
||||
}
|
||||
}
|
||||
|
||||
fn render_footer_navigation(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
// Previous button (left)
|
||||
<div style="width: 120px;">
|
||||
{if self.current_step > 1 {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={link.callback(|_| SimpleResidentWizardMsg::PrevStep)}
|
||||
disabled={self.processing_registration}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Previous"}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Step indicator (center)
|
||||
<div class="d-flex align-items-center">
|
||||
{for (1..=2).map(|step| {
|
||||
let is_current = step == self.current_step;
|
||||
let is_completed = step < self.current_step;
|
||||
let step_class = if is_current {
|
||||
"bg-primary text-white"
|
||||
} else if is_completed {
|
||||
"bg-success text-white"
|
||||
} else {
|
||||
"bg-white text-muted border"
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={format!("rounded-circle d-flex align-items-center justify-content-center {} fw-bold", step_class)}
|
||||
style="width: 28px; height: 28px; font-size: 12px;">
|
||||
{if is_completed {
|
||||
html! { <i class="bi bi-check"></i> }
|
||||
} else {
|
||||
html! { {step} }
|
||||
}}
|
||||
</div>
|
||||
{if step < 2 {
|
||||
html! {
|
||||
<div class={format!("mx-1 {}", if is_completed { "bg-success" } else { "bg-secondary" })}
|
||||
style="height: 2px; width: 24px;"></div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
// Next/Register button (right)
|
||||
<div style="width: 150px;" class="text-end">
|
||||
{if self.current_step < 2 {
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={link.callback(|_| SimpleResidentWizardMsg::NextStep)}
|
||||
disabled={self.processing_registration}
|
||||
>
|
||||
{"Next"}<i class="bi bi-arrow-right ms-1"></i>
|
||||
</button>
|
||||
}
|
||||
} else if self.current_step == 2 {
|
||||
// Payment is handled by the StepPaymentStripe component itself
|
||||
// No button needed here as the payment component has its own payment button
|
||||
html! {}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_validation_toast(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let close_toast = link.callback(|_| SimpleResidentWizardMsg::HideValidationToast);
|
||||
|
||||
html! {
|
||||
<div class="position-fixed bottom-0 start-50 translate-middle-x mb-3" style="z-index: 1055; max-width: 500px;">
|
||||
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header bg-warning text-dark">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong class="me-auto">{"Required Fields Missing"}</strong>
|
||||
<button type="button" class="btn-close" onclick={close_toast} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div class="mb-2">
|
||||
<strong>{"Please complete all required fields to continue:"}</strong>
|
||||
</div>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{for self.validation_errors.iter().map(|error| {
|
||||
html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-dot text-danger me-1"></i>{error}
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn get_step_info(&self) -> (&'static str, &'static str, &'static str) {
|
||||
match self.current_step {
|
||||
1 => (
|
||||
"Personal Information & KYC",
|
||||
"Provide your basic information and complete identity verification.",
|
||||
"bi-person-vcard"
|
||||
),
|
||||
2 => (
|
||||
"Payment Plan & Legal Agreements",
|
||||
"Choose your payment plan and review the legal agreements.",
|
||||
"bi-credit-card"
|
||||
),
|
||||
3 => (
|
||||
"Registration Complete",
|
||||
"Your digital resident registration has been successfully completed.",
|
||||
"bi-check-circle-fill"
|
||||
),
|
||||
_ => (
|
||||
"Digital Resident Registration",
|
||||
"Complete the registration process to become a digital resident.",
|
||||
"bi-person-plus"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_payment_intent(&self, ctx: &Context<Self>) {
|
||||
let link = ctx.link().clone();
|
||||
let form_data = self.form_data.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
match Self::setup_stripe_payment(form_data).await {
|
||||
Ok(client_secret) => {
|
||||
link.send_message(SimpleResidentWizardMsg::PaymentIntentCreated(client_secret));
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(SimpleResidentWizardMsg::PaymentIntentError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn setup_stripe_payment(form_data: DigitalResidentFormData) -> Result<String, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||
|
||||
console::log_1(&"🔧 Setting up Stripe payment for resident registration".into());
|
||||
console::log_1(&format!("📋 Resident: {}", form_data.full_name).into());
|
||||
console::log_1(&format!("💳 Payment plan: {}", form_data.payment_plan.get_display_name()).into());
|
||||
|
||||
// Prepare form data for payment intent creation
|
||||
let payment_data = json!({
|
||||
"resident_name": form_data.full_name,
|
||||
"email": form_data.email,
|
||||
"phone": form_data.phone,
|
||||
"date_of_birth": form_data.date_of_birth,
|
||||
"nationality": form_data.nationality,
|
||||
"passport_number": form_data.passport_number,
|
||||
"address": form_data.current_address,
|
||||
"payment_plan": form_data.payment_plan.get_display_name(),
|
||||
"amount": form_data.payment_plan.get_price(),
|
||||
"type": "resident_registration"
|
||||
});
|
||||
|
||||
console::log_1(&"📡 Calling server endpoint for resident payment intent creation".into());
|
||||
|
||||
// Create request to server endpoint
|
||||
let mut opts = RequestInit::new();
|
||||
opts.method("POST");
|
||||
opts.mode(RequestMode::Cors);
|
||||
|
||||
let headers = js_sys::Map::new();
|
||||
headers.set(&"Content-Type".into(), &"application/json".into());
|
||||
opts.headers(&headers);
|
||||
|
||||
opts.body(Some(&JsValue::from_str(&payment_data.to_string())));
|
||||
|
||||
let request = Request::new_with_str_and_init(
|
||||
"http://127.0.0.1:3001/resident/create-payment-intent",
|
||||
&opts,
|
||||
).map_err(|e| {
|
||||
let error_msg = format!("Failed to create request: {:?}", e);
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
// Make the request
|
||||
let window = web_sys::window().unwrap();
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Network request failed: {:?}", e);
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
let resp: Response = resp_value.dyn_into().unwrap();
|
||||
|
||||
if !resp.ok() {
|
||||
let status = resp.status();
|
||||
let error_msg = format!("Server error: HTTP {}", status);
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
// Parse response
|
||||
let json_value = JsFuture::from(resp.json().unwrap()).await
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to parse response: {:?}", e);
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
// Extract client secret from response
|
||||
let response_obj = js_sys::Object::from(json_value);
|
||||
let client_secret_value = js_sys::Reflect::get(&response_obj, &"client_secret".into())
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("No client_secret in response: {:?}", e);
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
let client_secret = client_secret_value.as_string()
|
||||
.ok_or_else(|| {
|
||||
let error_msg = "Invalid client secret received from server";
|
||||
console::log_1(&format!("❌ {}", error_msg).into());
|
||||
error_msg.to_string()
|
||||
})?;
|
||||
|
||||
console::log_1(&"✅ Payment intent created successfully".into());
|
||||
console::log_1(&format!("🔑 Client secret received: {}", if client_secret.len() > 10 { "Yes" } else { "No" }).into());
|
||||
Ok(client_secret)
|
||||
}
|
||||
|
||||
fn render_success_step(&self, ctx: &Context<Self>) -> Html {
|
||||
let resident_id = ctx.props().success_resident_id.unwrap_or(1);
|
||||
|
||||
html! {
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-check-circle-fill text-success" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="text-success mb-3">{"Registration Successful!"}</h2>
|
||||
<p class="lead mb-4">
|
||||
{"Your digital resident registration has been successfully submitted and is now pending approval."}
|
||||
</p>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card border-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success">
|
||||
<i class="bi bi-info-circle me-2"></i>{"What happens next?"}
|
||||
</h5>
|
||||
<div class="text-start">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-success rounded-pill">{"1"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Identity Verification"}</strong>
|
||||
<p class="mb-0 text-muted">{"Our team will verify your identity and submitted documents."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-primary rounded-pill">{"2"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Background Check"}</strong>
|
||||
<p class="mb-0 text-muted">{"We'll conduct necessary background checks and compliance verification."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-info rounded-pill">{"3"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{"Approval & Activation"}</strong>
|
||||
<p class="mb-0 text-muted">{"Once approved, your digital resident status will be activated and you'll gain access to selected services."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-center">
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
onclick={ctx.props().on_back_to_parent.reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-list me-2"></i>{"View My Registrations"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-envelope me-2"></i>
|
||||
{"You will receive email updates about your registration status. The approval process typically takes 3-5 business days."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_auto_save(&mut self, ctx: &Context<Self>) {
|
||||
// Auto-save after 2 seconds of inactivity
|
||||
let link = ctx.link().clone();
|
||||
Timeout::new(2000, move || {
|
||||
// Auto-save will be handled by the auto_save method
|
||||
}).forget();
|
||||
|
||||
self.auto_save();
|
||||
}
|
||||
|
||||
fn auto_save(&mut self) {
|
||||
// Save form data to localStorage for recovery
|
||||
let _ = ResidentService::save_resident_registration_form(&self.form_data, self.current_step);
|
||||
|
||||
// Also save as a draft registration
|
||||
let now = js_sys::Date::new_0();
|
||||
let created_at = format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
now.get_full_year(),
|
||||
now.get_month() + 1,
|
||||
now.get_date()
|
||||
);
|
||||
|
||||
let status = if self.current_step >= 2 {
|
||||
ResidentRegistrationStatus::PendingPayment
|
||||
} else {
|
||||
ResidentRegistrationStatus::Draft
|
||||
};
|
||||
|
||||
let registration = ResidentRegistration {
|
||||
id: self.current_registration_id.unwrap_or(0),
|
||||
full_name: if self.form_data.full_name.is_empty() {
|
||||
"Draft Registration".to_string()
|
||||
} else {
|
||||
self.form_data.full_name.clone()
|
||||
},
|
||||
email: self.form_data.email.clone(),
|
||||
status,
|
||||
created_at,
|
||||
form_data: self.form_data.clone(),
|
||||
current_step: self.current_step,
|
||||
};
|
||||
|
||||
if let Ok(saved_registration) = ResidentService::save_resident_registration(registration) {
|
||||
self.current_registration_id = Some(saved_registration.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::company::DigitalResidentFormData;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SimpleStepInfoProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(SimpleStepInfo)]
|
||||
pub fn simple_step_info(props: &SimpleStepInfoProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
let show_private_key = use_state(|| false);
|
||||
|
||||
let on_input = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let field_name = input.name();
|
||||
let value = input.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
match field_name.as_str() {
|
||||
"full_name" => updated_data.full_name = value,
|
||||
"email" => updated_data.email = value,
|
||||
_ => {}
|
||||
}
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_terms_change = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |_: Event| {
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.legal_agreements.terms = !updated_data.legal_agreements.terms;
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_kyc_click = {
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
// TODO: Redirect to KYC provider
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message("KYC verification will be implemented - redirecting to identity verification provider")
|
||||
.unwrap();
|
||||
})
|
||||
};
|
||||
|
||||
let on_generate_keys = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
let show_private_key = show_private_key.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
// Generate secp256k1 keypair (simplified for demo)
|
||||
let private_key = generate_private_key();
|
||||
let public_key = generate_public_key(&private_key);
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.public_key = Some(public_key);
|
||||
updated_data.private_key = Some(private_key);
|
||||
updated_data.private_key_shown = true;
|
||||
|
||||
show_private_key.set(true);
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let copy_private_key = {
|
||||
let private_key = form_data.private_key.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(key) = &private_key {
|
||||
// Copy to clipboard using a simple approach
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message(&format!("Private key copied! Please save it: {}", key))
|
||||
.unwrap();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div class="row h-100">
|
||||
// Left side - Form inputs
|
||||
<div class="col-md-6">
|
||||
<div class="mb-4">
|
||||
<label for="full_name" class="form-label">{"Full Name"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-lg"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
value={form_data.full_name.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter your full legal name"
|
||||
title="As it appears on your government-issued ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="email" class="form-label">{"Email Address"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control form-control-lg"
|
||||
id="email"
|
||||
name="email"
|
||||
value={form_data.email.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="your.email@example.com"
|
||||
title="We'll use this to send you updates about your application"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">{"Identity Verification"} <span class="text-danger">{"*"}</span></label>
|
||||
<div class="d-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary btn-lg"
|
||||
onclick={on_kyc_click}
|
||||
>
|
||||
<i class="bi bi-shield-check me-2"></i>
|
||||
{"Complete KYC Verification"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">{"Digital Identity Keys"}</label>
|
||||
{if form_data.public_key.is_none() {
|
||||
html! {
|
||||
<div class="d-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-lg"
|
||||
onclick={on_generate_keys}
|
||||
>
|
||||
<i class="bi bi-key me-2"></i>
|
||||
{"Generate Keys"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div>
|
||||
{if *show_private_key && form_data.private_key.is_some() {
|
||||
html! {
|
||||
<div class="mb-3 p-3 bg-warning bg-opacity-10 border border-warning rounded">
|
||||
<strong class="text-warning">{"Private Key (save securely!):"}</strong>
|
||||
<div class="mt-2 p-2 border rounded" style="font-family: monospace; font-size: 0.9rem; word-break: break-all;">
|
||||
{form_data.private_key.as_ref().unwrap_or(&"".to_string())}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">{"Public Key"}</label>
|
||||
<div class="form-control" style="font-family: monospace; font-size: 0.8rem; word-break: break-all;">
|
||||
{form_data.public_key.as_ref().unwrap_or(&"".to_string())}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
onclick={on_generate_keys}
|
||||
>
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
{"Generate New Keys"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="terms_agreement"
|
||||
checked={form_data.legal_agreements.terms}
|
||||
onchange={on_terms_change}
|
||||
/>
|
||||
<label class="form-check-label" for="terms_agreement">
|
||||
{"I agree to the "}<a href="#" class="text-primary">{"Terms of Service"}</a>{" and "}<a href="#" class="text-primary">{"Privacy Policy"}</a> <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Right side - Residence card preview
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center justify-content-center h-100">
|
||||
<div class="residence-card">
|
||||
<div class="card border-0 shadow-lg" style="width: 350px; background: linear-gradient(135deg, #0099FF 0%, #00CC66 100%); color: white; border-radius: 15px;">
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h6 class="mb-0 text-white-50">{"DIGITAL RESIDENT"}</h6>
|
||||
<small class="text-white-50">{"Zanzibar Digital Freezone"}</small>
|
||||
</div>
|
||||
<i class="bi bi-shield-check-fill" style="font-size: 1.5rem; opacity: 0.8;"></i>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-white-50 small">{"FULL NAME"}</div>
|
||||
<div class="h5 mb-0 text-white">
|
||||
{if form_data.full_name.is_empty() {
|
||||
"Your Name Here"
|
||||
} else {
|
||||
&form_data.full_name
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-white-50 small">{"EMAIL"}</div>
|
||||
<div class="text-white" style="font-size: 0.9rem;">
|
||||
{if form_data.email.is_empty() {
|
||||
"your.email@example.com"
|
||||
} else {
|
||||
&form_data.email
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if let Some(public_key) = &form_data.public_key {
|
||||
html! {
|
||||
<div class="mb-3">
|
||||
<div class="text-white-50 small">
|
||||
<i class="bi bi-key me-1"></i>
|
||||
{"PUBLIC KEY"}
|
||||
</div>
|
||||
<div class="text-white" style="font-size: 0.7rem; font-family: monospace; word-break: break-all;">
|
||||
{&public_key[..std::cmp::min(24, public_key.len())]}{"..."}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-end mb-3">
|
||||
<div>
|
||||
<div class="text-white-50 small">{"RESIDENT ID"}</div>
|
||||
<div class="text-white">{"ZDF-2025-****"}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="text-white-50 small">{"STATUS"}</div>
|
||||
<div class="badge bg-warning text-dark">{"PENDING"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// QR Code at bottom
|
||||
<div class="text-center border-top border-white border-opacity-25 pt-3">
|
||||
<div class="d-inline-block p-2 rounded">
|
||||
<div style="width: 60px; height: 60px; background: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjYwIiBoZWlnaHQ9IjYwIiBmaWxsPSJ3aGl0ZSIvPgo8cmVjdCB4PSI0IiB5PSI0IiB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjIwIiB5PSI0IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSIyOCIgeT0iNCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDQiIHk9IjQiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iOCIgeT0iOCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0id2hpdGUiLz4KPHJlY3QgeD0iNDgiIHk9IjgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9IndoaXRlIi8+CjxyZWN0IHg9IjIwIiB5PSIxMiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMzYiIHk9IjEyIiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI0IiB5PSIyMCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMTIiIHk9IjIwIiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSIyMCIgeT0iMjAiIHdpZHRoPSI4IiBoZWlnaHQ9IjgiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjM2IiB5PSIyMCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDQiIHk9IjIwIiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI1MiIgeT0iMjAiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjI0IiB5PSIyNCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0id2hpdGUiLz4KPHJlY3QgeD0iNCIgeT0iMjgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjEyIiB5PSIyOCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMzYiIHk9IjI4IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI0NCIgeT0iMjgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjUyIiB5PSIyOCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNCIgeT0iMzYiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjEyIiB5PSIzNiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMjAiIHk9IjM2IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSIyOCIgeT0iMzYiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjM2IiB5PSIzNiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDQiIHk9IjM2IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI1MiIgeT0iMzYiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjQiIHk9IjQ0IiB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIGZpbGw9ImJsYWNrIi8+CjxyZWN0IHg9IjIwIiB5PSI0NCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iMjgiIHk9IjQ0IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJibGFjayIvPgo8cmVjdCB4PSI0NCIgeT0iNDQiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iOCIgeT0iNDgiIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIGZpbGw9IndoaXRlIi8+CjxyZWN0IHg9IjIwIiB5PSI1MiIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0iYmxhY2siLz4KPHJlY3QgeD0iNDgiIHk9IjQ4IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K') no-repeat center; background-size: contain;"></div>
|
||||
</div>
|
||||
<div class="text-white-50 small mt-2">{"Scan to verify"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified key generation functions (for demo purposes)
|
||||
fn generate_private_key() -> String {
|
||||
// In a real implementation, this would use proper secp256k1 key generation
|
||||
// For demo purposes, we'll generate a hex string
|
||||
use js_sys::Math;
|
||||
let mut key = String::new();
|
||||
for _ in 0..64 {
|
||||
let digit = (Math::random() * 16.0) as u8;
|
||||
key.push_str(&format!("{:x}", digit));
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
fn generate_public_key(private_key: &str) -> String {
|
||||
// In a real implementation, this would derive the public key from the private key
|
||||
// For demo purposes, we'll generate a different hex string
|
||||
use js_sys::Math;
|
||||
let mut key = String::from("04"); // Uncompressed public key prefix
|
||||
for _ in 0..128 {
|
||||
let digit = (Math::random() * 16.0) as u8;
|
||||
key.push_str(&format!("{:x}", digit));
|
||||
}
|
||||
key
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::company::{DigitalResidentFormData, ResidentPaymentPlan, LegalAgreements};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepFiveProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(StepFive)]
|
||||
pub fn step_five(props: &StepFiveProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
|
||||
let payment_plans = vec![
|
||||
ResidentPaymentPlan::Monthly,
|
||||
ResidentPaymentPlan::Yearly,
|
||||
ResidentPaymentPlan::Lifetime,
|
||||
];
|
||||
|
||||
let select_payment_plan = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |plan: ResidentPaymentPlan| {
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.payment_plan = plan;
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let toggle_agreement = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |agreement_type: String| {
|
||||
let mut updated_data = form_data.clone();
|
||||
let mut agreements = updated_data.legal_agreements.clone();
|
||||
|
||||
match agreement_type.as_str() {
|
||||
"terms" => agreements.terms = !agreements.terms,
|
||||
"privacy" => agreements.privacy = !agreements.privacy,
|
||||
"compliance" => agreements.compliance = !agreements.compliance,
|
||||
"articles" => agreements.articles = !agreements.articles,
|
||||
"final_agreement" => agreements.final_agreement = !agreements.final_agreement,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
updated_data.legal_agreements = agreements;
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let calculate_savings = |plan: &ResidentPaymentPlan| -> Option<String> {
|
||||
match plan {
|
||||
ResidentPaymentPlan::Monthly => None,
|
||||
ResidentPaymentPlan::Yearly => {
|
||||
let monthly_total = ResidentPaymentPlan::Monthly.get_price() * 12.0;
|
||||
let yearly_price = plan.get_price();
|
||||
let savings = monthly_total - yearly_price;
|
||||
Some(format!("Save ${:.2}", savings))
|
||||
},
|
||||
ResidentPaymentPlan::Lifetime => {
|
||||
let monthly_total = ResidentPaymentPlan::Monthly.get_price() * 36.0; // 3 years
|
||||
let lifetime_price = plan.get_price();
|
||||
let savings = monthly_total - lifetime_price;
|
||||
Some(format!("Save ${:.2} over 3 years", savings))
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="step-header mb-4">
|
||||
<h3 class="step-title">{"Payment Plan & Legal Agreements"}</h3>
|
||||
<p class="step-description text-muted">
|
||||
{"Choose your payment plan and review the legal agreements to complete your registration."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<h5 class="mb-3">{"Select Payment Plan"}</h5>
|
||||
|
||||
<div class="row">
|
||||
{for payment_plans.iter().map(|plan| {
|
||||
let plan_clone = *plan;
|
||||
let is_selected = form_data.payment_plan == *plan;
|
||||
let select_callback = {
|
||||
let select_payment_plan = select_payment_plan.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
select_payment_plan.emit(plan_clone);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class={classes!(
|
||||
"card", "h-100", "payment-plan-card",
|
||||
if is_selected { "border-primary" } else { "" }
|
||||
)} style="cursor: pointer;" onclick={select_callback}>
|
||||
<div class="card-body text-center">
|
||||
<div class="form-check d-flex justify-content-center mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="payment_plan"
|
||||
checked={is_selected}
|
||||
readonly=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h5 class="card-title">{plan.get_display_name()}</h5>
|
||||
|
||||
<div class="price mb-2">
|
||||
<span class="h4 text-primary">{format!("${:.2}", plan.get_price())}</span>
|
||||
{match plan {
|
||||
ResidentPaymentPlan::Monthly => html! { <span class="text-muted">{"/month"}</span> },
|
||||
ResidentPaymentPlan::Yearly => html! { <span class="text-muted">{"/year"}</span> },
|
||||
ResidentPaymentPlan::Lifetime => html! { <span class="text-muted">{" once"}</span> },
|
||||
}}
|
||||
</div>
|
||||
|
||||
{if let Some(savings) = calculate_savings(plan) {
|
||||
html! {
|
||||
<div class="badge bg-success mb-2">{savings}</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<p class="card-text small text-muted">
|
||||
{plan.get_description()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">{"Legal Agreements"}</h5>
|
||||
<p class="text-muted mb-3">
|
||||
{"Please review and accept the following agreements to proceed:"}
|
||||
</p>
|
||||
|
||||
<div class="agreements-section">
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
checked={form_data.legal_agreements.terms}
|
||||
onclick={{
|
||||
let toggle = toggle_agreement.clone();
|
||||
Callback::from(move |_| toggle.emit("terms".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="terms">
|
||||
{"I agree to the "}
|
||||
<a href="#" class="text-primary">{"Terms of Service"}</a>
|
||||
{" and "}
|
||||
<a href="#" class="text-primary">{"User Agreement"}</a>
|
||||
<span class="text-danger">{" *"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="privacy"
|
||||
checked={form_data.legal_agreements.privacy}
|
||||
onclick={{
|
||||
let toggle = toggle_agreement.clone();
|
||||
Callback::from(move |_| toggle.emit("privacy".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="privacy">
|
||||
{"I acknowledge the "}
|
||||
<a href="#" class="text-primary">{"Privacy Policy"}</a>
|
||||
{" and consent to data processing"}
|
||||
<span class="text-danger">{" *"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="compliance"
|
||||
checked={form_data.legal_agreements.compliance}
|
||||
onclick={{
|
||||
let toggle = toggle_agreement.clone();
|
||||
Callback::from(move |_| toggle.emit("compliance".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="compliance">
|
||||
{"I agree to comply with "}
|
||||
<a href="#" class="text-primary">{"Digital Resident Regulations"}</a>
|
||||
{" and applicable laws"}
|
||||
<span class="text-danger">{" *"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="articles"
|
||||
checked={form_data.legal_agreements.articles}
|
||||
onclick={{
|
||||
let toggle = toggle_agreement.clone();
|
||||
Callback::from(move |_| toggle.emit("articles".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="articles">
|
||||
{"I accept the "}
|
||||
<a href="#" class="text-primary">{"Digital Resident Charter"}</a>
|
||||
{" and community guidelines"}
|
||||
<span class="text-danger">{" *"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="final_agreement"
|
||||
checked={form_data.legal_agreements.final_agreement}
|
||||
onclick={{
|
||||
let toggle = toggle_agreement.clone();
|
||||
Callback::from(move |_| toggle.emit("final_agreement".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="final_agreement">
|
||||
{"I confirm that all information provided is accurate and complete"}
|
||||
<span class="text-danger">{" *"}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="payment-summary p-4 rounded mb-4">
|
||||
<h6 class="mb-3">{"Payment Summary"}</h6>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>{"Digital Resident Registration"}</span>
|
||||
<span>{format!("${:.2}", form_data.payment_plan.get_price())}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>{"Selected Services"}</span>
|
||||
<span>{format!("{} services", form_data.requested_services.len())}</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="d-flex justify-content-between fw-bold">
|
||||
<span>{"Total"}</span>
|
||||
<span class="text-primary">{format!("${:.2}", form_data.payment_plan.get_price())}</span>
|
||||
</div>
|
||||
{if form_data.payment_plan != ResidentPaymentPlan::Monthly {
|
||||
html! {
|
||||
<div class="text-success small mt-2">
|
||||
{calculate_savings(&form_data.payment_plan).unwrap_or_default()}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
{if !form_data.legal_agreements.all_agreed() {
|
||||
html! {
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{"Please accept all required agreements to proceed with registration."}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="alert alert-success">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
{"All requirements met! You can now proceed to payment."}
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement};
|
||||
use crate::models::company::{DigitalResidentFormData, DigitalService, CommunicationPreferences};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepFourProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(StepFour)]
|
||||
pub fn step_four(props: &StepFourProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
|
||||
let available_services = vec![
|
||||
DigitalService::BankingAccess,
|
||||
DigitalService::TaxFiling,
|
||||
DigitalService::HealthcareAccess,
|
||||
DigitalService::EducationServices,
|
||||
DigitalService::BusinessLicensing,
|
||||
DigitalService::PropertyServices,
|
||||
DigitalService::LegalServices,
|
||||
DigitalService::DigitalIdentity,
|
||||
];
|
||||
|
||||
let toggle_service = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |service: DigitalService| {
|
||||
let mut updated_data = form_data.clone();
|
||||
if updated_data.requested_services.contains(&service) {
|
||||
updated_data.requested_services.retain(|s| s != &service);
|
||||
} else {
|
||||
updated_data.requested_services.push(service);
|
||||
}
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_language_change = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.preferred_language = select.value();
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let toggle_communication_pref = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |pref_type: String| {
|
||||
let mut updated_data = form_data.clone();
|
||||
let mut prefs = updated_data.communication_preferences.clone();
|
||||
|
||||
match pref_type.as_str() {
|
||||
"email" => prefs.email_notifications = !prefs.email_notifications,
|
||||
"sms" => prefs.sms_notifications = !prefs.sms_notifications,
|
||||
"push" => prefs.push_notifications = !prefs.push_notifications,
|
||||
"newsletter" => prefs.newsletter = !prefs.newsletter,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
updated_data.communication_preferences = prefs;
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="step-header mb-4">
|
||||
<h3 class="step-title">{"Digital Services & Preferences"}</h3>
|
||||
<p class="step-description text-muted">
|
||||
{"Select the digital services you're interested in and set your communication preferences."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<h5 class="mb-3">{"Requested Digital Services"}</h5>
|
||||
<p class="text-muted mb-3">
|
||||
{"Choose the services you'd like access to as a digital resident:"}
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
{for available_services.iter().map(|service| {
|
||||
let service_clone = service.clone();
|
||||
let is_selected = form_data.requested_services.contains(service);
|
||||
let toggle_callback = {
|
||||
let toggle_service = toggle_service.clone();
|
||||
let service = service.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
toggle_service.emit(service.clone());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class={classes!(
|
||||
"card", "h-100", "service-card",
|
||||
if is_selected { "border-primary" } else { "" }
|
||||
)} style="cursor: pointer;" onclick={toggle_callback}>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="form-check me-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
checked={is_selected}
|
||||
readonly=true
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class={classes!("bi", service.get_icon(), "me-2", "text-primary")}></i>
|
||||
<h6 class="card-title mb-0">{service.get_display_name()}</h6>
|
||||
</div>
|
||||
<p class="card-text small text-muted mb-0">
|
||||
{service.get_description()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">{"Language Preference"}</h5>
|
||||
<div class="col-md-6">
|
||||
<label for="preferred_language" class="form-label">
|
||||
{"Preferred Language"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="preferred_language"
|
||||
value={form_data.preferred_language.clone()}
|
||||
onchange={on_language_change}
|
||||
required=true
|
||||
>
|
||||
<option value="English">{"English"}</option>
|
||||
<option value="Spanish">{"Spanish"}</option>
|
||||
<option value="French">{"French"}</option>
|
||||
<option value="German">{"German"}</option>
|
||||
<option value="Italian">{"Italian"}</option>
|
||||
<option value="Portuguese">{"Portuguese"}</option>
|
||||
<option value="Dutch">{"Dutch"}</option>
|
||||
<option value="Arabic">{"Arabic"}</option>
|
||||
<option value="Chinese">{"Chinese"}</option>
|
||||
<option value="Japanese">{"Japanese"}</option>
|
||||
<option value="Korean">{"Korean"}</option>
|
||||
<option value="Russian">{"Russian"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">{"Communication Preferences"}</h5>
|
||||
<p class="text-muted mb-3">
|
||||
{"Choose how you'd like to receive updates and notifications:"}
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="email_notifications"
|
||||
checked={form_data.communication_preferences.email_notifications}
|
||||
onclick={{
|
||||
let toggle = toggle_communication_pref.clone();
|
||||
Callback::from(move |_| toggle.emit("email".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="email_notifications">
|
||||
<i class="bi bi-envelope me-2"></i>
|
||||
{"Email Notifications"}
|
||||
</label>
|
||||
<div class="form-text">
|
||||
{"Important updates and service notifications"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="sms_notifications"
|
||||
checked={form_data.communication_preferences.sms_notifications}
|
||||
onclick={{
|
||||
let toggle = toggle_communication_pref.clone();
|
||||
Callback::from(move |_| toggle.emit("sms".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="sms_notifications">
|
||||
<i class="bi bi-phone me-2"></i>
|
||||
{"SMS Notifications"}
|
||||
</label>
|
||||
<div class="form-text">
|
||||
{"Urgent alerts and security notifications"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="push_notifications"
|
||||
checked={form_data.communication_preferences.push_notifications}
|
||||
onclick={{
|
||||
let toggle = toggle_communication_pref.clone();
|
||||
Callback::from(move |_| toggle.emit("push".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="push_notifications">
|
||||
<i class="bi bi-bell me-2"></i>
|
||||
{"Push Notifications"}
|
||||
</label>
|
||||
<div class="form-text">
|
||||
{"Real-time updates in your browser"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="newsletter"
|
||||
checked={form_data.communication_preferences.newsletter}
|
||||
onclick={{
|
||||
let toggle = toggle_communication_pref.clone();
|
||||
Callback::from(move |_| toggle.emit("newsletter".to_string()))
|
||||
}}
|
||||
/>
|
||||
<label class="form-check-label" for="newsletter">
|
||||
<i class="bi bi-newspaper me-2"></i>
|
||||
{"Newsletter"}
|
||||
</label>
|
||||
<div class="form-text">
|
||||
{"Monthly updates and community news"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"You can modify your service selections and communication preferences at any time from your account settings."}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement};
|
||||
use crate::models::company::{DigitalResidentFormData, DigitalService};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepInfoKycProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(StepInfoKyc)]
|
||||
pub fn step_info_kyc(props: &StepInfoKycProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
|
||||
let update_field = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |field: String| {
|
||||
let mut updated_data = form_data.clone();
|
||||
// This will be called by individual field updates
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_input = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let field_name = input.name();
|
||||
let value = input.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
match field_name.as_str() {
|
||||
"full_name" => updated_data.full_name = value,
|
||||
"email" => updated_data.email = value,
|
||||
"phone" => updated_data.phone = value,
|
||||
"date_of_birth" => updated_data.date_of_birth = value,
|
||||
"nationality" => updated_data.nationality = value,
|
||||
"passport_number" => updated_data.passport_number = value,
|
||||
"passport_expiry" => updated_data.passport_expiry = value,
|
||||
"current_address" => updated_data.current_address = value,
|
||||
"city" => updated_data.city = value,
|
||||
"country" => updated_data.country = value,
|
||||
"postal_code" => updated_data.postal_code = value,
|
||||
"occupation" => updated_data.occupation = value,
|
||||
"employer" => updated_data.employer = Some(value),
|
||||
"annual_income" => updated_data.annual_income = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_select = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let field_name = select.name();
|
||||
let value = select.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
match field_name.as_str() {
|
||||
"education_level" => updated_data.education_level = value,
|
||||
_ => {}
|
||||
}
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_service_change = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let service_name = input.value();
|
||||
let is_checked = input.checked();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
|
||||
// Parse the service
|
||||
let service = match service_name.as_str() {
|
||||
"BankingAccess" => DigitalService::BankingAccess,
|
||||
"TaxFiling" => DigitalService::TaxFiling,
|
||||
"HealthcareAccess" => DigitalService::HealthcareAccess,
|
||||
"EducationServices" => DigitalService::EducationServices,
|
||||
"BusinessLicensing" => DigitalService::BusinessLicensing,
|
||||
"PropertyServices" => DigitalService::PropertyServices,
|
||||
"LegalServices" => DigitalService::LegalServices,
|
||||
"DigitalIdentity" => DigitalService::DigitalIdentity,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if is_checked {
|
||||
if !updated_data.requested_services.contains(&service) {
|
||||
updated_data.requested_services.push(service);
|
||||
}
|
||||
} else {
|
||||
updated_data.requested_services.retain(|s| s != &service);
|
||||
}
|
||||
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h4 class="mb-4">{"Personal Information & KYC"}</h4>
|
||||
|
||||
// Personal Information Section
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Personal Details"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="full_name" class="form-label">{"Full Name"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
value={form_data.full_name.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter your full legal name"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="email" class="form-label">{"Email Address"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value={form_data.email.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="phone" class="form-label">{"Phone Number"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="tel"
|
||||
class="form-control"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={form_data.phone.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="date_of_birth" class="form-label">{"Date of Birth"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="date"
|
||||
class="form-control"
|
||||
id="date_of_birth"
|
||||
name="date_of_birth"
|
||||
value={form_data.date_of_birth.clone()}
|
||||
oninput={on_input.clone()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="nationality" class="form-label">{"Nationality"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="nationality"
|
||||
name="nationality"
|
||||
value={form_data.nationality.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="e.g., American, British, etc."
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="passport_number" class="form-label">{"Passport Number"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="passport_number"
|
||||
name="passport_number"
|
||||
value={form_data.passport_number.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter passport number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="passport_expiry" class="form-label">{"Passport Expiry Date"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="date"
|
||||
class="form-control"
|
||||
id="passport_expiry"
|
||||
name="passport_expiry"
|
||||
value={form_data.passport_expiry.clone()}
|
||||
oninput={on_input.clone()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Address Information Section
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Address Information"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<label for="current_address" class="form-label">{"Current Address"} <span class="text-danger">{"*"}</span></label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="current_address"
|
||||
name="current_address"
|
||||
rows="3"
|
||||
value={form_data.current_address.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter your full current address"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="city" class="form-label">{"City"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="city"
|
||||
name="city"
|
||||
value={form_data.city.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="City"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="country" class="form-label">{"Country"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="country"
|
||||
name="country"
|
||||
value={form_data.country.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Country"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="postal_code" class="form-label">{"Postal Code"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
value={form_data.postal_code.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Postal Code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Professional Information Section
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Professional Information"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="occupation" class="form-label">{"Occupation"} <span class="text-danger">{"*"}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="occupation"
|
||||
name="occupation"
|
||||
value={form_data.occupation.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Your current occupation"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="employer" class="form-label">{"Employer"}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="employer"
|
||||
name="employer"
|
||||
value={form_data.employer.clone().unwrap_or_default()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Current employer (optional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="education_level" class="form-label">{"Education Level"} <span class="text-danger">{"*"}</span></label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="education_level"
|
||||
name="education_level"
|
||||
value={form_data.education_level.clone()}
|
||||
onchange={on_select.clone()}
|
||||
>
|
||||
<option value="">{"Select education level"}</option>
|
||||
<option value="High School">{"High School"}</option>
|
||||
<option value="Associate Degree">{"Associate Degree"}</option>
|
||||
<option value="Bachelor's Degree">{"Bachelor's Degree"}</option>
|
||||
<option value="Master's Degree">{"Master's Degree"}</option>
|
||||
<option value="Doctorate">{"Doctorate"}</option>
|
||||
<option value="Professional Certification">{"Professional Certification"}</option>
|
||||
<option value="Other">{"Other"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="annual_income" class="form-label">{"Annual Income"}</label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="annual_income"
|
||||
name="annual_income"
|
||||
value={form_data.annual_income.clone().unwrap_or_default()}
|
||||
onchange={on_select.clone()}
|
||||
>
|
||||
<option value="">{"Select income range (optional)"}</option>
|
||||
<option value="Under $25,000">{"Under $25,000"}</option>
|
||||
<option value="$25,000 - $50,000">{"$25,000 - $50,000"}</option>
|
||||
<option value="$50,000 - $75,000">{"$50,000 - $75,000"}</option>
|
||||
<option value="$75,000 - $100,000">{"$75,000 - $100,000"}</option>
|
||||
<option value="$100,000 - $150,000">{"$100,000 - $150,000"}</option>
|
||||
<option value="$150,000 - $250,000">{"$150,000 - $250,000"}</option>
|
||||
<option value="Over $250,000">{"Over $250,000"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Digital Services Section
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Digital Services"} <span class="text-danger">{"*"}</span></h5>
|
||||
<small class="text-muted">{"Select the digital services you'd like access to"}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{[
|
||||
DigitalService::BankingAccess,
|
||||
DigitalService::TaxFiling,
|
||||
DigitalService::HealthcareAccess,
|
||||
DigitalService::EducationServices,
|
||||
DigitalService::BusinessLicensing,
|
||||
DigitalService::PropertyServices,
|
||||
DigitalService::LegalServices,
|
||||
DigitalService::DigitalIdentity,
|
||||
].iter().map(|service| {
|
||||
let service_name = format!("{:?}", service);
|
||||
let is_selected = form_data.requested_services.contains(service);
|
||||
|
||||
html! {
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id={service_name.clone()}
|
||||
value={service_name.clone()}
|
||||
checked={is_selected}
|
||||
onchange={on_service_change.clone()}
|
||||
/>
|
||||
<label class="form-check-label" for={service_name.clone()}>
|
||||
<i class={format!("bi {} me-2", service.get_icon())}></i>
|
||||
<strong>{service.get_display_name()}</strong>
|
||||
<br />
|
||||
<small class="text-muted">{service.get_description()}</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// KYC Upload Section
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"KYC Document Upload"}</h5>
|
||||
<small class="text-muted">{"Upload required documents for identity verification"}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"Please prepare the following documents for upload:"}
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>{"Government-issued photo ID (passport, driver's license)"}</li>
|
||||
<li>{"Proof of address (utility bill, bank statement)"}</li>
|
||||
<li>{"Passport photo page"}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">{"Photo ID"}</label>
|
||||
<input type="file" class="form-control" accept="image/*,.pdf" />
|
||||
<small class="text-muted">{"Upload your government-issued photo ID"}</small>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">{"Proof of Address"}</label>
|
||||
<input type="file" class="form-control" accept="image/*,.pdf" />
|
||||
<small class="text-muted">{"Upload proof of your current address"}</small>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">{"Passport Photo Page"}</label>
|
||||
<input type="file" class="form-control" accept="image/*,.pdf" />
|
||||
<small class="text-muted">{"Upload your passport photo page"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::company::DigitalResidentFormData;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepOneProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(StepOne)]
|
||||
pub fn step_one(props: &StepOneProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
|
||||
let on_input = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let field_name = input.name();
|
||||
let value = input.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
match field_name.as_str() {
|
||||
"full_name" => updated_data.full_name = value,
|
||||
"email" => updated_data.email = value,
|
||||
"phone" => updated_data.phone = value,
|
||||
"date_of_birth" => updated_data.date_of_birth = value,
|
||||
"nationality" => updated_data.nationality = value,
|
||||
"passport_number" => updated_data.passport_number = value,
|
||||
"passport_expiry" => updated_data.passport_expiry = value,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="step-header mb-4">
|
||||
<h3 class="step-title">{"Personal Information"}</h3>
|
||||
<p class="step-description text-muted">
|
||||
{"Please provide your personal details for digital resident registration."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="full_name" class="form-label">
|
||||
{"Full Name"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
value={form_data.full_name.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter your full legal name"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="email" class="form-label">
|
||||
{"Email Address"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value={form_data.email.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="your.email@example.com"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="phone" class="form-label">
|
||||
{"Phone Number"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
class="form-control"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={form_data.phone.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="date_of_birth" class="form-label">
|
||||
{"Date of Birth"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
class="form-control"
|
||||
id="date_of_birth"
|
||||
name="date_of_birth"
|
||||
value={form_data.date_of_birth.clone()}
|
||||
oninput={on_input.clone()}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="nationality" class="form-label">
|
||||
{"Nationality"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="nationality"
|
||||
name="nationality"
|
||||
value={form_data.nationality.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="e.g., American, British, etc."
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="passport_number" class="form-label">
|
||||
{"Passport Number"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="passport_number"
|
||||
name="passport_number"
|
||||
value={form_data.passport_number.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter passport number"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="passport_expiry" class="form-label">
|
||||
{"Passport Expiry Date"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
class="form-control"
|
||||
id="passport_expiry"
|
||||
name="passport_expiry"
|
||||
value={form_data.passport_expiry.clone()}
|
||||
oninput={on_input.clone()}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"All personal information is encrypted and stored securely. Your data will only be used for digital resident services and verification purposes."}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::company::{DigitalResidentFormData, ResidentPaymentPlan};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepPaymentProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(StepPayment)]
|
||||
pub fn step_payment(props: &StepPaymentProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
|
||||
let on_payment_plan_change = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let plan_value = input.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.payment_plan = match plan_value.as_str() {
|
||||
"Monthly" => ResidentPaymentPlan::Monthly,
|
||||
"Yearly" => ResidentPaymentPlan::Yearly,
|
||||
"Lifetime" => ResidentPaymentPlan::Lifetime,
|
||||
_ => ResidentPaymentPlan::Monthly,
|
||||
};
|
||||
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_agreement_change = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let agreement_name = input.name();
|
||||
let is_checked = input.checked();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
match agreement_name.as_str() {
|
||||
"terms" => updated_data.legal_agreements.terms = is_checked,
|
||||
"privacy" => updated_data.legal_agreements.privacy = is_checked,
|
||||
"compliance" => updated_data.legal_agreements.compliance = is_checked,
|
||||
"articles" => updated_data.legal_agreements.articles = is_checked,
|
||||
"final_agreement" => updated_data.legal_agreements.final_agreement = is_checked,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h4 class="mb-4">{"Payment Plan & Legal Agreements"}</h4>
|
||||
|
||||
// Payment Plan Selection
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Choose Your Payment Plan"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{[
|
||||
ResidentPaymentPlan::Monthly,
|
||||
ResidentPaymentPlan::Yearly,
|
||||
ResidentPaymentPlan::Lifetime,
|
||||
].iter().map(|plan| {
|
||||
let plan_name = plan.get_display_name();
|
||||
let plan_price = plan.get_price();
|
||||
let plan_description = plan.get_description();
|
||||
let is_selected = form_data.payment_plan == *plan;
|
||||
let savings = if *plan == ResidentPaymentPlan::Yearly { "Save 17%" } else { "" };
|
||||
let popular = *plan == ResidentPaymentPlan::Yearly;
|
||||
|
||||
html! {
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class={format!("card h-100 {}", if is_selected { "border-primary" } else { "" })}>
|
||||
{if popular {
|
||||
html! {
|
||||
<div class="card-header bg-primary text-white text-center">
|
||||
<small>{"Most Popular"}</small>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
<div class="card-body text-center">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
name="payment_plan"
|
||||
id={format!("plan_{}", plan_name.to_lowercase())}
|
||||
value={plan_name}
|
||||
checked={is_selected}
|
||||
onchange={on_payment_plan_change.clone()}
|
||||
/>
|
||||
<label class="form-check-label w-100" for={format!("plan_{}", plan_name.to_lowercase())}>
|
||||
<h5 class="card-title">{plan_name}</h5>
|
||||
<div class="display-6 text-primary">{format!("{:.2}", plan_price)}</div>
|
||||
{if !savings.is_empty() {
|
||||
html! {
|
||||
<div class="badge bg-success mb-2">{savings}</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
<p class="text-muted">{plan_description}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"All plans include access to selected digital services, identity verification, and customer support."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Legal Agreements
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Legal Agreements"}</h5>
|
||||
<small class="text-muted">{"Please review and accept all required agreements"}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
name="terms"
|
||||
checked={form_data.legal_agreements.terms}
|
||||
onchange={on_agreement_change.clone()}
|
||||
/>
|
||||
<label class="form-check-label" for="terms">
|
||||
{"I agree to the "} <a href="#" target="_blank">{"Terms of Service"}</a> <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="privacy"
|
||||
name="privacy"
|
||||
checked={form_data.legal_agreements.privacy}
|
||||
onchange={on_agreement_change.clone()}
|
||||
/>
|
||||
<label class="form-check-label" for="privacy">
|
||||
{"I agree to the "} <a href="#" target="_blank">{"Privacy Policy"}</a> <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="compliance"
|
||||
name="compliance"
|
||||
checked={form_data.legal_agreements.compliance}
|
||||
onchange={on_agreement_change.clone()}
|
||||
/>
|
||||
<label class="form-check-label" for="compliance">
|
||||
{"I agree to the "} <a href="#" target="_blank">{"Compliance Agreement"}</a> <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="articles"
|
||||
name="articles"
|
||||
checked={form_data.legal_agreements.articles}
|
||||
onchange={on_agreement_change.clone()}
|
||||
/>
|
||||
<label class="form-check-label" for="articles">
|
||||
{"I agree to the "} <a href="#" target="_blank">{"Digital Resident Agreement"}</a> <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="final_agreement"
|
||||
name="final_agreement"
|
||||
checked={form_data.legal_agreements.final_agreement}
|
||||
onchange={on_agreement_change.clone()}
|
||||
/>
|
||||
<label class="form-check-label" for="final_agreement">
|
||||
{"I confirm that all information provided is accurate and complete, and I understand that providing false information may result in rejection of my application."} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Summary
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Payment Summary"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h6>{"Digital Resident Registration - "}{form_data.payment_plan.get_display_name()}</h6>
|
||||
<p class="text-muted mb-0">{form_data.payment_plan.get_description()}</p>
|
||||
<small class="text-muted">
|
||||
{"Services: "}{form_data.requested_services.len()}{" selected"}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<h4 class="text-primary">{format!("{:.2}", form_data.payment_plan.get_price())}</h4>
|
||||
{if form_data.payment_plan == ResidentPaymentPlan::Yearly {
|
||||
html! {
|
||||
<small class="text-success">{"Save $60.89 vs Monthly"}</small>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>{"Important:"}</strong> {" Your registration will be reviewed after payment. Approval typically takes 3-5 business days. You will receive email updates about your application status."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{window, console, js_sys};
|
||||
use crate::models::company::{DigitalResidentFormData, DigitalResident, ResidentPaymentPlan};
|
||||
use crate::services::ResidentService;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = window)]
|
||||
fn confirmStripePayment(client_secret: &str) -> js_sys::Promise;
|
||||
|
||||
#[wasm_bindgen(js_namespace = window)]
|
||||
fn initializeStripeElements(client_secret: &str);
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepPaymentStripeProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub client_secret: Option<String>,
|
||||
pub processing_payment: bool,
|
||||
pub on_process_payment: Callback<()>,
|
||||
pub on_payment_complete: Callback<DigitalResident>,
|
||||
pub on_payment_error: Callback<String>,
|
||||
pub on_payment_plan_change: Callback<ResidentPaymentPlan>,
|
||||
pub on_confirmation_change: Callback<bool>,
|
||||
}
|
||||
|
||||
pub enum StepPaymentStripeMsg {
|
||||
ProcessPayment,
|
||||
PaymentComplete,
|
||||
PaymentError(String),
|
||||
PaymentPlanChanged(ResidentPaymentPlan),
|
||||
ToggleConfirmation,
|
||||
}
|
||||
|
||||
pub struct StepPaymentStripe {
|
||||
form_data: DigitalResidentFormData,
|
||||
payment_error: Option<String>,
|
||||
selected_payment_plan: ResidentPaymentPlan,
|
||||
confirmation_checked: bool,
|
||||
}
|
||||
|
||||
impl Component for StepPaymentStripe {
|
||||
type Message = StepPaymentStripeMsg;
|
||||
type Properties = StepPaymentStripeProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
form_data: ctx.props().form_data.clone(),
|
||||
payment_error: None,
|
||||
selected_payment_plan: ctx.props().form_data.payment_plan.clone(),
|
||||
confirmation_checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
StepPaymentStripeMsg::ProcessPayment => {
|
||||
if let Some(client_secret) = &ctx.props().client_secret {
|
||||
console::log_1(&"🔄 User clicked 'Complete Payment' - processing with Stripe".into());
|
||||
self.process_stripe_payment(ctx, client_secret.clone());
|
||||
} else {
|
||||
console::log_1(&"❌ No client secret available for payment".into());
|
||||
self.payment_error = Some("Payment not ready. Please try again.".to_string());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
StepPaymentStripeMsg::PaymentComplete => {
|
||||
console::log_1(&"✅ Payment completed successfully".into());
|
||||
// Create resident from form data with current payment plan
|
||||
let mut updated_form_data = self.form_data.clone();
|
||||
updated_form_data.payment_plan = self.selected_payment_plan.clone();
|
||||
|
||||
match ResidentService::create_resident_from_form(&updated_form_data) {
|
||||
Ok(resident) => {
|
||||
ctx.props().on_payment_complete.emit(resident);
|
||||
}
|
||||
Err(e) => {
|
||||
console::log_1(&format!("❌ Failed to create resident: {}", e).into());
|
||||
ctx.props().on_payment_error.emit(format!("Failed to create resident: {}", e));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
StepPaymentStripeMsg::PaymentError(error) => {
|
||||
console::log_1(&format!("❌ Payment failed: {}", error).into());
|
||||
self.payment_error = Some(error.clone());
|
||||
ctx.props().on_payment_error.emit(error);
|
||||
}
|
||||
StepPaymentStripeMsg::PaymentPlanChanged(plan) => {
|
||||
console::log_1(&format!("💳 Payment plan changed to: {}", plan.get_display_name()).into());
|
||||
self.selected_payment_plan = plan.clone();
|
||||
self.payment_error = None; // Clear any previous errors
|
||||
|
||||
// Notify parent to create new payment intent
|
||||
ctx.props().on_payment_plan_change.emit(plan);
|
||||
return true;
|
||||
}
|
||||
StepPaymentStripeMsg::ToggleConfirmation => {
|
||||
self.confirmation_checked = !self.confirmation_checked;
|
||||
console::log_1(&format!("📋 Confirmation checkbox toggled: {}", self.confirmation_checked).into());
|
||||
// Notify parent of confirmation state change
|
||||
ctx.props().on_confirmation_change.emit(self.confirmation_checked);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
|
||||
self.form_data = ctx.props().form_data.clone();
|
||||
// Update selected payment plan if it changed from parent
|
||||
if self.selected_payment_plan != ctx.props().form_data.payment_plan {
|
||||
self.selected_payment_plan = ctx.props().form_data.payment_plan.clone();
|
||||
}
|
||||
|
||||
// Initialize Stripe Elements if client secret became available
|
||||
if old_props.client_secret.is_none() && ctx.props().client_secret.is_some() {
|
||||
if let Some(client_secret) = &ctx.props().client_secret {
|
||||
initializeStripeElements(client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
// Initialize Stripe Elements if client secret is available
|
||||
if let Some(client_secret) = &ctx.props().client_secret {
|
||||
initializeStripeElements(client_secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let has_client_secret = ctx.props().client_secret.is_some();
|
||||
let can_process_payment = has_client_secret && !ctx.props().processing_payment && self.confirmation_checked;
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
// Registration Summary
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h6 class="text-secondary mb-3">
|
||||
<i class="bi bi-receipt me-2"></i>{"Registration Summary"}
|
||||
</h6>
|
||||
|
||||
<div class="card border-0">
|
||||
<div class="card-body py-3">
|
||||
<div class="row g-2 small">
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-person text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{&self.form_data.full_name}</div>
|
||||
<div class="text-muted">{"Digital Resident"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-envelope text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold">{&self.form_data.email}</div>
|
||||
<div class="text-muted">{"Email"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{if let Some(public_key) = &self.form_data.public_key {
|
||||
html! {
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-key text-primary me-2"></i>
|
||||
<div>
|
||||
<div class="fw-bold" style="font-family: monospace; font-size: 0.8rem;">
|
||||
{&public_key[..std::cmp::min(16, public_key.len())]}{"..."}
|
||||
</div>
|
||||
<div class="text-muted">{"Public Key"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Confirmation Checkbox
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning py-2 mb-0">
|
||||
<div class="form-check mb-0">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="registrationConfirmation"
|
||||
checked={self.confirmation_checked}
|
||||
onchange={link.callback(|_| StepPaymentStripeMsg::ToggleConfirmation)}
|
||||
/>
|
||||
<label class="form-check-label small" for="registrationConfirmation">
|
||||
<strong>{"I confirm the accuracy of all information and authorize digital resident registration with the selected payment plan."}</strong>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Plans (Left) and Payment Form (Right)
|
||||
<div class="row mb-4">
|
||||
// Payment Plan Selection - Left
|
||||
<div class="col-lg-6 mb-4">
|
||||
<h5 class="text-secondary mb-3">
|
||||
{"Choose Your Payment Plan"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
<div class="row">
|
||||
{self.render_payment_plan_option(ctx, ResidentPaymentPlan::Monthly, "Monthly Plan", "Pay monthly with flexibility", "bi-calendar-month")}
|
||||
{self.render_payment_plan_option(ctx, ResidentPaymentPlan::Yearly, "Yearly Plan", "Save 17% with annual payments", "bi-calendar-check")}
|
||||
{self.render_payment_plan_option(ctx, ResidentPaymentPlan::Lifetime, "Lifetime Plan", "One-time payment for lifetime access", "bi-infinity")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Form - Right
|
||||
<div class="col-lg-6">
|
||||
<h5 class="text-secondary mb-3">
|
||||
{"Payment Information"} <span class="text-danger">{"*"}</span>
|
||||
</h5>
|
||||
|
||||
<div class="card" id="payment-information-section">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-shield-check me-2"></i>{"Secure Payment Processing"}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
// Stripe Elements will be mounted here
|
||||
<div id="payment-element" style="min-height: 40px; padding: 10px; border: 1px solid #dee2e6; border-radius: 0.375rem; background-color: #ffffff;">
|
||||
{if ctx.props().processing_payment {
|
||||
html! {
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="text-muted">{"Processing payment..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else if !has_client_secret {
|
||||
html! {
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="text-muted">{"Preparing payment form..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Payment button
|
||||
{if has_client_secret && !ctx.props().processing_payment {
|
||||
html! {
|
||||
<div class="d-grid mt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-lg"
|
||||
disabled={!can_process_payment}
|
||||
onclick={link.callback(|_| StepPaymentStripeMsg::ProcessPayment)}
|
||||
>
|
||||
{if self.confirmation_checked {
|
||||
html! {
|
||||
<>
|
||||
<i class="bi bi-credit-card me-2"></i>
|
||||
{format!("Complete Payment - ${:.2}", self.selected_payment_plan.get_price())}
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{"Please confirm registration details"}
|
||||
</>
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
{if let Some(error) = &self.payment_error {
|
||||
html! {
|
||||
<div id="payment-errors" class="alert alert-danger mt-3">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>{"Payment Error: "}</strong>{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div id="payment-errors" class="alert alert-danger mt-3" style="display: none;"></div>
|
||||
}
|
||||
}}
|
||||
|
||||
// Payment info text
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
{"Payment plan: "}{self.selected_payment_plan.get_display_name()}
|
||||
{" - $"}{self.selected_payment_plan.get_price()}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepPaymentStripe {
|
||||
fn render_payment_plan_option(&self, ctx: &Context<Self>, plan: ResidentPaymentPlan, title: &str, description: &str, icon: &str) -> Html {
|
||||
let link = ctx.link();
|
||||
let is_selected = self.selected_payment_plan == plan;
|
||||
let card_class = if is_selected {
|
||||
"card border-success mb-3"
|
||||
} else {
|
||||
"card border-secondary mb-3"
|
||||
};
|
||||
|
||||
let on_select = link.callback(move |_| StepPaymentStripeMsg::PaymentPlanChanged(plan.clone()));
|
||||
|
||||
// Get pricing for this plan
|
||||
let price = plan.get_price();
|
||||
|
||||
html! {
|
||||
<div class="col-12">
|
||||
<div class={card_class} style="cursor: pointer;" onclick={on_select}>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class={format!("bi {} fs-3 text-primary me-3", icon)}></i>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="card-title mb-1">{title}</h6>
|
||||
<p class="card-text text-muted mb-0 small">{description}</p>
|
||||
<div class="mt-1">
|
||||
<span class="fw-bold text-success">{format!("${:.2}", price)}</span>
|
||||
{if plan == ResidentPaymentPlan::Yearly {
|
||||
html! {
|
||||
<span class="badge bg-success ms-2 small">
|
||||
{"17% OFF"}
|
||||
</span>
|
||||
}
|
||||
} else if plan == ResidentPaymentPlan::Lifetime {
|
||||
html! {
|
||||
<span class="badge bg-warning ms-2 small">
|
||||
{"BEST VALUE"}
|
||||
</span>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{if is_selected {
|
||||
html! {
|
||||
<i class="bi bi-check-circle-fill text-success fs-4"></i>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<i class="bi bi-circle text-muted fs-4"></i>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn process_stripe_payment(&mut self, ctx: &Context<Self>, client_secret: String) {
|
||||
let link = ctx.link().clone();
|
||||
|
||||
// Trigger parent to show processing state
|
||||
ctx.props().on_process_payment.emit(());
|
||||
|
||||
spawn_local(async move {
|
||||
match Self::confirm_payment(&client_secret).await {
|
||||
Ok(_) => {
|
||||
link.send_message(StepPaymentStripeMsg::PaymentComplete);
|
||||
}
|
||||
Err(e) => {
|
||||
link.send_message(StepPaymentStripeMsg::PaymentError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn confirm_payment(client_secret: &str) -> Result<(), String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
console::log_1(&"🔄 Confirming payment with Stripe...".into());
|
||||
|
||||
// Call JavaScript function to confirm payment
|
||||
let promise = confirmStripePayment(client_secret);
|
||||
JsFuture::from(promise).await
|
||||
.map_err(|e| format!("Payment confirmation failed: {:?}", e))?;
|
||||
|
||||
console::log_1(&"✅ Payment confirmed successfully".into());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement};
|
||||
use crate::models::company::DigitalResidentFormData;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepThreeProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(StepThree)]
|
||||
pub fn step_three(props: &StepThreeProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
let skills_input = use_state(|| String::new());
|
||||
|
||||
let on_input = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let field_name = input.name();
|
||||
let value = input.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
match field_name.as_str() {
|
||||
"occupation" => updated_data.occupation = value,
|
||||
"employer" => updated_data.employer = if value.is_empty() { None } else { Some(value) },
|
||||
"annual_income" => updated_data.annual_income = if value.is_empty() { None } else { Some(value) },
|
||||
_ => {}
|
||||
}
|
||||
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_education_change = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let value = select.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.education_level = value;
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_income_change = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
let value = select.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.annual_income = if value.is_empty() { None } else { Some(value) };
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let on_skill_input = {
|
||||
let skills_input = skills_input.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
skills_input.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let add_skill = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
let skills_input = skills_input.clone();
|
||||
Callback::from(move |e: KeyboardEvent| {
|
||||
if e.key() == "Enter" {
|
||||
e.prevent_default();
|
||||
let skill = (*skills_input).trim().to_string();
|
||||
if !skill.is_empty() && !form_data.skills.contains(&skill) {
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.skills.push(skill);
|
||||
on_change.emit(updated_data);
|
||||
skills_input.set(String::new());
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let remove_skill = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |skill: String| {
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.skills.retain(|s| s != &skill);
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="step-header mb-4">
|
||||
<h3 class="step-title">{"Professional Information"}</h3>
|
||||
<p class="step-description text-muted">
|
||||
{"Tell us about your professional background and qualifications."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="occupation" class="form-label">
|
||||
{"Occupation"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="occupation"
|
||||
name="occupation"
|
||||
value={form_data.occupation.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="e.g., Software Engineer, Doctor, Teacher"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="employer" class="form-label">
|
||||
{"Current Employer"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="employer"
|
||||
name="employer"
|
||||
value={form_data.employer.clone().unwrap_or_default()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Company or organization name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="education_level" class="form-label">
|
||||
{"Education Level"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="education_level"
|
||||
name="education_level"
|
||||
value={form_data.education_level.clone()}
|
||||
onchange={on_education_change}
|
||||
required=true
|
||||
>
|
||||
<option value="">{"Select education level"}</option>
|
||||
<option value="High School">{"High School"}</option>
|
||||
<option value="Associate Degree">{"Associate Degree"}</option>
|
||||
<option value="Bachelor's Degree">{"Bachelor's Degree"}</option>
|
||||
<option value="Master's Degree">{"Master's Degree"}</option>
|
||||
<option value="Doctorate">{"Doctorate"}</option>
|
||||
<option value="Professional Certification">{"Professional Certification"}</option>
|
||||
<option value="Other">{"Other"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="annual_income" class="form-label">
|
||||
{"Annual Income (Optional)"}
|
||||
</label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="annual_income"
|
||||
name="annual_income"
|
||||
value={form_data.annual_income.clone().unwrap_or_default()}
|
||||
onchange={on_income_change}
|
||||
>
|
||||
<option value="">{"Prefer not to say"}</option>
|
||||
<option value="Under $25,000">{"Under $25,000"}</option>
|
||||
<option value="$25,000 - $50,000">{"$25,000 - $50,000"}</option>
|
||||
<option value="$50,000 - $75,000">{"$50,000 - $75,000"}</option>
|
||||
<option value="$75,000 - $100,000">{"$75,000 - $100,000"}</option>
|
||||
<option value="$100,000 - $150,000">{"$100,000 - $150,000"}</option>
|
||||
<option value="$150,000 - $250,000">{"$150,000 - $250,000"}</option>
|
||||
<option value="Over $250,000">{"Over $250,000"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="skills" class="form-label">
|
||||
{"Skills & Expertise"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="skills"
|
||||
value={(*skills_input).clone()}
|
||||
oninput={on_skill_input}
|
||||
onkeypress={add_skill}
|
||||
placeholder="Type a skill and press Enter to add"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{"Add your professional skills, certifications, or areas of expertise"}
|
||||
</div>
|
||||
|
||||
{if !form_data.skills.is_empty() {
|
||||
html! {
|
||||
<div class="mt-3">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{for form_data.skills.iter().map(|skill| {
|
||||
let skill_clone = skill.clone();
|
||||
let remove_callback = {
|
||||
let remove_skill = remove_skill.clone();
|
||||
let skill = skill.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
remove_skill.emit(skill.clone());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<span class="badge bg-primary d-flex align-items-center">
|
||||
{skill_clone}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close btn-close-white ms-2"
|
||||
style="font-size: 0.7em;"
|
||||
onclick={remove_callback}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"Your professional information helps us recommend relevant digital services and opportunities within the ecosystem."}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::company::DigitalResidentFormData;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StepTwoProps {
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub on_change: Callback<DigitalResidentFormData>,
|
||||
}
|
||||
|
||||
#[function_component(StepTwo)]
|
||||
pub fn step_two(props: &StepTwoProps) -> Html {
|
||||
let form_data = props.form_data.clone();
|
||||
let on_change = props.on_change.clone();
|
||||
|
||||
let on_input = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let field_name = input.name();
|
||||
let value = input.value();
|
||||
|
||||
let mut updated_data = form_data.clone();
|
||||
match field_name.as_str() {
|
||||
"current_address" => updated_data.current_address = value,
|
||||
"city" => updated_data.city = value,
|
||||
"country" => updated_data.country = value,
|
||||
"postal_code" => updated_data.postal_code = value,
|
||||
"permanent_address" => updated_data.permanent_address = if value.is_empty() { None } else { Some(value) },
|
||||
_ => {}
|
||||
}
|
||||
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
let same_as_current = {
|
||||
let form_data = form_data.clone();
|
||||
let on_change = on_change.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let mut updated_data = form_data.clone();
|
||||
updated_data.permanent_address = Some(updated_data.current_address.clone());
|
||||
on_change.emit(updated_data);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="step-content">
|
||||
<div class="step-header mb-4">
|
||||
<h3 class="step-title">{"Address Information"}</h3>
|
||||
<p class="step-description text-muted">
|
||||
{"Please provide your current and permanent address details."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">{"Current Address"}</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="current_address" class="form-label">
|
||||
{"Street Address"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="current_address"
|
||||
name="current_address"
|
||||
rows="3"
|
||||
value={form_data.current_address.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter your full street address"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="city" class="form-label">
|
||||
{"City"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="city"
|
||||
name="city"
|
||||
value={form_data.city.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter city"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="country" class="form-label">
|
||||
{"Country"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="country"
|
||||
name="country"
|
||||
value={form_data.country.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter country"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="postal_code" class="form-label">
|
||||
{"Postal Code"} <span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
value={form_data.postal_code.clone()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter postal code"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">{"Permanent Address"}</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="same_address"
|
||||
onclick={same_as_current}
|
||||
/>
|
||||
<label class="form-check-label" for="same_address">
|
||||
{"Same as current address"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="permanent_address" class="form-label">
|
||||
{"Permanent Address"}
|
||||
</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="permanent_address"
|
||||
name="permanent_address"
|
||||
rows="3"
|
||||
value={form_data.permanent_address.clone().unwrap_or_default()}
|
||||
oninput={on_input.clone()}
|
||||
placeholder="Enter permanent address (if different from current)"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{"Leave empty if same as current address"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"Your address information is used for verification purposes and to determine applicable services in your region."}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
96
platform/src/components/forms/login_form.rs
Normal file
96
platform/src/components/forms/login_form.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LoginFormProps {
|
||||
pub on_submit: Callback<(String, String)>, // (email, password)
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(LoginForm)]
|
||||
pub fn login_form(props: &LoginFormProps) -> Html {
|
||||
let email_ref = use_node_ref();
|
||||
let password_ref = use_node_ref();
|
||||
let on_submit = props.on_submit.clone();
|
||||
|
||||
let onsubmit = {
|
||||
let email_ref = email_ref.clone();
|
||||
let password_ref = password_ref.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let email = email_ref
|
||||
.cast::<HtmlInputElement>()
|
||||
.map(|input| input.value())
|
||||
.unwrap_or_default();
|
||||
|
||||
let password = password_ref
|
||||
.cast::<HtmlInputElement>()
|
||||
.map(|input| input.value())
|
||||
.unwrap_or_default();
|
||||
|
||||
on_submit.emit((email, password));
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="login-container">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow login-card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">{"Login"}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{if let Some(error) = &props.error_message {
|
||||
html! {
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
|
||||
<form {onsubmit}>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">{"Email address"}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
ref={email_ref}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">{"Password"}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
ref={password_ref}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">{"Login"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<p class="mb-0">
|
||||
{"Don't have an account? "}
|
||||
<a href="/register">{"Register"}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
3
platform/src/components/forms/mod.rs
Normal file
3
platform/src/components/forms/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod login_form;
|
||||
|
||||
pub use login_form::*;
|
||||
32
platform/src/components/layout/footer.rs
Normal file
32
platform/src/components/layout/footer.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(Footer)]
|
||||
pub fn footer() -> Html {
|
||||
html! {
|
||||
<footer class="footer bg-dark text-white">
|
||||
<div class="container-fluid">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4 text-center text-md-start mb-2 mb-md-0">
|
||||
<small>{"Convenience, Safety and Privacy"}</small>
|
||||
</div>
|
||||
<div class="col-md-4 text-center mb-2 mb-md-0">
|
||||
<a
|
||||
class="text-white text-decoration-none mx-2"
|
||||
target="_blank"
|
||||
href="https://info.ourworld.tf/zdfz"
|
||||
>
|
||||
{"About"}
|
||||
</a>
|
||||
<span class="text-white">{"| "}</span>
|
||||
<a class="text-white text-decoration-none mx-2" href="/contact">
|
||||
{"Contact"}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4 text-center text-md-end">
|
||||
<small>{"© 2024 Zanzibar Digital Freezone"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
192
platform/src/components/layout/header.rs
Normal file
192
platform/src/components/layout/header.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::routing::{ViewContext, AppView};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct HeaderProps {
|
||||
pub user_name: Option<String>,
|
||||
pub entity_name: Option<String>,
|
||||
pub current_context: ViewContext,
|
||||
pub is_dark_mode: bool,
|
||||
pub on_sidebar_toggle: Callback<MouseEvent>,
|
||||
pub on_login: Callback<MouseEvent>,
|
||||
pub on_logout: Callback<MouseEvent>,
|
||||
pub on_context_change: Callback<ViewContext>,
|
||||
pub on_navigate: Callback<AppView>,
|
||||
pub on_theme_toggle: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(Header)]
|
||||
pub fn header(props: &HeaderProps) -> Html {
|
||||
let user_name = props.user_name.clone();
|
||||
let entity_name = props.entity_name.clone();
|
||||
let on_sidebar_toggle = props.on_sidebar_toggle.clone();
|
||||
let on_login = props.on_login.clone();
|
||||
let on_logout = props.on_logout.clone();
|
||||
let on_theme_toggle = props.on_theme_toggle.clone();
|
||||
|
||||
html! {
|
||||
<header class={classes!(
|
||||
"header",
|
||||
if props.is_dark_mode { "bg-dark text-white" } else { "bg-light text-dark" }
|
||||
)}>
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center h-100 py-2">
|
||||
// Left side - Logo and mobile menu
|
||||
<div class="d-flex align-items-center">
|
||||
<button
|
||||
class={classes!(
|
||||
"navbar-toggler",
|
||||
"d-md-none",
|
||||
"me-3",
|
||||
"btn",
|
||||
"btn-sm",
|
||||
if props.is_dark_mode { "btn-outline-light" } else { "btn-outline-dark" }
|
||||
)}
|
||||
type="button"
|
||||
onclick={on_sidebar_toggle}
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-building-gear text-primary fs-4 me-2"></i>
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{"Zanzibar Digital Freezone"}</h5>
|
||||
{if let Some(entity) = entity_name {
|
||||
html! { <small class="text-info">{entity}</small> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Right side - Theme toggle and user actions
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
// Dark/Light mode toggle
|
||||
<button
|
||||
class={classes!(
|
||||
"btn",
|
||||
"btn-sm",
|
||||
if props.is_dark_mode { "btn-outline-light" } else { "btn-outline-dark" }
|
||||
)}
|
||||
onclick={on_theme_toggle}
|
||||
title={if props.is_dark_mode { "Switch to light mode" } else { "Switch to dark mode" }}
|
||||
>
|
||||
<i class={if props.is_dark_mode { "bi bi-sun" } else { "bi bi-moon" }}></i>
|
||||
</button>
|
||||
|
||||
{if let Some(user) = user_name {
|
||||
html! {
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class={classes!(
|
||||
"btn",
|
||||
"dropdown-toggle",
|
||||
"d-flex",
|
||||
"align-items-center",
|
||||
if props.is_dark_mode { "btn-outline-light" } else { "btn-outline-dark" }
|
||||
)}
|
||||
type="button"
|
||||
id="userDropdown"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
<span class="d-none d-md-inline">{user}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
||||
// Context switcher in dropdown
|
||||
<li><h6 class="dropdown-header">{"Switch Context"}</h6></li>
|
||||
<li>
|
||||
<button
|
||||
class={classes!(
|
||||
"dropdown-item",
|
||||
"d-flex",
|
||||
"align-items-center",
|
||||
matches!(props.current_context, ViewContext::Business).then_some("active")
|
||||
)}
|
||||
onclick={
|
||||
let on_context_change = props.on_context_change.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_context_change.emit(ViewContext::Business);
|
||||
})
|
||||
}
|
||||
>
|
||||
<i class="bi bi-building me-2"></i>
|
||||
{"Business Mode"}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={classes!(
|
||||
"dropdown-item",
|
||||
"d-flex",
|
||||
"align-items-center",
|
||||
matches!(props.current_context, ViewContext::Person).then_some("active")
|
||||
)}
|
||||
onclick={
|
||||
let on_context_change = props.on_context_change.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_context_change.emit(ViewContext::Person);
|
||||
})
|
||||
}
|
||||
>
|
||||
<i class="bi bi-person me-2"></i>
|
||||
{"Personal Mode"}
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
|
||||
// User menu items
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-person me-2"></i>{"Profile"}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-gear me-2"></i>{"Settings"}</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="bi bi-question-circle me-2"></i>{"Help"}</a></li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
onclick={on_logout}
|
||||
>
|
||||
<i class="bi bi-box-arrow-right me-2"></i>
|
||||
{"Logout"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button
|
||||
class={classes!(
|
||||
"btn",
|
||||
if props.is_dark_mode { "btn-outline-light" } else { "btn-outline-dark" }
|
||||
)}
|
||||
onclick={on_login}
|
||||
>
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>
|
||||
{"Login"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={
|
||||
let on_navigate = props.on_navigate.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_navigate.emit(AppView::ResidentRegister);
|
||||
})
|
||||
}
|
||||
>
|
||||
<i class="bi bi-person-plus me-1"></i>
|
||||
{"Register"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
7
platform/src/components/layout/mod.rs
Normal file
7
platform/src/components/layout/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod header;
|
||||
pub mod sidebar;
|
||||
pub mod footer;
|
||||
|
||||
pub use header::*;
|
||||
pub use sidebar::*;
|
||||
pub use footer::*;
|
||||
230
platform/src/components/layout/sidebar.rs
Normal file
230
platform/src/components/layout/sidebar.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use yew::prelude::*;
|
||||
use crate::routing::{AppView, ViewContext};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SidebarProps {
|
||||
pub current_view: AppView,
|
||||
pub current_context: ViewContext,
|
||||
pub is_visible: bool,
|
||||
pub on_view_change: Callback<AppView>,
|
||||
}
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
let current_view = props.current_view.clone();
|
||||
let current_context = props.current_context.clone();
|
||||
let on_view_change = props.on_view_change.clone();
|
||||
|
||||
let sidebar_class = if props.is_visible {
|
||||
"sidebar shadow-sm border-end d-flex show"
|
||||
} else {
|
||||
"sidebar shadow-sm border-end d-flex"
|
||||
};
|
||||
|
||||
// All possible navigation items
|
||||
let all_nav_items = vec![
|
||||
AppView::Home,
|
||||
AppView::Administration,
|
||||
AppView::PersonAdministration,
|
||||
AppView::Residence,
|
||||
AppView::Accounting,
|
||||
AppView::Contracts,
|
||||
AppView::Governance,
|
||||
AppView::Treasury,
|
||||
AppView::Entities,
|
||||
];
|
||||
|
||||
// Filter items based on current context
|
||||
let nav_items: Vec<AppView> = all_nav_items
|
||||
.into_iter()
|
||||
.filter(|view| view.is_available_for_context(¤t_context))
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
<div class={sidebar_class} id="sidebar">
|
||||
<div class="py-2">
|
||||
|
||||
// Identity Card - Business/Residence
|
||||
<div class="px-3 mb-3">
|
||||
{match current_context {
|
||||
ViewContext::Business => {
|
||||
let business_view = AppView::Business;
|
||||
let is_active = business_view == current_view;
|
||||
let on_click = {
|
||||
let on_view_change = on_view_change.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_view_change.emit(AppView::Business);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!(
|
||||
"card",
|
||||
"shadow-sm",
|
||||
if is_active { "bg-dark text-white border-0" } else { "bg-white border-dark" },
|
||||
"cursor-pointer"
|
||||
)}
|
||||
onclick={on_click}
|
||||
style="cursor: pointer;"
|
||||
>
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={classes!(
|
||||
"rounded-circle",
|
||||
"d-flex",
|
||||
"align-items-center",
|
||||
"justify-content-center",
|
||||
"me-3",
|
||||
if is_active { "bg-white text-dark" } else { "bg-dark text-white" }
|
||||
)} style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-building fs-5"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-0">{"TechCorp Solutions"}</h6>
|
||||
<small class={classes!(
|
||||
"font-monospace",
|
||||
if is_active { "text-white-50" } else { "text-muted" }
|
||||
)}>
|
||||
{"BIZ-2024-001"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
},
|
||||
ViewContext::Person => {
|
||||
let residence_view = AppView::Residence;
|
||||
let is_active = residence_view == current_view;
|
||||
let on_click = {
|
||||
let on_view_change = on_view_change.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_view_change.emit(AppView::Residence);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!(
|
||||
"card",
|
||||
"shadow-sm",
|
||||
if is_active { "bg-dark text-white border-0" } else { "bg-white border-dark" },
|
||||
"cursor-pointer"
|
||||
)}
|
||||
onclick={on_click}
|
||||
style="cursor: pointer;"
|
||||
>
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={classes!(
|
||||
"rounded-circle",
|
||||
"d-flex",
|
||||
"align-items-center",
|
||||
"justify-content-center",
|
||||
"me-3",
|
||||
if is_active { "bg-white text-dark" } else { "bg-dark text-white" }
|
||||
)} style="width: 40px; height: 40px;">
|
||||
<i class="bi bi-person fs-5"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-0">{"John Doe"}</h6>
|
||||
<small class={classes!(
|
||||
"font-monospace",
|
||||
if is_active { "text-white-50" } else { "text-muted" }
|
||||
)}>
|
||||
{"RES-ZNZ-2024-042"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Horizontal divider
|
||||
<div class="px-3 mb-3">
|
||||
<hr class="text-muted" />
|
||||
</div>
|
||||
|
||||
// Navigation items
|
||||
<ul class="nav flex-column">
|
||||
{for nav_items.iter().map(|view| {
|
||||
let is_active = *view == current_view;
|
||||
let view_to_emit = view.clone();
|
||||
let on_click = {
|
||||
let on_view_change = on_view_change.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_view_change.emit(view_to_emit.clone());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class={classes!(
|
||||
"nav-link",
|
||||
"d-flex",
|
||||
"align-items-center",
|
||||
"ps-3",
|
||||
"py-2",
|
||||
is_active.then_some("active"),
|
||||
is_active.then_some("fw-bold"),
|
||||
is_active.then_some("border-start"),
|
||||
is_active.then_some("border-4"),
|
||||
is_active.then_some("border-primary"),
|
||||
)}
|
||||
href="#"
|
||||
onclick={on_click}
|
||||
>
|
||||
<i class={classes!("bi", view.get_icon(), "me-2")}></i>
|
||||
{view.get_title(¤t_context)}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
|
||||
// Divider for external applications
|
||||
<div class="px-3 my-3">
|
||||
<hr class="text-muted" />
|
||||
</div>
|
||||
|
||||
// External Applications
|
||||
<div class="px-3">
|
||||
// Marketplace Button
|
||||
<div class="card border-primary mb-3" style="cursor: pointer;">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3" style="width: 35px; height: 35px;">
|
||||
<i class="bi bi-shop fs-6"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1 text-primary">{"Marketplace"}</h6>
|
||||
<small class="text-muted">{"Browse contract templates"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// DeFi Button
|
||||
<div class="card border-success" style="cursor: pointer;">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle bg-success text-white d-flex align-items-center justify-content-center me-3" style="width: 35px; height: 35px;">
|
||||
<i class="bi bi-currency-exchange fs-6"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1 text-success">{"DeFi"}</h6>
|
||||
<small class="text-muted">{"Financial tools & escrow"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
21
platform/src/components/mod.rs
Normal file
21
platform/src/components/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
pub mod layout;
|
||||
pub mod forms;
|
||||
pub mod cards;
|
||||
pub mod view_component;
|
||||
pub mod empty_state;
|
||||
pub mod entities;
|
||||
pub mod toast;
|
||||
pub mod common;
|
||||
pub mod accounting;
|
||||
pub mod resident_landing_overlay;
|
||||
|
||||
pub use layout::*;
|
||||
pub use forms::*;
|
||||
pub use cards::*;
|
||||
pub use view_component::*;
|
||||
pub use empty_state::*;
|
||||
pub use entities::*;
|
||||
pub use toast::*;
|
||||
pub use common::*;
|
||||
pub use accounting::*;
|
||||
pub use resident_landing_overlay::*;
|
||||
498
platform/src/components/resident_landing_overlay.rs
Normal file
498
platform/src/components/resident_landing_overlay.rs
Normal file
@@ -0,0 +1,498 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use crate::models::company::{DigitalResidentFormData, DigitalResident};
|
||||
use crate::components::entities::resident_registration::SimpleResidentWizard;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ResidentLandingOverlayProps {
|
||||
pub on_registration_complete: Callback<()>,
|
||||
pub on_sign_in: Callback<(String, String)>, // email, password
|
||||
pub on_close: Option<Callback<()>>,
|
||||
}
|
||||
|
||||
pub enum ResidentLandingMsg {
|
||||
ShowSignIn,
|
||||
ShowRegister,
|
||||
UpdateEmail(String),
|
||||
UpdatePassword(String),
|
||||
UpdateConfirmPassword(String),
|
||||
SignIn,
|
||||
StartRegistration,
|
||||
RegistrationComplete(DigitalResident),
|
||||
BackToLanding,
|
||||
}
|
||||
|
||||
pub struct ResidentLandingOverlay {
|
||||
view_mode: ViewMode,
|
||||
email: String,
|
||||
password: String,
|
||||
confirm_password: String,
|
||||
show_registration_wizard: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum ViewMode {
|
||||
Landing,
|
||||
SignIn,
|
||||
Register,
|
||||
}
|
||||
|
||||
impl Component for ResidentLandingOverlay {
|
||||
type Message = ResidentLandingMsg;
|
||||
type Properties = ResidentLandingOverlayProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
view_mode: ViewMode::Landing,
|
||||
email: String::new(),
|
||||
password: String::new(),
|
||||
confirm_password: String::new(),
|
||||
show_registration_wizard: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ResidentLandingMsg::ShowSignIn => {
|
||||
self.view_mode = ViewMode::SignIn;
|
||||
self.show_registration_wizard = false;
|
||||
true
|
||||
}
|
||||
ResidentLandingMsg::ShowRegister => {
|
||||
self.view_mode = ViewMode::Register;
|
||||
self.show_registration_wizard = false;
|
||||
true
|
||||
}
|
||||
ResidentLandingMsg::UpdateEmail(email) => {
|
||||
self.email = email;
|
||||
true
|
||||
}
|
||||
ResidentLandingMsg::UpdatePassword(password) => {
|
||||
self.password = password;
|
||||
true
|
||||
}
|
||||
ResidentLandingMsg::UpdateConfirmPassword(password) => {
|
||||
self.confirm_password = password;
|
||||
true
|
||||
}
|
||||
ResidentLandingMsg::SignIn => {
|
||||
ctx.props().on_sign_in.emit((self.email.clone(), self.password.clone()));
|
||||
false
|
||||
}
|
||||
ResidentLandingMsg::StartRegistration => {
|
||||
self.view_mode = ViewMode::Register;
|
||||
self.show_registration_wizard = true;
|
||||
true
|
||||
}
|
||||
ResidentLandingMsg::RegistrationComplete(resident) => {
|
||||
ctx.props().on_registration_complete.emit(());
|
||||
false
|
||||
}
|
||||
ResidentLandingMsg::BackToLanding => {
|
||||
self.view_mode = ViewMode::Landing;
|
||||
self.show_registration_wizard = false;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<style>
|
||||
{"@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }"}
|
||||
</style>
|
||||
<div class="position-fixed top-0 start-0 w-100 h-100 d-flex" style="z-index: 9999; background: linear-gradient(135deg, #0099FF 0%, #00CC66 100%);">
|
||||
{self.render_content(ctx)}
|
||||
|
||||
// Close button (if callback provided)
|
||||
{if ctx.props().on_close.is_some() {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-outline-light position-absolute top-0 end-0 m-3"
|
||||
style="z-index: 10000;"
|
||||
onclick={ctx.props().on_close.as_ref().unwrap().reform(|_| ())}
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResidentLandingOverlay {
|
||||
fn render_content(&self, ctx: &Context<Self>) -> Html {
|
||||
// Determine column sizes based on view mode
|
||||
let (left_col_class, right_col_class) = match self.view_mode {
|
||||
ViewMode::Register if self.show_registration_wizard => ("col-lg-4", "col-lg-8"),
|
||||
_ => ("col-lg-7", "col-lg-5"),
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="container-fluid h-100">
|
||||
<div class="row h-100">
|
||||
// Left side - Branding and description (shrinks when registration is active)
|
||||
<div class={format!("{} d-flex align-items-center justify-content-center text-white p-5 transition-all", left_col_class)}
|
||||
style="transition: all 0.5s ease-in-out;">
|
||||
<div class="text-center text-lg-start" style="max-width: 600px;">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-globe2" style="font-size: 4rem; opacity: 0.9;"></i>
|
||||
</div>
|
||||
<h1 class="display-4 fw-bold mb-4">
|
||||
{"Zanzibar Digital Freezone"}
|
||||
</h1>
|
||||
<h2 class="h3 mb-4 text-white-75">
|
||||
{"Your Gateway to Digital Residency"}
|
||||
</h2>
|
||||
<p class="lead mb-4 text-white-75">
|
||||
{"Join the world's most innovative digital economic zone. Become a digital resident and unlock access to global opportunities, seamless business registration, and cutting-edge financial services."}
|
||||
</p>
|
||||
|
||||
{if !self.show_registration_wizard {
|
||||
html! {
|
||||
<div class="row text-center mt-5">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="bg-white bg-opacity-10 rounded-3 p-3">
|
||||
<i class="bi bi-shield-check display-6 mb-2"></i>
|
||||
<h6 class="fw-bold">{"Secure Identity"}</h6>
|
||||
<small class="text-white-75">{"Blockchain-verified digital identity"}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="bg-white bg-opacity-10 rounded-3 p-3">
|
||||
<i class="bi bi-building display-6 mb-2"></i>
|
||||
<h6 class="fw-bold">{"Business Ready"}</h6>
|
||||
<small class="text-white-75">{"Register companies in minutes"}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="bg-white bg-opacity-10 rounded-3 p-3">
|
||||
<i class="bi bi-globe display-6 mb-2"></i>
|
||||
<h6 class="fw-bold">{"Global Access"}</h6>
|
||||
<small class="text-white-75">{"Worldwide financial services"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Right side - Sign in/Register form (expands when registration is active)
|
||||
<div class={format!("{} d-flex align-items-center justify-content-center bg-white", right_col_class)}
|
||||
style="transition: all 0.5s ease-in-out;">
|
||||
<div class="w-100 h-100" style={if self.show_registration_wizard { "padding: 1rem;" } else { "max-width: 400px; padding: 2rem;" }}>
|
||||
{match self.view_mode {
|
||||
ViewMode::Landing => self.render_landing_form(ctx),
|
||||
ViewMode::SignIn => self.render_sign_in_form(ctx),
|
||||
ViewMode::Register if self.show_registration_wizard => self.render_embedded_registration_wizard(ctx),
|
||||
ViewMode::Register => self.render_register_form(ctx),
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_landing_form(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-person-circle text-primary" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h3 class="mb-4">{"Welcome to ZDF"}</h3>
|
||||
<p class="text-muted mb-4">
|
||||
{"Get started with your digital residency journey"}
|
||||
</p>
|
||||
|
||||
<div class="d-grid gap-3">
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={link.callback(|_| ResidentLandingMsg::StartRegistration)}
|
||||
>
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
{"Become a Digital Resident"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-primary btn-lg"
|
||||
onclick={link.callback(|_| ResidentLandingMsg::ShowSignIn)}
|
||||
>
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
{"Sign In to Your Account"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-top">
|
||||
<small class="text-muted">
|
||||
{"Already have an account? "}
|
||||
<a href="#" class="text-primary text-decoration-none" onclick={link.callback(|e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
ResidentLandingMsg::ShowSignIn
|
||||
})}>
|
||||
{"Sign in here"}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sign_in_form(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
let on_email_input = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
link.send_message(ResidentLandingMsg::UpdateEmail(input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_password_input = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
link.send_message(ResidentLandingMsg::UpdatePassword(input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
link.send_message(ResidentLandingMsg::SignIn);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div>
|
||||
<div class="text-center mb-4">
|
||||
<h3>{"Sign In"}</h3>
|
||||
<p class="text-muted">{"Welcome back to your digital residency"}</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="mb-3">
|
||||
<label for="signin-email" class="form-label">{"Email Address"}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control form-control-lg"
|
||||
id="signin-email"
|
||||
value={self.email.clone()}
|
||||
oninput={on_email_input}
|
||||
placeholder="your.email@example.com"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="signin-password" class="form-label">{"Password"}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control form-control-lg"
|
||||
id="signin-password"
|
||||
value={self.password.clone()}
|
||||
oninput={on_password_input}
|
||||
placeholder="Enter your password"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
{"Sign In"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center">
|
||||
<small class="text-muted">
|
||||
{"Don't have an account? "}
|
||||
<a href="#" class="text-primary text-decoration-none" onclick={link.callback(|e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
ResidentLandingMsg::ShowRegister
|
||||
})}>
|
||||
{"Register here"}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button
|
||||
class="btn btn-link text-muted"
|
||||
onclick={link.callback(|_| ResidentLandingMsg::BackToLanding)}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_register_form(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
let on_email_input = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
link.send_message(ResidentLandingMsg::UpdateEmail(input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_password_input = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
link.send_message(ResidentLandingMsg::UpdatePassword(input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_confirm_password_input = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
link.send_message(ResidentLandingMsg::UpdateConfirmPassword(input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let passwords_match = self.password == self.confirm_password && !self.password.is_empty();
|
||||
|
||||
html! {
|
||||
<div>
|
||||
<div class="text-center mb-4">
|
||||
<h3>{"Create Account"}</h3>
|
||||
<p class="text-muted">{"Start your digital residency journey"}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="register-email" class="form-label">{"Email Address"}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control form-control-lg"
|
||||
id="register-email"
|
||||
value={self.email.clone()}
|
||||
oninput={on_email_input}
|
||||
placeholder="your.email@example.com"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="register-password" class="form-label">{"Password"}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control form-control-lg"
|
||||
id="register-password"
|
||||
value={self.password.clone()}
|
||||
oninput={on_password_input}
|
||||
placeholder="Create a strong password"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="register-confirm-password" class="form-label">{"Confirm Password"}</label>
|
||||
<input
|
||||
type="password"
|
||||
class={format!("form-control form-control-lg {}",
|
||||
if self.confirm_password.is_empty() { "" }
|
||||
else if passwords_match { "is-valid" }
|
||||
else { "is-invalid" }
|
||||
)}
|
||||
id="register-confirm-password"
|
||||
value={self.confirm_password.clone()}
|
||||
oninput={on_confirm_password_input}
|
||||
placeholder="Confirm your password"
|
||||
required={true}
|
||||
/>
|
||||
{if !self.confirm_password.is_empty() && !passwords_match {
|
||||
html! {
|
||||
<div class="invalid-feedback">
|
||||
{"Passwords do not match"}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-lg"
|
||||
disabled={!passwords_match || self.email.is_empty()}
|
||||
onclick={link.callback(|_| ResidentLandingMsg::StartRegistration)}
|
||||
>
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
{"Start Registration Process"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<small class="text-muted">
|
||||
{"Already have an account? "}
|
||||
<a href="#" class="text-primary text-decoration-none" onclick={link.callback(|e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
ResidentLandingMsg::ShowSignIn
|
||||
})}>
|
||||
{"Sign in here"}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button
|
||||
class="btn btn-link text-muted"
|
||||
onclick={link.callback(|_| ResidentLandingMsg::BackToLanding)}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_embedded_registration_wizard(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="h-100 d-flex flex-column">
|
||||
// Header with back button (always visible)
|
||||
<div class="d-flex justify-content-between align-items-center p-3 border-bottom">
|
||||
<h4 class="mb-0">{"Digital Resident Registration"}</h4>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
onclick={link.callback(|_| ResidentLandingMsg::BackToLanding)}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Registration wizard content with fade-in animation
|
||||
<div class="flex-grow-1 overflow-auto"
|
||||
style="opacity: 0; animation: fadeIn 0.5s ease-in-out 0.25s forwards;">
|
||||
<SimpleResidentWizard
|
||||
on_registration_complete={link.callback(ResidentLandingMsg::RegistrationComplete)}
|
||||
on_back_to_parent={link.callback(|_| ResidentLandingMsg::BackToLanding)}
|
||||
success_resident_id={None}
|
||||
show_failure={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
170
platform/src/components/toast.rs
Normal file
170
platform/src/components/toast.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::timers::callback::Timeout;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum ToastType {
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl ToastType {
|
||||
pub fn get_class(&self) -> &'static str {
|
||||
match self {
|
||||
ToastType::Success => "toast-success",
|
||||
ToastType::Error => "toast-error",
|
||||
ToastType::Warning => "toast-warning",
|
||||
ToastType::Info => "toast-info",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_icon(&self) -> &'static str {
|
||||
match self {
|
||||
ToastType::Success => "bi-check-circle-fill",
|
||||
ToastType::Error => "bi-x-circle-fill",
|
||||
ToastType::Warning => "bi-exclamation-triangle-fill",
|
||||
ToastType::Info => "bi-info-circle-fill",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_bg_class(&self) -> &'static str {
|
||||
match self {
|
||||
ToastType::Success => "bg-success",
|
||||
ToastType::Error => "bg-danger",
|
||||
ToastType::Warning => "bg-warning",
|
||||
ToastType::Info => "bg-info",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ToastMessage {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub toast_type: ToastType,
|
||||
pub duration: Option<u32>, // Duration in milliseconds, None for persistent
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ToastProps {
|
||||
pub toast: ToastMessage,
|
||||
pub on_dismiss: Callback<u32>,
|
||||
}
|
||||
|
||||
pub enum ToastMsg {
|
||||
AutoDismiss,
|
||||
}
|
||||
|
||||
pub struct Toast {
|
||||
_timeout: Option<Timeout>,
|
||||
}
|
||||
|
||||
impl Component for Toast {
|
||||
type Message = ToastMsg;
|
||||
type Properties = ToastProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let timeout = if let Some(duration) = ctx.props().toast.duration {
|
||||
let link = ctx.link().clone();
|
||||
Some(Timeout::new(duration, move || {
|
||||
link.send_message(ToastMsg::AutoDismiss);
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
_timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ToastMsg::AutoDismiss => {
|
||||
ctx.props().on_dismiss.emit(ctx.props().toast.id);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let toast = &ctx.props().toast;
|
||||
let on_dismiss = ctx.props().on_dismiss.clone();
|
||||
let toast_id = toast.id;
|
||||
|
||||
html! {
|
||||
<div class={format!("toast show {}", toast.toast_type.get_class())} role="alert">
|
||||
<div class={format!("toast-header {}", toast.toast_type.get_bg_class())}>
|
||||
<i class={format!("{} me-2 text-white", toast.toast_type.get_icon())}></i>
|
||||
<strong class="me-auto text-white">{&toast.title}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close btn-close-white"
|
||||
onclick={move |_| on_dismiss.emit(toast_id)}
|
||||
></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{&toast.message}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ToastContainerProps {
|
||||
pub toasts: Vec<ToastMessage>,
|
||||
pub on_dismiss: Callback<u32>,
|
||||
}
|
||||
|
||||
#[function_component(ToastContainer)]
|
||||
pub fn toast_container(props: &ToastContainerProps) -> Html {
|
||||
html! {
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1055;">
|
||||
{for props.toasts.iter().map(|toast| {
|
||||
html! {
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast.clone()}
|
||||
on_dismiss={props.on_dismiss.clone()}
|
||||
/>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create success toast
|
||||
pub fn create_success_toast(id: u32, title: &str, message: &str) -> ToastMessage {
|
||||
ToastMessage {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
message: message.to_string(),
|
||||
toast_type: ToastType::Success,
|
||||
duration: Some(5000), // 5 seconds
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create error toast
|
||||
pub fn create_error_toast(id: u32, title: &str, message: &str) -> ToastMessage {
|
||||
ToastMessage {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
message: message.to_string(),
|
||||
toast_type: ToastType::Error,
|
||||
duration: Some(8000), // 8 seconds for errors
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create info toast
|
||||
pub fn create_info_toast(id: u32, title: &str, message: &str) -> ToastMessage {
|
||||
ToastMessage {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
message: message.to_string(),
|
||||
toast_type: ToastType::Info,
|
||||
duration: Some(4000), // 4 seconds
|
||||
}
|
||||
}
|
||||
152
platform/src/components/view_component.rs
Normal file
152
platform/src/components/view_component.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::components::EmptyState;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ViewComponentProps {
|
||||
#[prop_or_default]
|
||||
pub title: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub description: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub breadcrumbs: Option<Vec<(String, Option<String>)>>, // (name, optional_link)
|
||||
#[prop_or_default]
|
||||
pub tabs: Option<HashMap<String, Html>>, // tab_name -> tab_content
|
||||
#[prop_or_default]
|
||||
pub default_tab: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub actions: Option<Html>, // Action buttons in top-right
|
||||
#[prop_or_default]
|
||||
pub empty_state: Option<(String, String, String, Option<(String, String)>, Option<(String, String)>)>, // (icon, title, description, primary_action, secondary_action)
|
||||
#[prop_or_default]
|
||||
pub children: Children, // Main content when no tabs
|
||||
}
|
||||
|
||||
#[function_component(ViewComponent)]
|
||||
pub fn view_component(props: &ViewComponentProps) -> Html {
|
||||
let active_tab = use_state(|| {
|
||||
props.default_tab.clone().unwrap_or_else(|| {
|
||||
props.tabs.as_ref()
|
||||
.and_then(|tabs| tabs.keys().next().cloned())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
});
|
||||
|
||||
let on_tab_click = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |tab_name: String| {
|
||||
active_tab.set(tab_name);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
// Breadcrumbs (if provided)
|
||||
if let Some(breadcrumbs) = &props.breadcrumbs {
|
||||
<ol class="breadcrumb mb-3">
|
||||
{for breadcrumbs.iter().enumerate().map(|(i, (name, link))| {
|
||||
let is_last = i == breadcrumbs.len() - 1;
|
||||
html! {
|
||||
<li class={classes!("breadcrumb-item", is_last.then(|| "active"))}>
|
||||
if let Some(href) = link {
|
||||
<a href={href.clone()}>{name}</a>
|
||||
} else {
|
||||
{name}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ol>
|
||||
}
|
||||
|
||||
// Page Header in Card (with integrated tabs if provided)
|
||||
if props.title.is_some() || props.description.is_some() || props.actions.is_some() || props.tabs.is_some() {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-end">
|
||||
// Left side: Title and description
|
||||
<div class="flex-grow-1">
|
||||
if let Some(title) = &props.title {
|
||||
<h2 class="mb-1">{title}</h2>
|
||||
}
|
||||
if let Some(description) = &props.description {
|
||||
<p class="text-muted mb-0">{description}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
// Center: Tabs navigation (if provided)
|
||||
if let Some(tabs) = &props.tabs {
|
||||
<div class="flex-grow-1 d-flex justify-content-right">
|
||||
<ul class="nav nav-tabs border-0" role="tablist">
|
||||
{for tabs.keys().map(|tab_name| {
|
||||
let is_active = *active_tab == *tab_name;
|
||||
let tab_name_clone = tab_name.clone();
|
||||
let on_click = {
|
||||
let on_tab_click = on_tab_click.clone();
|
||||
let tab_name = tab_name.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
on_tab_click.emit(tab_name.clone());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class={classes!("nav-link", "px-4", "py-2", is_active.then(|| "active"))}
|
||||
type="button"
|
||||
role="tab"
|
||||
onclick={on_click}
|
||||
>
|
||||
{tab_name}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Right side: Actions
|
||||
if let Some(actions) = &props.actions {
|
||||
<div>
|
||||
{actions.clone()}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Tab Content (if tabs are provided)
|
||||
if let Some(tabs) = &props.tabs {
|
||||
<div class="tab-content">
|
||||
{for tabs.iter().map(|(tab_name, content)| {
|
||||
let is_active = *active_tab == *tab_name;
|
||||
html! {
|
||||
<div class={classes!("tab-pane", "fade", is_active.then(|| "show"), is_active.then(|| "active"))} role="tabpanel">
|
||||
{content.clone()}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
} else if let Some((icon, title, description, primary_action, secondary_action)) = &props.empty_state {
|
||||
// Render empty state
|
||||
<EmptyState
|
||||
icon={icon.clone()}
|
||||
title={title.clone()}
|
||||
description={description.clone()}
|
||||
primary_action={primary_action.clone()}
|
||||
secondary_action={secondary_action.clone()}
|
||||
/>
|
||||
} else {
|
||||
// No tabs, render children directly
|
||||
{for props.children.iter()}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
18
platform/src/lib.rs
Normal file
18
platform/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod components;
|
||||
mod views;
|
||||
mod routing;
|
||||
mod services;
|
||||
mod models;
|
||||
|
||||
use app::App;
|
||||
|
||||
// This is the entry point for the web app
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn run_app() {
|
||||
wasm_logger::init(wasm_logger::Config::default());
|
||||
log::info!("Starting Zanzibar Digital Freezone WASM app");
|
||||
yew::Renderer::<App>::new().render();
|
||||
}
|
||||
747
platform/src/models/company.rs
Normal file
747
platform/src/models/company.rs
Normal file
@@ -0,0 +1,747 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct Company {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub company_type: CompanyType,
|
||||
pub status: CompanyStatus,
|
||||
pub registration_number: String,
|
||||
pub incorporation_date: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub website: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub industry: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub fiscal_year_end: Option<String>,
|
||||
pub shareholders: Vec<Shareholder>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum CompanyType {
|
||||
SingleFZC,
|
||||
StartupFZC,
|
||||
GrowthFZC,
|
||||
GlobalFZC,
|
||||
CooperativeFZC,
|
||||
}
|
||||
|
||||
impl CompanyType {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
CompanyType::SingleFZC => "Single FZC".to_string(),
|
||||
CompanyType::StartupFZC => "Startup FZC".to_string(),
|
||||
CompanyType::GrowthFZC => "Growth FZC".to_string(),
|
||||
CompanyType::GlobalFZC => "Global FZC".to_string(),
|
||||
CompanyType::CooperativeFZC => "Cooperative FZC".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_string(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"Single FZC" => Some(CompanyType::SingleFZC),
|
||||
"Startup FZC" => Some(CompanyType::StartupFZC),
|
||||
"Growth FZC" => Some(CompanyType::GrowthFZC),
|
||||
"Global FZC" => Some(CompanyType::GlobalFZC),
|
||||
"Cooperative FZC" => Some(CompanyType::CooperativeFZC),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_pricing(&self) -> CompanyPricing {
|
||||
match self {
|
||||
CompanyType::SingleFZC => CompanyPricing {
|
||||
setup_fee: 20.0,
|
||||
monthly_fee: 20.0,
|
||||
max_shareholders: 1,
|
||||
features: vec![
|
||||
"1 shareholder".to_string(),
|
||||
"Cannot issue digital assets".to_string(),
|
||||
"Can hold external shares".to_string(),
|
||||
"Connect to bank".to_string(),
|
||||
"Participate in ecosystem".to_string(),
|
||||
],
|
||||
},
|
||||
CompanyType::StartupFZC => CompanyPricing {
|
||||
setup_fee: 50.0,
|
||||
monthly_fee: 50.0,
|
||||
max_shareholders: 5,
|
||||
features: vec![
|
||||
"Up to 5 shareholders".to_string(),
|
||||
"Can issue digital assets".to_string(),
|
||||
"Hold external shares".to_string(),
|
||||
"Connect to bank".to_string(),
|
||||
],
|
||||
},
|
||||
CompanyType::GrowthFZC => CompanyPricing {
|
||||
setup_fee: 100.0,
|
||||
monthly_fee: 100.0,
|
||||
max_shareholders: 20,
|
||||
features: vec![
|
||||
"Up to 20 shareholders".to_string(),
|
||||
"Can issue digital assets".to_string(),
|
||||
"Hold external shares".to_string(),
|
||||
"Connect to bank".to_string(),
|
||||
"Hold physical assets".to_string(),
|
||||
],
|
||||
},
|
||||
CompanyType::GlobalFZC => CompanyPricing {
|
||||
setup_fee: 2000.0,
|
||||
monthly_fee: 200.0,
|
||||
max_shareholders: 999,
|
||||
features: vec![
|
||||
"Unlimited shareholders".to_string(),
|
||||
"Can issue digital assets".to_string(),
|
||||
"Hold external shares".to_string(),
|
||||
"Connect to bank".to_string(),
|
||||
"Hold physical assets".to_string(),
|
||||
],
|
||||
},
|
||||
CompanyType::CooperativeFZC => CompanyPricing {
|
||||
setup_fee: 2000.0,
|
||||
monthly_fee: 200.0,
|
||||
max_shareholders: 999,
|
||||
features: vec![
|
||||
"Unlimited members".to_string(),
|
||||
"Democratic governance".to_string(),
|
||||
"Collective decision-making".to_string(),
|
||||
"Equitable distribution".to_string(),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_capabilities(&self) -> HashMap<String, bool> {
|
||||
let mut capabilities = HashMap::new();
|
||||
|
||||
// All types have these basic capabilities
|
||||
capabilities.insert("digital_assets".to_string(), true);
|
||||
capabilities.insert("ecosystem".to_string(), true);
|
||||
capabilities.insert("ai_dispute".to_string(), true);
|
||||
capabilities.insert("digital_signing".to_string(), true);
|
||||
capabilities.insert("external_shares".to_string(), true);
|
||||
capabilities.insert("bank_account".to_string(), true);
|
||||
|
||||
// Type-specific capabilities
|
||||
match self {
|
||||
CompanyType::SingleFZC => {
|
||||
capabilities.insert("issue_assets".to_string(), false);
|
||||
capabilities.insert("physical_assets".to_string(), false);
|
||||
capabilities.insert("democratic".to_string(), false);
|
||||
capabilities.insert("collective".to_string(), false);
|
||||
},
|
||||
CompanyType::StartupFZC => {
|
||||
capabilities.insert("issue_assets".to_string(), true);
|
||||
capabilities.insert("physical_assets".to_string(), false);
|
||||
capabilities.insert("democratic".to_string(), false);
|
||||
capabilities.insert("collective".to_string(), false);
|
||||
},
|
||||
CompanyType::GrowthFZC => {
|
||||
capabilities.insert("issue_assets".to_string(), true);
|
||||
capabilities.insert("physical_assets".to_string(), true);
|
||||
capabilities.insert("democratic".to_string(), false);
|
||||
capabilities.insert("collective".to_string(), false);
|
||||
},
|
||||
CompanyType::GlobalFZC => {
|
||||
capabilities.insert("issue_assets".to_string(), true);
|
||||
capabilities.insert("physical_assets".to_string(), true);
|
||||
capabilities.insert("democratic".to_string(), false);
|
||||
capabilities.insert("collective".to_string(), false);
|
||||
},
|
||||
CompanyType::CooperativeFZC => {
|
||||
capabilities.insert("issue_assets".to_string(), true);
|
||||
capabilities.insert("physical_assets".to_string(), true);
|
||||
capabilities.insert("democratic".to_string(), true);
|
||||
capabilities.insert("collective".to_string(), true);
|
||||
},
|
||||
}
|
||||
|
||||
capabilities
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum CompanyStatus {
|
||||
Active,
|
||||
Inactive,
|
||||
Suspended,
|
||||
PendingPayment,
|
||||
}
|
||||
|
||||
impl CompanyStatus {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
CompanyStatus::Active => "Active".to_string(),
|
||||
CompanyStatus::Inactive => "Inactive".to_string(),
|
||||
CompanyStatus::Suspended => "Suspended".to_string(),
|
||||
CompanyStatus::PendingPayment => "Pending Payment".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_class(&self) -> String {
|
||||
match self {
|
||||
CompanyStatus::Active => "badge bg-success".to_string(),
|
||||
CompanyStatus::Inactive => "badge bg-secondary".to_string(),
|
||||
CompanyStatus::Suspended => "badge bg-warning text-dark".to_string(),
|
||||
CompanyStatus::PendingPayment => "badge bg-info".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct CompanyPricing {
|
||||
pub setup_fee: f64,
|
||||
pub monthly_fee: f64,
|
||||
pub max_shareholders: u32,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
// Registration form data
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct CompanyFormData {
|
||||
// Step 1: General Information
|
||||
pub company_name: String,
|
||||
pub company_email: String,
|
||||
pub company_phone: String,
|
||||
pub company_website: Option<String>,
|
||||
pub company_address: String,
|
||||
pub company_industry: Option<String>,
|
||||
pub company_purpose: Option<String>,
|
||||
pub fiscal_year_end: Option<String>,
|
||||
|
||||
// Step 2: Company Type
|
||||
pub company_type: CompanyType,
|
||||
|
||||
// Step 3: Shareholders
|
||||
pub shareholder_structure: ShareholderStructure,
|
||||
pub shareholders: Vec<Shareholder>,
|
||||
|
||||
// Step 4: Payment & Agreements
|
||||
pub payment_plan: PaymentPlan,
|
||||
pub legal_agreements: LegalAgreements,
|
||||
}
|
||||
|
||||
impl Default for CompanyFormData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
company_name: String::new(),
|
||||
company_email: String::new(),
|
||||
company_phone: String::new(),
|
||||
company_website: None,
|
||||
company_address: String::new(),
|
||||
company_industry: None,
|
||||
company_purpose: None,
|
||||
fiscal_year_end: None,
|
||||
company_type: CompanyType::StartupFZC,
|
||||
shareholder_structure: ShareholderStructure::Equal,
|
||||
shareholders: vec![Shareholder {
|
||||
name: String::new(),
|
||||
resident_id: String::new(),
|
||||
percentage: 100.0,
|
||||
}],
|
||||
payment_plan: PaymentPlan::Monthly,
|
||||
legal_agreements: LegalAgreements::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct Shareholder {
|
||||
pub name: String,
|
||||
pub resident_id: String,
|
||||
pub percentage: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum ShareholderStructure {
|
||||
Equal,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl ShareholderStructure {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
ShareholderStructure::Equal => "equal".to_string(),
|
||||
ShareholderStructure::Custom => "custom".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum PaymentPlan {
|
||||
Monthly,
|
||||
Yearly,
|
||||
TwoYear,
|
||||
}
|
||||
|
||||
impl PaymentPlan {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
PaymentPlan::Monthly => "monthly".to_string(),
|
||||
PaymentPlan::Yearly => "yearly".to_string(),
|
||||
PaymentPlan::TwoYear => "two_year".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_string(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"monthly" => Some(PaymentPlan::Monthly),
|
||||
"yearly" => Some(PaymentPlan::Yearly),
|
||||
"two_year" => Some(PaymentPlan::TwoYear),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_display_name(&self) -> String {
|
||||
match self {
|
||||
PaymentPlan::Monthly => "Monthly".to_string(),
|
||||
PaymentPlan::Yearly => "Yearly".to_string(),
|
||||
PaymentPlan::TwoYear => "2 Years".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_discount(&self) -> f64 {
|
||||
match self {
|
||||
PaymentPlan::Monthly => 1.0,
|
||||
PaymentPlan::Yearly => 0.8, // 20% discount
|
||||
PaymentPlan::TwoYear => 0.6, // 40% discount
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_class(&self) -> Option<String> {
|
||||
match self {
|
||||
PaymentPlan::Monthly => None,
|
||||
PaymentPlan::Yearly => Some("badge bg-success".to_string()),
|
||||
PaymentPlan::TwoYear => Some("badge bg-warning".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_text(&self) -> Option<String> {
|
||||
match self {
|
||||
PaymentPlan::Monthly => None,
|
||||
PaymentPlan::Yearly => Some("20% OFF".to_string()),
|
||||
PaymentPlan::TwoYear => Some("40% OFF".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct LegalAgreements {
|
||||
pub terms: bool,
|
||||
pub privacy: bool,
|
||||
pub compliance: bool,
|
||||
pub articles: bool,
|
||||
pub final_agreement: bool,
|
||||
}
|
||||
|
||||
impl Default for LegalAgreements {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
terms: false,
|
||||
privacy: false,
|
||||
compliance: false,
|
||||
articles: false,
|
||||
final_agreement: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LegalAgreements {
|
||||
pub fn all_agreed(&self) -> bool {
|
||||
self.terms && self.privacy && self.compliance && self.articles && self.final_agreement
|
||||
}
|
||||
|
||||
pub fn missing_agreements(&self) -> Vec<String> {
|
||||
let mut missing = Vec::new();
|
||||
|
||||
if !self.terms {
|
||||
missing.push("Terms of Service".to_string());
|
||||
}
|
||||
if !self.privacy {
|
||||
missing.push("Privacy Policy".to_string());
|
||||
}
|
||||
if !self.compliance {
|
||||
missing.push("Compliance Agreement".to_string());
|
||||
}
|
||||
if !self.articles {
|
||||
missing.push("Articles of Incorporation".to_string());
|
||||
}
|
||||
if !self.final_agreement {
|
||||
missing.push("Final Agreement".to_string());
|
||||
}
|
||||
|
||||
missing
|
||||
}
|
||||
}
|
||||
|
||||
// State management structures
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct EntitiesState {
|
||||
pub active_tab: ActiveTab,
|
||||
pub companies: Vec<Company>,
|
||||
pub registration_state: RegistrationState,
|
||||
pub loading: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for EntitiesState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_tab: ActiveTab::Companies,
|
||||
companies: Vec::new(),
|
||||
registration_state: RegistrationState::default(),
|
||||
loading: false,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct RegistrationState {
|
||||
pub current_step: u8,
|
||||
pub form_data: CompanyFormData,
|
||||
pub validation_errors: std::collections::HashMap<String, String>,
|
||||
pub payment_intent: Option<String>, // Payment intent ID
|
||||
pub auto_save_enabled: bool,
|
||||
pub processing_payment: bool,
|
||||
}
|
||||
|
||||
impl Default for RegistrationState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_step: 1,
|
||||
form_data: CompanyFormData::default(),
|
||||
validation_errors: std::collections::HashMap::new(),
|
||||
payment_intent: None,
|
||||
auto_save_enabled: true,
|
||||
processing_payment: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ActiveTab {
|
||||
Companies,
|
||||
RegisterCompany,
|
||||
}
|
||||
|
||||
impl ActiveTab {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
ActiveTab::Companies => "Companies".to_string(),
|
||||
ActiveTab::RegisterCompany => "Register Company".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Payment-related structures
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct PaymentIntent {
|
||||
pub id: String,
|
||||
pub client_secret: String,
|
||||
pub amount: f64,
|
||||
pub currency: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
// Validation result
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ValidationResult {
|
||||
pub is_valid: bool,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
pub fn valid() -> Self {
|
||||
Self {
|
||||
is_valid: true,
|
||||
errors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid(errors: Vec<String>) -> Self {
|
||||
Self {
|
||||
is_valid: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Digital Resident Registration Models
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct DigitalResidentFormData {
|
||||
// Step 1: Personal Information
|
||||
pub full_name: String,
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
pub date_of_birth: String,
|
||||
pub nationality: String,
|
||||
pub passport_number: String,
|
||||
pub passport_expiry: String,
|
||||
|
||||
// Cryptographic Keys
|
||||
pub public_key: Option<String>,
|
||||
pub private_key: Option<String>,
|
||||
pub private_key_shown: bool, // Track if private key has been shown
|
||||
|
||||
// Step 2: Address Information
|
||||
pub current_address: String,
|
||||
pub city: String,
|
||||
pub country: String,
|
||||
pub postal_code: String,
|
||||
pub permanent_address: Option<String>,
|
||||
|
||||
// Step 3: Professional Information
|
||||
pub occupation: String,
|
||||
pub employer: Option<String>,
|
||||
pub annual_income: Option<String>,
|
||||
pub education_level: String,
|
||||
pub skills: Vec<String>,
|
||||
|
||||
// Step 4: Digital Services
|
||||
pub requested_services: Vec<DigitalService>,
|
||||
pub preferred_language: String,
|
||||
pub communication_preferences: CommunicationPreferences,
|
||||
|
||||
// Step 5: Payment & Agreements
|
||||
pub payment_plan: ResidentPaymentPlan,
|
||||
pub legal_agreements: LegalAgreements,
|
||||
}
|
||||
|
||||
impl Default for DigitalResidentFormData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
full_name: String::new(),
|
||||
email: String::new(),
|
||||
phone: String::new(),
|
||||
date_of_birth: String::new(),
|
||||
nationality: String::new(),
|
||||
passport_number: String::new(),
|
||||
passport_expiry: String::new(),
|
||||
public_key: None,
|
||||
private_key: None,
|
||||
private_key_shown: false,
|
||||
current_address: String::new(),
|
||||
city: String::new(),
|
||||
country: String::new(),
|
||||
postal_code: String::new(),
|
||||
permanent_address: None,
|
||||
occupation: String::new(),
|
||||
employer: None,
|
||||
annual_income: None,
|
||||
education_level: String::new(),
|
||||
skills: Vec::new(),
|
||||
requested_services: Vec::new(),
|
||||
preferred_language: "English".to_string(),
|
||||
communication_preferences: CommunicationPreferences::default(),
|
||||
payment_plan: ResidentPaymentPlan::Monthly,
|
||||
legal_agreements: LegalAgreements::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum DigitalService {
|
||||
BankingAccess,
|
||||
TaxFiling,
|
||||
HealthcareAccess,
|
||||
EducationServices,
|
||||
BusinessLicensing,
|
||||
PropertyServices,
|
||||
LegalServices,
|
||||
DigitalIdentity,
|
||||
}
|
||||
|
||||
impl DigitalService {
|
||||
pub fn get_display_name(&self) -> &'static str {
|
||||
match self {
|
||||
DigitalService::BankingAccess => "Banking Access",
|
||||
DigitalService::TaxFiling => "Tax Filing Services",
|
||||
DigitalService::HealthcareAccess => "Healthcare Access",
|
||||
DigitalService::EducationServices => "Education Services",
|
||||
DigitalService::BusinessLicensing => "Business Licensing",
|
||||
DigitalService::PropertyServices => "Property Services",
|
||||
DigitalService::LegalServices => "Legal Services",
|
||||
DigitalService::DigitalIdentity => "Digital Identity",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_description(&self) -> &'static str {
|
||||
match self {
|
||||
DigitalService::BankingAccess => "Access to digital banking services and financial institutions",
|
||||
DigitalService::TaxFiling => "Automated tax filing and compliance services",
|
||||
DigitalService::HealthcareAccess => "Access to healthcare providers and medical services",
|
||||
DigitalService::EducationServices => "Educational resources and certification programs",
|
||||
DigitalService::BusinessLicensing => "Business registration and licensing services",
|
||||
DigitalService::PropertyServices => "Property rental and purchase assistance",
|
||||
DigitalService::LegalServices => "Legal consultation and document services",
|
||||
DigitalService::DigitalIdentity => "Secure digital identity verification",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_icon(&self) -> &'static str {
|
||||
match self {
|
||||
DigitalService::BankingAccess => "bi-bank",
|
||||
DigitalService::TaxFiling => "bi-calculator",
|
||||
DigitalService::HealthcareAccess => "bi-heart-pulse",
|
||||
DigitalService::EducationServices => "bi-mortarboard",
|
||||
DigitalService::BusinessLicensing => "bi-briefcase",
|
||||
DigitalService::PropertyServices => "bi-house",
|
||||
DigitalService::LegalServices => "bi-scales",
|
||||
DigitalService::DigitalIdentity => "bi-person-badge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct CommunicationPreferences {
|
||||
pub email_notifications: bool,
|
||||
pub sms_notifications: bool,
|
||||
pub push_notifications: bool,
|
||||
pub newsletter: bool,
|
||||
}
|
||||
|
||||
impl Default for CommunicationPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
email_notifications: true,
|
||||
sms_notifications: false,
|
||||
push_notifications: true,
|
||||
newsletter: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum ResidentPaymentPlan {
|
||||
Monthly,
|
||||
Yearly,
|
||||
Lifetime,
|
||||
}
|
||||
|
||||
impl ResidentPaymentPlan {
|
||||
pub fn get_display_name(&self) -> &'static str {
|
||||
match self {
|
||||
ResidentPaymentPlan::Monthly => "Monthly",
|
||||
ResidentPaymentPlan::Yearly => "Yearly",
|
||||
ResidentPaymentPlan::Lifetime => "Lifetime",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_price(&self) -> f64 {
|
||||
match self {
|
||||
ResidentPaymentPlan::Monthly => 29.99,
|
||||
ResidentPaymentPlan::Yearly => 299.99, // ~17% discount
|
||||
ResidentPaymentPlan::Lifetime => 999.99,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_discount(&self) -> f64 {
|
||||
match self {
|
||||
ResidentPaymentPlan::Monthly => 1.0,
|
||||
ResidentPaymentPlan::Yearly => 0.83, // 17% discount
|
||||
ResidentPaymentPlan::Lifetime => 0.0, // Special pricing
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_description(&self) -> &'static str {
|
||||
match self {
|
||||
ResidentPaymentPlan::Monthly => "Pay monthly with full flexibility",
|
||||
ResidentPaymentPlan::Yearly => "Save 17% with annual payment",
|
||||
ResidentPaymentPlan::Lifetime => "One-time payment for lifetime access",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub struct DigitalResident {
|
||||
pub id: u32,
|
||||
pub full_name: String,
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
pub date_of_birth: String,
|
||||
pub nationality: String,
|
||||
pub passport_number: String,
|
||||
pub passport_expiry: String,
|
||||
pub current_address: String,
|
||||
pub city: String,
|
||||
pub country: String,
|
||||
pub postal_code: String,
|
||||
pub occupation: String,
|
||||
pub employer: Option<String>,
|
||||
pub annual_income: Option<String>,
|
||||
pub education_level: String,
|
||||
pub selected_services: Vec<DigitalService>,
|
||||
pub payment_plan: ResidentPaymentPlan,
|
||||
pub registration_date: String,
|
||||
pub status: ResidentStatus,
|
||||
// KYC fields
|
||||
pub kyc_documents_uploaded: bool,
|
||||
pub kyc_status: KycStatus,
|
||||
// Cryptographic Keys
|
||||
pub public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum ResidentStatus {
|
||||
Pending,
|
||||
Active,
|
||||
Suspended,
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
|
||||
pub enum KycStatus {
|
||||
NotStarted,
|
||||
DocumentsUploaded,
|
||||
UnderReview,
|
||||
Approved,
|
||||
Rejected,
|
||||
RequiresAdditionalInfo,
|
||||
}
|
||||
|
||||
impl KycStatus {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
KycStatus::NotStarted => "Not Started".to_string(),
|
||||
KycStatus::DocumentsUploaded => "Documents Uploaded".to_string(),
|
||||
KycStatus::UnderReview => "Under Review".to_string(),
|
||||
KycStatus::Approved => "Approved".to_string(),
|
||||
KycStatus::Rejected => "Rejected".to_string(),
|
||||
KycStatus::RequiresAdditionalInfo => "Requires Additional Info".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_class(&self) -> String {
|
||||
match self {
|
||||
KycStatus::NotStarted => "badge bg-secondary".to_string(),
|
||||
KycStatus::DocumentsUploaded => "badge bg-info".to_string(),
|
||||
KycStatus::UnderReview => "badge bg-warning text-dark".to_string(),
|
||||
KycStatus::Approved => "badge bg-success".to_string(),
|
||||
KycStatus::Rejected => "badge bg-danger".to_string(),
|
||||
KycStatus::RequiresAdditionalInfo => "badge bg-warning text-dark".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResidentStatus {
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
ResidentStatus::Pending => "Pending".to_string(),
|
||||
ResidentStatus::Active => "Active".to_string(),
|
||||
ResidentStatus::Suspended => "Suspended".to_string(),
|
||||
ResidentStatus::Expired => "Expired".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_class(&self) -> String {
|
||||
match self {
|
||||
ResidentStatus::Pending => "badge bg-warning text-dark".to_string(),
|
||||
ResidentStatus::Active => "badge bg-success".to_string(),
|
||||
ResidentStatus::Suspended => "badge bg-danger".to_string(),
|
||||
ResidentStatus::Expired => "badge bg-secondary".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
3
platform/src/models/mod.rs
Normal file
3
platform/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod company;
|
||||
|
||||
pub use company::*;
|
||||
246
platform/src/routing/app_router.rs
Normal file
246
platform/src/routing/app_router.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ViewContext {
|
||||
Business,
|
||||
Person,
|
||||
}
|
||||
|
||||
impl ViewContext {
|
||||
pub fn get_title(&self) -> &'static str {
|
||||
match self {
|
||||
ViewContext::Business => "For Businesses",
|
||||
ViewContext::Person => "For Persons",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppView {
|
||||
Login,
|
||||
Home,
|
||||
Administration,
|
||||
PersonAdministration,
|
||||
Business,
|
||||
Accounting,
|
||||
Contracts,
|
||||
Governance,
|
||||
Treasury,
|
||||
Residence,
|
||||
Entities,
|
||||
EntitiesRegister,
|
||||
EntitiesRegisterSuccess(u32), // Company ID
|
||||
EntitiesRegisterFailure,
|
||||
CompanyView(u32), // Company ID
|
||||
ResidentRegister,
|
||||
ResidentRegisterSuccess,
|
||||
ResidentRegisterFailure,
|
||||
ResidentLanding, // New landing page for unregistered users
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
pub fn to_path(&self) -> String {
|
||||
match self {
|
||||
AppView::Login => "/login".to_string(),
|
||||
AppView::Home => "/".to_string(),
|
||||
AppView::Administration => "/administration".to_string(),
|
||||
AppView::PersonAdministration => "/person-administration".to_string(),
|
||||
AppView::Business => "/business".to_string(),
|
||||
AppView::Accounting => "/accounting".to_string(),
|
||||
AppView::Contracts => "/contracts".to_string(),
|
||||
AppView::Governance => "/governance".to_string(),
|
||||
AppView::Treasury => "/treasury".to_string(),
|
||||
AppView::Residence => "/residence".to_string(),
|
||||
AppView::Entities => "/companies".to_string(),
|
||||
AppView::EntitiesRegister => "/companies/register".to_string(),
|
||||
AppView::EntitiesRegisterSuccess(id) => format!("/companies/register/success/{}", id),
|
||||
AppView::EntitiesRegisterFailure => "/companies/register/failure".to_string(),
|
||||
AppView::CompanyView(id) => format!("/companies/{}", id),
|
||||
AppView::ResidentRegister => "/resident/register".to_string(),
|
||||
AppView::ResidentRegisterSuccess => "/resident/register/success".to_string(),
|
||||
AppView::ResidentRegisterFailure => "/resident/register/failure".to_string(),
|
||||
AppView::ResidentLanding => "/welcome".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_path(path: &str) -> Self {
|
||||
match path {
|
||||
"/login" => AppView::Login,
|
||||
"/administration" => AppView::Administration,
|
||||
"/person-administration" => AppView::PersonAdministration,
|
||||
"/business" => AppView::Business,
|
||||
"/accounting" => AppView::Accounting,
|
||||
"/contracts" => AppView::Contracts,
|
||||
"/governance" => AppView::Governance,
|
||||
"/treasury" => AppView::Treasury,
|
||||
"/residence" => AppView::Residence,
|
||||
"/entities" | "/companies" => AppView::Entities,
|
||||
"/entities/register" | "/companies/register" => AppView::EntitiesRegister,
|
||||
"/entities/register/failure" | "/companies/register/failure" => AppView::EntitiesRegisterFailure,
|
||||
"/resident/register" => AppView::ResidentRegister,
|
||||
"/resident/register/success" => AppView::ResidentRegisterSuccess,
|
||||
"/resident/register/failure" => AppView::ResidentRegisterFailure,
|
||||
"/welcome" => AppView::ResidentLanding,
|
||||
path if path.starts_with("/entities/register/success/") || path.starts_with("/companies/register/success/") => {
|
||||
// Extract company ID from path like "/companies/register/success/123"
|
||||
let prefix = if path.starts_with("/entities/register/success/") {
|
||||
"/entities/register/success/"
|
||||
} else {
|
||||
"/companies/register/success/"
|
||||
};
|
||||
if let Some(id_str) = path.strip_prefix(prefix) {
|
||||
if let Ok(id) = id_str.parse::<u32>() {
|
||||
return AppView::EntitiesRegisterSuccess(id);
|
||||
}
|
||||
}
|
||||
AppView::Entities // Fallback to entities list if parsing fails
|
||||
}
|
||||
path if path.starts_with("/entities/company/") || path.starts_with("/companies/") => {
|
||||
// Extract company ID from path like "/companies/123"
|
||||
let prefix = if path.starts_with("/entities/company/") {
|
||||
"/entities/company/"
|
||||
} else {
|
||||
"/companies/"
|
||||
};
|
||||
if let Some(id_str) = path.strip_prefix(prefix) {
|
||||
if let Ok(id) = id_str.parse::<u32>() {
|
||||
return AppView::CompanyView(id);
|
||||
}
|
||||
}
|
||||
AppView::Entities // Fallback to entities list if parsing fails
|
||||
}
|
||||
path if path.starts_with("/company/payment-success") => {
|
||||
// Handle legacy payment success redirect - redirect to entities view
|
||||
// The payment success will be handled by showing a toast notification
|
||||
AppView::Entities
|
||||
}
|
||||
_ => AppView::Home, // Default to Home for root or unknown paths
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_title(&self, context: &ViewContext) -> String {
|
||||
match self {
|
||||
AppView::Login => "Login".to_string(),
|
||||
AppView::Home => "Home".to_string(),
|
||||
AppView::Administration => "Administration".to_string(),
|
||||
AppView::PersonAdministration => "Administration".to_string(),
|
||||
AppView::Business => "Business".to_string(),
|
||||
AppView::Accounting => "Accounting".to_string(),
|
||||
AppView::Contracts => "Contracts".to_string(),
|
||||
AppView::Governance => "Governance".to_string(),
|
||||
AppView::Treasury => "Treasury".to_string(),
|
||||
AppView::Residence => "Residence".to_string(),
|
||||
AppView::Entities => "Companies".to_string(),
|
||||
AppView::EntitiesRegister => "Register Company".to_string(),
|
||||
AppView::EntitiesRegisterSuccess(_) => "Registration Successful".to_string(),
|
||||
AppView::EntitiesRegisterFailure => "Registration Failed".to_string(),
|
||||
AppView::CompanyView(_) => "Company Details".to_string(),
|
||||
AppView::ResidentRegister => "Register as Digital Resident".to_string(),
|
||||
AppView::ResidentRegisterSuccess => "Resident Registration Successful".to_string(),
|
||||
AppView::ResidentRegisterFailure => "Resident Registration Failed".to_string(),
|
||||
AppView::ResidentLanding => "Welcome to Zanzibar Digital Freezone".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_icon(&self) -> &'static str {
|
||||
match self {
|
||||
AppView::Login => "bi-box-arrow-in-right",
|
||||
AppView::Home => "bi-house-door",
|
||||
AppView::Administration => "bi-gear",
|
||||
AppView::PersonAdministration => "bi-gear",
|
||||
AppView::Business => "bi-building",
|
||||
AppView::Accounting => "bi-calculator",
|
||||
AppView::Contracts => "bi-file-earmark-text",
|
||||
AppView::Governance => "bi-people",
|
||||
AppView::Treasury => "bi-safe",
|
||||
AppView::Residence => "bi-house",
|
||||
AppView::Entities => "bi-building",
|
||||
AppView::EntitiesRegister => "bi-plus-circle",
|
||||
AppView::EntitiesRegisterSuccess(_) => "bi-check-circle",
|
||||
AppView::EntitiesRegisterFailure => "bi-x-circle",
|
||||
AppView::CompanyView(_) => "bi-building-check",
|
||||
AppView::ResidentRegister => "bi-person-plus",
|
||||
AppView::ResidentRegisterSuccess => "bi-person-check",
|
||||
AppView::ResidentRegisterFailure => "bi-person-x",
|
||||
AppView::ResidentLanding => "bi-globe2",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_available_for_context(&self, context: &ViewContext) -> bool {
|
||||
match self {
|
||||
AppView::Login | AppView::Home => true,
|
||||
AppView::Administration => matches!(context, ViewContext::Business),
|
||||
AppView::PersonAdministration | AppView::Residence => matches!(context, ViewContext::Person),
|
||||
AppView::Business | AppView::Governance => matches!(context, ViewContext::Business),
|
||||
AppView::Accounting | AppView::Contracts | AppView::Treasury => true,
|
||||
AppView::Entities | AppView::EntitiesRegister | AppView::EntitiesRegisterSuccess(_)
|
||||
| AppView::EntitiesRegisterFailure | AppView::CompanyView(_) => matches!(context, ViewContext::Person),
|
||||
AppView::ResidentRegister | AppView::ResidentRegisterSuccess | AppView::ResidentRegisterFailure => true,
|
||||
AppView::ResidentLanding => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_description(&self, context: &ViewContext) -> &'static str {
|
||||
match (self, context) {
|
||||
(AppView::Administration, ViewContext::Business) => "Org setup, members, roles, integrations",
|
||||
(AppView::PersonAdministration, ViewContext::Person) => "Account settings, billing, integrations",
|
||||
(AppView::Business, ViewContext::Business) => "Business overview, registration details, certificate",
|
||||
(AppView::Accounting, ViewContext::Business) => "Revenues, assets, ledgers",
|
||||
(AppView::Accounting, ViewContext::Person) => "Income, holdings, logs (e.g. salary, royalties, crypto inflows)",
|
||||
(AppView::Contracts, ViewContext::Business) => "Agreements, wrappers, signatures",
|
||||
(AppView::Contracts, ViewContext::Person) => "Employment, freelance, operating agreements",
|
||||
(AppView::Governance, ViewContext::Business) => "Voting, rules, proposals",
|
||||
(AppView::Treasury, ViewContext::Business) => "Wallets, safes, asset custody",
|
||||
(AppView::Treasury, ViewContext::Person) => "Your wallets, digital assets, spend permissions",
|
||||
(AppView::Residence, ViewContext::Person) => "Jurisdiction, address, digital domicile",
|
||||
(AppView::Entities, _) => "Your owned companies and corporate entities",
|
||||
(AppView::EntitiesRegister, _) => "Register a new company or entity",
|
||||
(AppView::EntitiesRegisterSuccess(_), _) => "Company registration completed successfully",
|
||||
(AppView::EntitiesRegisterFailure, _) => "Company registration failed - please try again",
|
||||
(AppView::CompanyView(_), _) => "Company details, status, documents, and management",
|
||||
(AppView::ResidentRegister, _) => "Register as a digital resident to access exclusive services",
|
||||
(AppView::ResidentRegisterSuccess, _) => "Digital resident registration completed successfully",
|
||||
(AppView::ResidentRegisterFailure, _) => "Digital resident registration failed - please try again",
|
||||
(AppView::ResidentLanding, _) => "Welcome to Zanzibar Digital Freezone - Your gateway to digital residency",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility functions for URL and history management
|
||||
pub struct HistoryManager;
|
||||
|
||||
impl HistoryManager {
|
||||
/// Update the browser URL using pushState (creates new history entry)
|
||||
pub fn push_url(url: &str) -> Result<(), String> {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(history) = window.history() {
|
||||
history
|
||||
.push_state_with_url(&JsValue::NULL, "", Some(url))
|
||||
.map_err(|e| format!("Failed to push URL: {:?}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("Failed to access browser history".to_string())
|
||||
}
|
||||
|
||||
/// Update the browser URL using replaceState (replaces current history entry)
|
||||
pub fn replace_url(url: &str) -> Result<(), String> {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(history) = window.history() {
|
||||
history
|
||||
.replace_state_with_url(&JsValue::NULL, "", Some(url))
|
||||
.map_err(|e| format!("Failed to replace URL: {:?}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("Failed to access browser history".to_string())
|
||||
}
|
||||
|
||||
/// Get the current pathname from the browser
|
||||
pub fn get_current_path() -> String {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.location().pathname().ok())
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
}
|
||||
3
platform/src/routing/mod.rs
Normal file
3
platform/src/routing/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod app_router;
|
||||
|
||||
pub use app_router::*;
|
||||
392
platform/src/services/company_service.rs
Normal file
392
platform/src/services/company_service.rs
Normal file
@@ -0,0 +1,392 @@
|
||||
use crate::models::*;
|
||||
use gloo::storage::{LocalStorage, Storage};
|
||||
use serde_json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const COMPANIES_STORAGE_KEY: &str = "freezone_companies";
|
||||
const REGISTRATION_FORM_KEY: &str = "freezone_registration_form";
|
||||
const REGISTRATIONS_STORAGE_KEY: &str = "freezone_registrations";
|
||||
const FORM_EXPIRY_HOURS: i64 = 24;
|
||||
|
||||
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CompanyRegistration {
|
||||
pub id: u32,
|
||||
pub company_name: String,
|
||||
pub company_type: CompanyType,
|
||||
pub status: RegistrationStatus,
|
||||
pub created_at: String,
|
||||
pub form_data: CompanyFormData,
|
||||
pub current_step: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum RegistrationStatus {
|
||||
Draft,
|
||||
PendingPayment,
|
||||
PaymentFailed,
|
||||
PendingApproval,
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl RegistrationStatus {
|
||||
pub fn to_string(&self) -> &'static str {
|
||||
match self {
|
||||
RegistrationStatus::Draft => "Draft",
|
||||
RegistrationStatus::PendingPayment => "Pending Payment",
|
||||
RegistrationStatus::PaymentFailed => "Payment Failed",
|
||||
RegistrationStatus::PendingApproval => "Pending Approval",
|
||||
RegistrationStatus::Approved => "Approved",
|
||||
RegistrationStatus::Rejected => "Rejected",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
RegistrationStatus::Draft => "bg-secondary",
|
||||
RegistrationStatus::PendingPayment => "bg-warning",
|
||||
RegistrationStatus::PaymentFailed => "bg-danger",
|
||||
RegistrationStatus::PendingApproval => "bg-info",
|
||||
RegistrationStatus::Approved => "bg-success",
|
||||
RegistrationStatus::Rejected => "bg-danger",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CompanyService;
|
||||
|
||||
impl CompanyService {
|
||||
/// Get all companies from local storage
|
||||
pub fn get_companies() -> Vec<Company> {
|
||||
match LocalStorage::get::<Vec<Company>>(COMPANIES_STORAGE_KEY) {
|
||||
Ok(companies) => companies,
|
||||
Err(_) => {
|
||||
// Initialize with empty list if not found
|
||||
let companies = Vec::new();
|
||||
let _ = LocalStorage::set(COMPANIES_STORAGE_KEY, &companies);
|
||||
companies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save companies to local storage
|
||||
pub fn save_companies(companies: &[Company]) -> Result<(), String> {
|
||||
LocalStorage::set(COMPANIES_STORAGE_KEY, companies)
|
||||
.map_err(|e| format!("Failed to save companies: {:?}", e))
|
||||
}
|
||||
|
||||
/// Add a new company
|
||||
pub fn add_company(mut company: Company) -> Result<Company, String> {
|
||||
let mut companies = Self::get_companies();
|
||||
|
||||
// Generate new ID
|
||||
let max_id = companies.iter().map(|c| c.id).max().unwrap_or(0);
|
||||
company.id = max_id + 1;
|
||||
|
||||
// Generate registration number
|
||||
company.registration_number = Self::generate_registration_number(&company.name);
|
||||
|
||||
companies.push(company.clone());
|
||||
Self::save_companies(&companies)?;
|
||||
|
||||
Ok(company)
|
||||
}
|
||||
|
||||
/// Update an existing company
|
||||
pub fn update_company(updated_company: &Company) -> Result<(), String> {
|
||||
let mut companies = Self::get_companies();
|
||||
|
||||
if let Some(company) = companies.iter_mut().find(|c| c.id == updated_company.id) {
|
||||
*company = updated_company.clone();
|
||||
Self::save_companies(&companies)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Company not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a company
|
||||
pub fn delete_company(company_id: u32) -> Result<(), String> {
|
||||
let mut companies = Self::get_companies();
|
||||
companies.retain(|c| c.id != company_id);
|
||||
Self::save_companies(&companies)
|
||||
}
|
||||
|
||||
/// Get company by ID
|
||||
pub fn get_company_by_id(company_id: u32) -> Option<Company> {
|
||||
Self::get_companies().into_iter().find(|c| c.id == company_id)
|
||||
}
|
||||
|
||||
/// Generate a registration number
|
||||
fn generate_registration_number(company_name: &str) -> String {
|
||||
let date = js_sys::Date::new_0();
|
||||
let year = date.get_full_year();
|
||||
let month = date.get_month() + 1; // JS months are 0-based
|
||||
let day = date.get_date();
|
||||
|
||||
let prefix = company_name
|
||||
.chars()
|
||||
.take(3)
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
|
||||
format!("FZC-{:04}{:02}{:02}-{}", year, month, day, prefix)
|
||||
}
|
||||
|
||||
/// Save registration form data with expiration
|
||||
pub fn save_registration_form(form_data: &CompanyFormData, current_step: u8) -> Result<(), String> {
|
||||
let now = js_sys::Date::now() as i64;
|
||||
let expires_at = now + (FORM_EXPIRY_HOURS * 60 * 60 * 1000);
|
||||
|
||||
let saved_form = SavedRegistrationForm {
|
||||
form_data: form_data.clone(),
|
||||
current_step,
|
||||
saved_at: now,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
LocalStorage::set(REGISTRATION_FORM_KEY, &saved_form)
|
||||
.map_err(|e| format!("Failed to save form: {:?}", e))
|
||||
}
|
||||
|
||||
/// Load registration form data if not expired
|
||||
pub fn load_registration_form() -> Option<(CompanyFormData, u8)> {
|
||||
match LocalStorage::get::<SavedRegistrationForm>(REGISTRATION_FORM_KEY) {
|
||||
Ok(saved_form) => {
|
||||
let now = js_sys::Date::now() as i64;
|
||||
if now < saved_form.expires_at {
|
||||
Some((saved_form.form_data, saved_form.current_step))
|
||||
} else {
|
||||
// Form expired, remove it
|
||||
let _ = LocalStorage::delete(REGISTRATION_FORM_KEY);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear saved registration form
|
||||
pub fn clear_registration_form() -> Result<(), String> {
|
||||
LocalStorage::delete(REGISTRATION_FORM_KEY);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate form data for a specific step
|
||||
pub fn validate_step(form_data: &CompanyFormData, step: u8) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
match step {
|
||||
1 => {
|
||||
if form_data.company_name.trim().is_empty() {
|
||||
errors.push("Company name is required".to_string());
|
||||
} else if form_data.company_name.len() < 2 {
|
||||
errors.push("Company name must be at least 2 characters".to_string());
|
||||
}
|
||||
|
||||
if form_data.company_email.trim().is_empty() {
|
||||
errors.push("Company email is required".to_string());
|
||||
} else if !Self::is_valid_email(&form_data.company_email) {
|
||||
errors.push("Please enter a valid email address".to_string());
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Company type is always valid since it's a dropdown
|
||||
}
|
||||
3 => {
|
||||
if form_data.shareholders.is_empty() {
|
||||
errors.push("At least one shareholder is required".to_string());
|
||||
} else {
|
||||
let total_percentage: f64 = form_data.shareholders.iter().map(|s| s.percentage).sum();
|
||||
if (total_percentage - 100.0).abs() > 0.01 {
|
||||
errors.push(format!("Shareholder percentages must add up to 100% (currently {:.1}%)", total_percentage));
|
||||
}
|
||||
|
||||
for (i, shareholder) in form_data.shareholders.iter().enumerate() {
|
||||
if shareholder.name.trim().is_empty() {
|
||||
errors.push(format!("Shareholder {} name is required", i + 1));
|
||||
}
|
||||
if shareholder.percentage <= 0.0 || shareholder.percentage > 100.0 {
|
||||
errors.push(format!("Shareholder {} percentage must be between 0 and 100", i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
if !form_data.legal_agreements.all_agreed() {
|
||||
let missing = form_data.legal_agreements.missing_agreements();
|
||||
errors.push(format!("Please accept all required agreements: {}", missing.join(", ")));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
errors.push("Invalid step".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::valid()
|
||||
} else {
|
||||
ValidationResult::invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple email validation
|
||||
fn is_valid_email(email: &str) -> bool {
|
||||
email.contains('@') && email.contains('.') && email.len() > 5
|
||||
}
|
||||
|
||||
/// Create a company from form data (simulated)
|
||||
pub fn create_company_from_form(form_data: &CompanyFormData) -> Result<Company, String> {
|
||||
let now = js_sys::Date::new_0();
|
||||
let incorporation_date = format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
now.get_full_year(),
|
||||
now.get_month() + 1,
|
||||
now.get_date()
|
||||
);
|
||||
|
||||
let company = Company {
|
||||
id: 0, // Will be set by add_company
|
||||
name: form_data.company_name.clone(),
|
||||
company_type: form_data.company_type.clone(),
|
||||
status: CompanyStatus::PendingPayment,
|
||||
registration_number: String::new(), // Will be generated by add_company
|
||||
incorporation_date,
|
||||
email: Some(form_data.company_email.clone()),
|
||||
phone: Some(form_data.company_phone.clone()),
|
||||
website: form_data.company_website.clone(),
|
||||
address: Some(form_data.company_address.clone()),
|
||||
industry: form_data.company_industry.clone(),
|
||||
description: form_data.company_purpose.clone(),
|
||||
fiscal_year_end: form_data.fiscal_year_end.clone(),
|
||||
shareholders: form_data.shareholders.clone(),
|
||||
};
|
||||
|
||||
Self::add_company(company)
|
||||
}
|
||||
|
||||
/// Calculate total payment amount
|
||||
pub fn calculate_payment_amount(company_type: &CompanyType, payment_plan: &PaymentPlan) -> f64 {
|
||||
let pricing = company_type.get_pricing();
|
||||
let twin_fee = 2.0; // ZDFZ Twin fee
|
||||
let monthly_total = pricing.monthly_fee + twin_fee;
|
||||
|
||||
let subscription_amount = match payment_plan {
|
||||
PaymentPlan::Monthly => monthly_total,
|
||||
PaymentPlan::Yearly => monthly_total * 12.0 * payment_plan.get_discount(),
|
||||
PaymentPlan::TwoYear => monthly_total * 24.0 * payment_plan.get_discount(),
|
||||
};
|
||||
|
||||
pricing.setup_fee + subscription_amount
|
||||
}
|
||||
|
||||
/// Initialize with sample data for demonstration
|
||||
pub fn initialize_sample_data() -> Result<(), String> {
|
||||
let companies = Self::get_companies();
|
||||
if companies.is_empty() {
|
||||
let sample_companies = vec![
|
||||
Company {
|
||||
id: 1,
|
||||
name: "Zanzibar Digital Solutions".to_string(),
|
||||
company_type: CompanyType::StartupFZC,
|
||||
status: CompanyStatus::Active,
|
||||
registration_number: "FZC-20250101-ZAN".to_string(),
|
||||
incorporation_date: "2025-01-01".to_string(),
|
||||
email: Some("contact@zanzibar-digital.com".to_string()),
|
||||
phone: Some("+255 123 456 789".to_string()),
|
||||
website: Some("https://zanzibar-digital.com".to_string()),
|
||||
address: Some("Stone Town, Zanzibar".to_string()),
|
||||
industry: Some("Technology".to_string()),
|
||||
description: Some("Digital solutions and blockchain development".to_string()),
|
||||
fiscal_year_end: Some("12-31".to_string()),
|
||||
shareholders: vec![
|
||||
Shareholder {
|
||||
name: "John Smith".to_string(),
|
||||
resident_id: "ID123456789".to_string(),
|
||||
percentage: 60.0,
|
||||
},
|
||||
Shareholder {
|
||||
name: "Sarah Johnson".to_string(),
|
||||
resident_id: "ID987654321".to_string(),
|
||||
percentage: 40.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
Company {
|
||||
id: 2,
|
||||
name: "Ocean Trading Co".to_string(),
|
||||
company_type: CompanyType::GrowthFZC,
|
||||
status: CompanyStatus::Active,
|
||||
registration_number: "FZC-20250102-OCE".to_string(),
|
||||
incorporation_date: "2025-01-02".to_string(),
|
||||
email: Some("info@ocean-trading.com".to_string()),
|
||||
phone: Some("+255 987 654 321".to_string()),
|
||||
website: None,
|
||||
address: Some("Pemba Island, Zanzibar".to_string()),
|
||||
industry: Some("Trading".to_string()),
|
||||
description: Some("International trading and logistics".to_string()),
|
||||
fiscal_year_end: Some("06-30".to_string()),
|
||||
shareholders: vec![
|
||||
Shareholder {
|
||||
name: "Ahmed Hassan".to_string(),
|
||||
resident_id: "ID555666777".to_string(),
|
||||
percentage: 100.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Self::save_companies(&sample_companies)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all registrations from local storage
|
||||
pub fn get_registrations() -> Vec<CompanyRegistration> {
|
||||
match LocalStorage::get::<Vec<CompanyRegistration>>(REGISTRATIONS_STORAGE_KEY) {
|
||||
Ok(registrations) => registrations,
|
||||
Err(_) => {
|
||||
// Initialize with empty list if not found
|
||||
let registrations = Vec::new();
|
||||
let _ = LocalStorage::set(REGISTRATIONS_STORAGE_KEY, ®istrations);
|
||||
registrations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save registrations to local storage
|
||||
pub fn save_registrations(registrations: &[CompanyRegistration]) -> Result<(), String> {
|
||||
LocalStorage::set(REGISTRATIONS_STORAGE_KEY, registrations)
|
||||
.map_err(|e| format!("Failed to save registrations: {:?}", e))
|
||||
}
|
||||
|
||||
/// Add or update a registration
|
||||
pub fn save_registration(mut registration: CompanyRegistration) -> Result<CompanyRegistration, String> {
|
||||
let mut registrations = Self::get_registrations();
|
||||
|
||||
if registration.id == 0 {
|
||||
// Generate new ID for new registration
|
||||
let max_id = registrations.iter().map(|r| r.id).max().unwrap_or(0);
|
||||
registration.id = max_id + 1;
|
||||
registrations.push(registration.clone());
|
||||
} else {
|
||||
// Update existing registration
|
||||
if let Some(existing) = registrations.iter_mut().find(|r| r.id == registration.id) {
|
||||
*existing = registration.clone();
|
||||
} else {
|
||||
return Err("Registration not found".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Self::save_registrations(®istrations)?;
|
||||
Ok(registration)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct SavedRegistrationForm {
|
||||
form_data: CompanyFormData,
|
||||
current_step: u8,
|
||||
saved_at: i64,
|
||||
expires_at: i64,
|
||||
}
|
||||
223
platform/src/services/mock_billing_api.rs
Normal file
223
platform/src/services/mock_billing_api.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Plan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub price: f64,
|
||||
pub features: Vec<String>,
|
||||
pub popular: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaymentMethod {
|
||||
pub id: String,
|
||||
pub method_type: String,
|
||||
pub last_four: String,
|
||||
pub expires: Option<String>,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
pub date: String,
|
||||
pub description: String,
|
||||
pub amount: f64,
|
||||
pub status: String,
|
||||
pub pdf_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Subscription {
|
||||
pub plan: Plan,
|
||||
pub next_billing_date: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockBillingApi {
|
||||
pub current_subscription: Subscription,
|
||||
pub available_plans: Vec<Plan>,
|
||||
pub payment_methods: Vec<PaymentMethod>,
|
||||
pub invoices: Vec<Invoice>,
|
||||
}
|
||||
|
||||
impl Default for MockBillingApi {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockBillingApi {
|
||||
pub fn new() -> Self {
|
||||
let available_plans = vec![
|
||||
Plan {
|
||||
id: "starter".to_string(),
|
||||
name: "Starter".to_string(),
|
||||
price: 29.0,
|
||||
features: vec![
|
||||
"Up to 100 transactions".to_string(),
|
||||
"Basic reporting".to_string(),
|
||||
"Email support".to_string(),
|
||||
],
|
||||
popular: false,
|
||||
},
|
||||
Plan {
|
||||
id: "business_pro".to_string(),
|
||||
name: "Business Pro".to_string(),
|
||||
price: 99.0,
|
||||
features: vec![
|
||||
"Unlimited transactions".to_string(),
|
||||
"Advanced reporting".to_string(),
|
||||
"Priority support".to_string(),
|
||||
"API access".to_string(),
|
||||
],
|
||||
popular: true,
|
||||
},
|
||||
Plan {
|
||||
id: "enterprise".to_string(),
|
||||
name: "Enterprise".to_string(),
|
||||
price: 299.0,
|
||||
features: vec![
|
||||
"Unlimited everything".to_string(),
|
||||
"Custom integrations".to_string(),
|
||||
"Dedicated support".to_string(),
|
||||
"SLA guarantee".to_string(),
|
||||
"White-label options".to_string(),
|
||||
],
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
let current_subscription = Subscription {
|
||||
plan: available_plans[1].clone(), // Business Pro
|
||||
next_billing_date: "January 15, 2025".to_string(),
|
||||
status: "active".to_string(),
|
||||
};
|
||||
|
||||
let payment_methods = vec![
|
||||
PaymentMethod {
|
||||
id: "card_4242".to_string(),
|
||||
method_type: "Credit Card".to_string(),
|
||||
last_four: "4242".to_string(),
|
||||
expires: Some("12/26".to_string()),
|
||||
is_primary: true,
|
||||
},
|
||||
PaymentMethod {
|
||||
id: "bank_5678".to_string(),
|
||||
method_type: "Bank Transfer".to_string(),
|
||||
last_four: "5678".to_string(),
|
||||
expires: None,
|
||||
is_primary: false,
|
||||
},
|
||||
];
|
||||
|
||||
let invoices = vec![
|
||||
Invoice {
|
||||
id: "inv_001".to_string(),
|
||||
date: "Dec 15, 2024".to_string(),
|
||||
description: "Business Pro - Monthly".to_string(),
|
||||
amount: 99.0,
|
||||
status: "Paid".to_string(),
|
||||
pdf_url: "data:application/pdf;base64,JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PAovVGl0bGUgKEludm9pY2UgIzAwMSkKL0NyZWF0b3IgKE1vY2sgQmlsbGluZyBBUEkpCi9Qcm9kdWNlciAoTW9jayBCaWxsaW5nIEFQSSkKL0NyZWF0aW9uRGF0ZSAoRDoyMDI0MTIxNTAwMDAwMFopCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9DYXRhbG9nCi9QYWdlcyAzIDAgUgo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0tpZHMgWzQgMCBSXQovQ291bnQgMQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcyIDcyMCA3MiA3MjAgcmUKUwpRCkJUCi9GMSAxMiBUZgo3MiA3MDAgVGQKKEludm9pY2UgIzAwMSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYQo+PgplbmRvYmoKeHJlZgowIDcKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAxNzQgMDAwMDAgbiAKMDAwMDAwMDIyMSAwMDAwMCBuIAowMDAwMDAwMjc4IDAwMDAwIG4gCjAwMDAwMDAzNzUgMDAwMDAgbiAKMDAwMDAwMDQ2OSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDcKL1Jvb3QgMiAwIFIKPj4Kc3RhcnR4cmVmCjU2NwolJUVPRgo=".to_string(),
|
||||
},
|
||||
Invoice {
|
||||
id: "inv_002".to_string(),
|
||||
date: "Nov 15, 2024".to_string(),
|
||||
description: "Business Pro - Monthly".to_string(),
|
||||
amount: 99.0,
|
||||
status: "Paid".to_string(),
|
||||
pdf_url: "data:application/pdf;base64,JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PAovVGl0bGUgKEludm9pY2UgIzAwMikKL0NyZWF0b3IgKE1vY2sgQmlsbGluZyBBUEkpCi9Qcm9kdWNlciAoTW9jayBCaWxsaW5nIEFQSSkKL0NyZWF0aW9uRGF0ZSAoRDoyMDI0MTExNTAwMDAwMFopCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9DYXRhbG9nCi9QYWdlcyAzIDAgUgo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0tpZHMgWzQgMCBSXQovQ291bnQgMQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcyIDcyMCA3MiA3MjAgcmUKUwpRCkJUCi9GMSAxMiBUZgo3MiA3MDAgVGQKKEludm9pY2UgIzAwMikgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYQo+PgplbmRvYmoKeHJlZgowIDcKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAxNzQgMDAwMDAgbiAKMDAwMDAwMDIyMSAwMDAwMCBuIAowMDAwMDAwMjc4IDAwMDAwIG4gCjAwMDAwMDAzNzUgMDAwMDAgbiAKMDAwMDAwMDQ2OSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDcKL1Jvb3QgMiAwIFIKPj4Kc3RhcnR4cmVmCjU2NwolJUVPRgo=".to_string(),
|
||||
},
|
||||
Invoice {
|
||||
id: "inv_003".to_string(),
|
||||
date: "Oct 15, 2024".to_string(),
|
||||
description: "Business Pro - Monthly".to_string(),
|
||||
amount: 99.0,
|
||||
status: "Paid".to_string(),
|
||||
pdf_url: "data:application/pdf;base64,JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PAovVGl0bGUgKEludm9pY2UgIzAwMykKL0NyZWF0b3IgKE1vY2sgQmlsbGluZyBBUEkpCi9Qcm9kdWNlciAoTW9jayBCaWxsaW5nIEFQSSkKL0NyZWF0aW9uRGF0ZSAoRDoyMDI0MTAxNTAwMDAwMFopCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9DYXRhbG9nCi9QYWdlcyAzIDAgUgo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0tpZHMgWzQgMCBSXQovQ291bnQgMQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcyIDcyMCA3MiA3MjAgcmUKUwpRCkJUCi9GMSAxMiBUZgo3MiA3MDAgVGQKKEludm9pY2UgIzAwMykgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYQo+PgplbmRvYmoKeHJlZgowIDcKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAxNzQgMDAwMDAgbiAKMDAwMDAwMDIyMSAwMDAwMCBuIAowMDAwMDAwMjc4IDAwMDAwIG4gCjAwMDAwMDAzNzUgMDAwMDAgbiAKMDAwMDAwMDQ2OSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDcKL1Jvb3QgMiAwIFIKPj4Kc3RhcnR4cmVmCjU2NwolJUVPRgo=".to_string(),
|
||||
},
|
||||
Invoice {
|
||||
id: "inv_004".to_string(),
|
||||
date: "Sep 15, 2024".to_string(),
|
||||
description: "Setup Fee".to_string(),
|
||||
amount: 50.0,
|
||||
status: "Paid".to_string(),
|
||||
pdf_url: "data:application/pdf;base64,JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PAovVGl0bGUgKFNldHVwIEZlZSBJbnZvaWNlKQovQ3JlYXRvciAoTW9jayBCaWxsaW5nIEFQSSkKL1Byb2R1Y2VyIChNb2NrIEJpbGxpbmcgQVBJKQovQ3JlYXRpb25EYXRlIChEOjIwMjQwOTE1MDAwMDAwWikKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL0NhdGFsb2cKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagozIDAgb2JqCjw8Ci9UeXBlIC9QYWdlcwovS2lkcyBbNCAwIFJdCi9Db3VudCAxCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9UeXBlIC9QYWdlCi9QYXJlbnQgMyAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDUgMCBSCj4+CmVuZG9iago1IDAgb2JqCjw8Ci9MZW5ndGggNDQKPj4Kc3RyZWFtCkJUCnEKNzIgNzIwIDcyIDcyMCByZQpTClEKQlQKL0YxIDEyIFRmCjcyIDcwMCBUZAooU2V0dXAgRmVlKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCjYgMCBvYmoKPDwKL1R5cGUgL0ZvbnQKL1N1YnR5cGUgL1R5cGUxCi9CYXNlRm9udCAvSGVsdmV0aWNhCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDE3NCAwMDAwMCBuIAowMDAwMDAwMjIxIDAwMDAwIG4gCjAwMDAwMDAyNzggMDAwMDAgbiAKMDAwMDAwMDM3NSAwMDAwMCBuIAowMDAwMDAwNDY5IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAyIDAgUgo+PgpzdGFydHhyZWYKNTY3CiUlRU9GCg==".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
Self {
|
||||
current_subscription,
|
||||
available_plans,
|
||||
payment_methods,
|
||||
invoices,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription methods
|
||||
pub async fn get_current_subscription(&self) -> Result<Subscription, String> {
|
||||
// Simulate API delay
|
||||
Ok(self.current_subscription.clone())
|
||||
}
|
||||
|
||||
pub async fn get_available_plans(&self) -> Result<Vec<Plan>, String> {
|
||||
Ok(self.available_plans.clone())
|
||||
}
|
||||
|
||||
pub async fn change_plan(&mut self, plan_id: &str) -> Result<Subscription, String> {
|
||||
if let Some(plan) = self.available_plans.iter().find(|p| p.id == plan_id) {
|
||||
self.current_subscription.plan = plan.clone();
|
||||
Ok(self.current_subscription.clone())
|
||||
} else {
|
||||
Err("Plan not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cancel_subscription(&mut self) -> Result<String, String> {
|
||||
self.current_subscription.status = "cancelled".to_string();
|
||||
Ok("Subscription cancelled successfully".to_string())
|
||||
}
|
||||
|
||||
// Payment methods
|
||||
pub async fn get_payment_methods(&self) -> Result<Vec<PaymentMethod>, String> {
|
||||
Ok(self.payment_methods.clone())
|
||||
}
|
||||
|
||||
pub async fn add_payment_method(&mut self, method_type: &str, last_four: &str) -> Result<PaymentMethod, String> {
|
||||
let new_method = PaymentMethod {
|
||||
id: format!("{}_{}", method_type.to_lowercase(), last_four),
|
||||
method_type: method_type.to_string(),
|
||||
last_four: last_four.to_string(),
|
||||
expires: if method_type == "Credit Card" { Some("12/28".to_string()) } else { None },
|
||||
is_primary: false,
|
||||
};
|
||||
|
||||
self.payment_methods.push(new_method.clone());
|
||||
Ok(new_method)
|
||||
}
|
||||
|
||||
pub async fn remove_payment_method(&mut self, method_id: &str) -> Result<String, String> {
|
||||
if let Some(pos) = self.payment_methods.iter().position(|m| m.id == method_id) {
|
||||
self.payment_methods.remove(pos);
|
||||
Ok("Payment method removed successfully".to_string())
|
||||
} else {
|
||||
Err("Payment method not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Invoices
|
||||
pub async fn get_invoices(&self) -> Result<Vec<Invoice>, String> {
|
||||
Ok(self.invoices.clone())
|
||||
}
|
||||
|
||||
pub async fn download_invoice(&self, invoice_id: &str) -> Result<String, String> {
|
||||
if let Some(invoice) = self.invoices.iter().find(|i| i.id == invoice_id) {
|
||||
Ok(invoice.pdf_url.clone())
|
||||
} else {
|
||||
Err("Invoice not found".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
7
platform/src/services/mod.rs
Normal file
7
platform/src/services/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod mock_billing_api;
|
||||
pub mod company_service;
|
||||
pub mod resident_service;
|
||||
|
||||
pub use mock_billing_api::*;
|
||||
pub use company_service::*;
|
||||
pub use resident_service::*;
|
||||
257
platform/src/services/resident_service.rs
Normal file
257
platform/src/services/resident_service.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use crate::models::company::{DigitalResident, DigitalResidentFormData, KycStatus};
|
||||
use gloo::storage::{LocalStorage, Storage};
|
||||
|
||||
const RESIDENTS_STORAGE_KEY: &str = "freezone_residents";
|
||||
const RESIDENT_REGISTRATIONS_STORAGE_KEY: &str = "freezone_resident_registrations";
|
||||
const RESIDENT_FORM_KEY: &str = "freezone_resident_registration_form";
|
||||
const FORM_EXPIRY_HOURS: i64 = 24;
|
||||
|
||||
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ResidentRegistration {
|
||||
pub id: u32,
|
||||
pub full_name: String,
|
||||
pub email: String,
|
||||
pub status: ResidentRegistrationStatus,
|
||||
pub created_at: String,
|
||||
pub form_data: DigitalResidentFormData,
|
||||
pub current_step: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ResidentRegistrationStatus {
|
||||
Draft,
|
||||
PendingPayment,
|
||||
PaymentFailed,
|
||||
PendingApproval,
|
||||
Approved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl ResidentRegistrationStatus {
|
||||
pub fn to_string(&self) -> &'static str {
|
||||
match self {
|
||||
ResidentRegistrationStatus::Draft => "Draft",
|
||||
ResidentRegistrationStatus::PendingPayment => "Pending Payment",
|
||||
ResidentRegistrationStatus::PaymentFailed => "Payment Failed",
|
||||
ResidentRegistrationStatus::PendingApproval => "Pending Approval",
|
||||
ResidentRegistrationStatus::Approved => "Approved",
|
||||
ResidentRegistrationStatus::Rejected => "Rejected",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
ResidentRegistrationStatus::Draft => "bg-secondary",
|
||||
ResidentRegistrationStatus::PendingPayment => "bg-warning",
|
||||
ResidentRegistrationStatus::PaymentFailed => "bg-danger",
|
||||
ResidentRegistrationStatus::PendingApproval => "bg-info",
|
||||
ResidentRegistrationStatus::Approved => "bg-success",
|
||||
ResidentRegistrationStatus::Rejected => "bg-danger",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResidentService;
|
||||
|
||||
impl ResidentService {
|
||||
/// Get all residents from local storage
|
||||
pub fn get_residents() -> Vec<DigitalResident> {
|
||||
match LocalStorage::get::<Vec<DigitalResident>>(RESIDENTS_STORAGE_KEY) {
|
||||
Ok(residents) => residents,
|
||||
Err(_) => {
|
||||
// Initialize with empty list if not found
|
||||
let residents = Vec::new();
|
||||
let _ = LocalStorage::set(RESIDENTS_STORAGE_KEY, &residents);
|
||||
residents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save residents to local storage
|
||||
pub fn save_residents(residents: &[DigitalResident]) -> Result<(), String> {
|
||||
LocalStorage::set(RESIDENTS_STORAGE_KEY, residents)
|
||||
.map_err(|e| format!("Failed to save residents: {:?}", e))
|
||||
}
|
||||
|
||||
/// Add a new resident
|
||||
pub fn add_resident(mut resident: DigitalResident) -> Result<DigitalResident, String> {
|
||||
let mut residents = Self::get_residents();
|
||||
|
||||
// Generate new ID
|
||||
let max_id = residents.iter().map(|r| r.id).max().unwrap_or(0);
|
||||
resident.id = max_id + 1;
|
||||
|
||||
residents.push(resident.clone());
|
||||
Self::save_residents(&residents)?;
|
||||
|
||||
Ok(resident)
|
||||
}
|
||||
|
||||
/// Get all resident registrations from local storage
|
||||
pub fn get_resident_registrations() -> Vec<ResidentRegistration> {
|
||||
match LocalStorage::get::<Vec<ResidentRegistration>>(RESIDENT_REGISTRATIONS_STORAGE_KEY) {
|
||||
Ok(registrations) => registrations,
|
||||
Err(_) => {
|
||||
// Initialize with empty list if not found
|
||||
let registrations = Vec::new();
|
||||
let _ = LocalStorage::set(RESIDENT_REGISTRATIONS_STORAGE_KEY, ®istrations);
|
||||
registrations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save resident registrations to local storage
|
||||
pub fn save_resident_registrations(registrations: &[ResidentRegistration]) -> Result<(), String> {
|
||||
LocalStorage::set(RESIDENT_REGISTRATIONS_STORAGE_KEY, registrations)
|
||||
.map_err(|e| format!("Failed to save resident registrations: {:?}", e))
|
||||
}
|
||||
|
||||
/// Add or update a resident registration
|
||||
pub fn save_resident_registration(mut registration: ResidentRegistration) -> Result<ResidentRegistration, String> {
|
||||
let mut registrations = Self::get_resident_registrations();
|
||||
|
||||
if registration.id == 0 {
|
||||
// Generate new ID for new registration
|
||||
let max_id = registrations.iter().map(|r| r.id).max().unwrap_or(0);
|
||||
registration.id = max_id + 1;
|
||||
registrations.push(registration.clone());
|
||||
} else {
|
||||
// Update existing registration
|
||||
if let Some(existing) = registrations.iter_mut().find(|r| r.id == registration.id) {
|
||||
*existing = registration.clone();
|
||||
} else {
|
||||
return Err("Registration not found".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Self::save_resident_registrations(®istrations)?;
|
||||
Ok(registration)
|
||||
}
|
||||
|
||||
/// Save registration form data with expiration
|
||||
pub fn save_resident_registration_form(form_data: &DigitalResidentFormData, current_step: u8) -> Result<(), String> {
|
||||
let now = js_sys::Date::now() as i64;
|
||||
let expires_at = now + (FORM_EXPIRY_HOURS * 60 * 60 * 1000);
|
||||
|
||||
let saved_form = SavedResidentRegistrationForm {
|
||||
form_data: form_data.clone(),
|
||||
current_step,
|
||||
saved_at: now,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
LocalStorage::set(RESIDENT_FORM_KEY, &saved_form)
|
||||
.map_err(|e| format!("Failed to save form: {:?}", e))
|
||||
}
|
||||
|
||||
/// Load registration form data if not expired
|
||||
pub fn load_resident_registration_form() -> Option<(DigitalResidentFormData, u8)> {
|
||||
match LocalStorage::get::<SavedResidentRegistrationForm>(RESIDENT_FORM_KEY) {
|
||||
Ok(saved_form) => {
|
||||
let now = js_sys::Date::now() as i64;
|
||||
if now < saved_form.expires_at {
|
||||
Some((saved_form.form_data, saved_form.current_step))
|
||||
} else {
|
||||
// Form expired, remove it
|
||||
let _ = LocalStorage::delete(RESIDENT_FORM_KEY);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear saved registration form
|
||||
pub fn clear_resident_registration_form() -> Result<(), String> {
|
||||
LocalStorage::delete(RESIDENT_FORM_KEY);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a resident from form data
|
||||
pub fn create_resident_from_form(form_data: &DigitalResidentFormData) -> Result<DigitalResident, String> {
|
||||
let now = js_sys::Date::new_0();
|
||||
let registration_date = format!(
|
||||
"{:04}-{:02}-{:02}",
|
||||
now.get_full_year(),
|
||||
now.get_month() + 1,
|
||||
now.get_date()
|
||||
);
|
||||
|
||||
let resident = DigitalResident {
|
||||
id: 0, // Will be set by add_resident
|
||||
full_name: form_data.full_name.clone(),
|
||||
email: form_data.email.clone(),
|
||||
phone: form_data.phone.clone(),
|
||||
date_of_birth: form_data.date_of_birth.clone(),
|
||||
nationality: form_data.nationality.clone(),
|
||||
passport_number: form_data.passport_number.clone(),
|
||||
passport_expiry: form_data.passport_expiry.clone(),
|
||||
current_address: form_data.current_address.clone(),
|
||||
city: form_data.city.clone(),
|
||||
country: form_data.country.clone(),
|
||||
postal_code: form_data.postal_code.clone(),
|
||||
occupation: form_data.occupation.clone(),
|
||||
employer: form_data.employer.clone(),
|
||||
annual_income: form_data.annual_income.clone(),
|
||||
education_level: form_data.education_level.clone(),
|
||||
selected_services: form_data.requested_services.clone(),
|
||||
payment_plan: form_data.payment_plan.clone(),
|
||||
registration_date,
|
||||
status: crate::models::company::ResidentStatus::Pending,
|
||||
kyc_documents_uploaded: false, // Will be updated when documents are uploaded
|
||||
kyc_status: KycStatus::NotStarted,
|
||||
public_key: form_data.public_key.clone(),
|
||||
};
|
||||
|
||||
Self::add_resident(resident)
|
||||
}
|
||||
|
||||
/// Validate form data for a specific step (simplified 2-step form)
|
||||
pub fn validate_resident_step(form_data: &DigitalResidentFormData, step: u8) -> crate::models::ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
match step {
|
||||
1 => {
|
||||
// Step 1: Personal Information & KYC (simplified - only name, email, and terms required)
|
||||
if form_data.full_name.trim().is_empty() {
|
||||
errors.push("Full name is required".to_string());
|
||||
}
|
||||
if form_data.email.trim().is_empty() {
|
||||
errors.push("Email is required".to_string());
|
||||
} else if !Self::is_valid_email(&form_data.email) {
|
||||
errors.push("Please enter a valid email address".to_string());
|
||||
}
|
||||
if !form_data.legal_agreements.terms {
|
||||
errors.push("You must agree to the Terms of Service and Privacy Policy".to_string());
|
||||
}
|
||||
// Note: KYC verification is handled separately via button click
|
||||
}
|
||||
2 => {
|
||||
// Step 2: Payment only (no additional agreements needed)
|
||||
// Payment validation will be handled by Stripe
|
||||
}
|
||||
_ => {
|
||||
errors.push("Invalid step".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
crate::models::ValidationResult { is_valid: true, errors: Vec::new() }
|
||||
} else {
|
||||
crate::models::ValidationResult { is_valid: false, errors }
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple email validation
|
||||
fn is_valid_email(email: &str) -> bool {
|
||||
email.contains('@') && email.contains('.') && email.len() > 5
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct SavedResidentRegistrationForm {
|
||||
form_data: DigitalResidentFormData,
|
||||
current_step: u8,
|
||||
saved_at: i64,
|
||||
expires_at: i64,
|
||||
}
|
||||
283
platform/src/views/accounting_view.rs
Normal file
283
platform/src/views/accounting_view.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::routing::ViewContext;
|
||||
use crate::components::ViewComponent;
|
||||
use crate::components::accounting::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AccountingViewProps {
|
||||
pub context: ViewContext,
|
||||
}
|
||||
|
||||
#[function_component(AccountingView)]
|
||||
pub fn accounting_view(props: &AccountingViewProps) -> Html {
|
||||
let context = &props.context;
|
||||
|
||||
// Initialize state with mock data
|
||||
let initial_state = {
|
||||
let mut state = AccountingState::default();
|
||||
|
||||
// Mock revenue data
|
||||
state.revenue_entries = vec![
|
||||
RevenueEntry {
|
||||
id: "INV-2024-001".to_string(),
|
||||
date: "2024-01-15".to_string(),
|
||||
invoice_number: "INV-2024-001".to_string(),
|
||||
client_name: "Tech Corp Ltd".to_string(),
|
||||
client_email: "billing@techcorp.com".to_string(),
|
||||
client_address: "123 Tech Street, Silicon Valley, CA 94000".to_string(),
|
||||
description: "Web Development Services - Q1 2024".to_string(),
|
||||
quantity: 80.0,
|
||||
unit_price: 150.0,
|
||||
subtotal: 12000.0,
|
||||
tax_rate: 0.20,
|
||||
tax_amount: 2400.0,
|
||||
total_amount: 14400.0,
|
||||
category: RevenueCategory::ServiceRevenue,
|
||||
payment_method: PaymentMethod::BankTransfer,
|
||||
payment_status: PaymentStatus::Paid,
|
||||
due_date: "2024-02-14".to_string(),
|
||||
paid_date: Some("2024-02-10".to_string()),
|
||||
notes: "Monthly retainer for web development services".to_string(),
|
||||
recurring: true,
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
RevenueEntry {
|
||||
id: "INV-2024-002".to_string(),
|
||||
date: "2024-01-20".to_string(),
|
||||
invoice_number: "INV-2024-002".to_string(),
|
||||
client_name: "StartupXYZ Inc".to_string(),
|
||||
client_email: "finance@startupxyz.com".to_string(),
|
||||
client_address: "456 Innovation Ave, Austin, TX 78701".to_string(),
|
||||
description: "Software License - Enterprise Plan".to_string(),
|
||||
quantity: 1.0,
|
||||
unit_price: 8500.0,
|
||||
subtotal: 8500.0,
|
||||
tax_rate: 0.20,
|
||||
tax_amount: 1700.0,
|
||||
total_amount: 10200.0,
|
||||
category: RevenueCategory::LicensingRoyalties,
|
||||
payment_method: PaymentMethod::CryptoUSDC,
|
||||
payment_status: PaymentStatus::Pending,
|
||||
due_date: "2024-02-19".to_string(),
|
||||
paid_date: None,
|
||||
notes: "Annual enterprise software license".to_string(),
|
||||
recurring: false,
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
RevenueEntry {
|
||||
id: "INV-2024-003".to_string(),
|
||||
date: "2024-01-25".to_string(),
|
||||
invoice_number: "INV-2024-003".to_string(),
|
||||
client_name: "Enterprise Solutions LLC".to_string(),
|
||||
client_email: "accounts@enterprise-sol.com".to_string(),
|
||||
client_address: "789 Business Blvd, New York, NY 10001".to_string(),
|
||||
description: "Strategic Consulting - Digital Transformation".to_string(),
|
||||
quantity: 40.0,
|
||||
unit_price: 250.0,
|
||||
subtotal: 10000.0,
|
||||
tax_rate: 0.20,
|
||||
tax_amount: 2000.0,
|
||||
total_amount: 12000.0,
|
||||
category: RevenueCategory::ConsultingFees,
|
||||
payment_method: PaymentMethod::WireTransfer,
|
||||
payment_status: PaymentStatus::PartiallyPaid,
|
||||
due_date: "2024-02-24".to_string(),
|
||||
paid_date: Some("2024-02-15".to_string()),
|
||||
notes: "Phase 1 of digital transformation project".to_string(),
|
||||
recurring: false,
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Mock expense data
|
||||
state.expense_entries = vec![
|
||||
ExpenseEntry {
|
||||
id: "EXP-2024-001".to_string(),
|
||||
date: "2024-01-10".to_string(),
|
||||
receipt_number: "RENT-2024-01".to_string(),
|
||||
vendor_name: "Property Management Co".to_string(),
|
||||
vendor_email: "billing@propmanagement.com".to_string(),
|
||||
vendor_address: "321 Real Estate Ave, Downtown, CA 90210".to_string(),
|
||||
description: "Monthly Office Rent - January 2024".to_string(),
|
||||
amount: 2500.0,
|
||||
tax_amount: 0.0,
|
||||
total_amount: 2500.0,
|
||||
category: ExpenseCategory::RentLease,
|
||||
payment_method: PaymentMethod::BankTransfer,
|
||||
payment_status: PaymentStatus::Paid,
|
||||
is_deductible: true,
|
||||
receipt_url: Some("/receipts/rent-jan-2024.pdf".to_string()),
|
||||
approval_status: ApprovalStatus::Approved,
|
||||
approved_by: Some("John Manager".to_string()),
|
||||
notes: "Monthly office rent payment".to_string(),
|
||||
project_code: None,
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
ExpenseEntry {
|
||||
id: "EXP-2024-002".to_string(),
|
||||
date: "2024-01-12".to_string(),
|
||||
receipt_number: "SW-2024-001".to_string(),
|
||||
vendor_name: "SaaS Solutions Inc".to_string(),
|
||||
vendor_email: "billing@saas-solutions.com".to_string(),
|
||||
vendor_address: "555 Cloud Street, Seattle, WA 98101".to_string(),
|
||||
description: "Software Subscriptions Bundle".to_string(),
|
||||
amount: 850.0,
|
||||
tax_amount: 170.0,
|
||||
total_amount: 1020.0,
|
||||
category: ExpenseCategory::SoftwareLicenses,
|
||||
payment_method: PaymentMethod::CreditCard,
|
||||
payment_status: PaymentStatus::Paid,
|
||||
is_deductible: true,
|
||||
receipt_url: Some("/receipts/software-jan-2024.pdf".to_string()),
|
||||
approval_status: ApprovalStatus::Approved,
|
||||
approved_by: Some("Jane CFO".to_string()),
|
||||
notes: "Monthly SaaS subscriptions for team productivity".to_string(),
|
||||
project_code: Some("TECH-001".to_string()),
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
ExpenseEntry {
|
||||
id: "EXP-2024-003".to_string(),
|
||||
date: "2024-01-18".to_string(),
|
||||
receipt_number: "MKT-2024-001".to_string(),
|
||||
vendor_name: "Digital Marketing Agency".to_string(),
|
||||
vendor_email: "invoices@digitalmarketing.com".to_string(),
|
||||
vendor_address: "777 Marketing Plaza, Los Angeles, CA 90028".to_string(),
|
||||
description: "Q1 Digital Marketing Campaign".to_string(),
|
||||
amount: 3200.0,
|
||||
tax_amount: 640.0,
|
||||
total_amount: 3840.0,
|
||||
category: ExpenseCategory::MarketingAdvertising,
|
||||
payment_method: PaymentMethod::CryptoBitcoin,
|
||||
payment_status: PaymentStatus::Pending,
|
||||
is_deductible: true,
|
||||
receipt_url: None,
|
||||
approval_status: ApprovalStatus::RequiresReview,
|
||||
approved_by: None,
|
||||
notes: "Social media and PPC advertising campaign".to_string(),
|
||||
project_code: Some("MKT-Q1-2024".to_string()),
|
||||
currency: "USD".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Mock financial reports data
|
||||
state.financial_reports = vec![
|
||||
FinancialReport {
|
||||
id: 1,
|
||||
report_type: ReportType::ProfitLoss,
|
||||
period_start: "2024-01-01".to_string(),
|
||||
period_end: "2024-01-31".to_string(),
|
||||
generated_date: "2024-01-31".to_string(),
|
||||
status: "Generated".to_string(),
|
||||
},
|
||||
FinancialReport {
|
||||
id: 2,
|
||||
report_type: ReportType::TaxSummary,
|
||||
period_start: "2024-01-01".to_string(),
|
||||
period_end: "2024-01-31".to_string(),
|
||||
generated_date: "2024-01-31".to_string(),
|
||||
status: "Generated".to_string(),
|
||||
},
|
||||
FinancialReport {
|
||||
id: 3,
|
||||
report_type: ReportType::CashFlow,
|
||||
period_start: "2024-01-01".to_string(),
|
||||
period_end: "2024-01-31".to_string(),
|
||||
generated_date: "2024-01-25".to_string(),
|
||||
status: "Generating".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Mock payment transactions data
|
||||
state.payment_transactions = vec![
|
||||
PaymentTransaction {
|
||||
id: "TXN-2024-001".to_string(),
|
||||
invoice_id: Some("INV-2024-001".to_string()),
|
||||
expense_id: None,
|
||||
date: "2024-02-10".to_string(),
|
||||
amount: 14400.0,
|
||||
payment_method: PaymentMethod::BankTransfer,
|
||||
transaction_hash: None,
|
||||
reference_number: Some("REF-2024-001".to_string()),
|
||||
notes: "Full payment received".to_string(),
|
||||
attached_files: vec!["/receipts/payment-inv-001.pdf".to_string()],
|
||||
status: TransactionStatus::Confirmed,
|
||||
},
|
||||
PaymentTransaction {
|
||||
id: "TXN-2024-002".to_string(),
|
||||
invoice_id: Some("INV-2024-003".to_string()),
|
||||
expense_id: None,
|
||||
date: "2024-02-15".to_string(),
|
||||
amount: 6000.0,
|
||||
payment_method: PaymentMethod::CryptoBitcoin,
|
||||
transaction_hash: Some("1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z".to_string()),
|
||||
reference_number: None,
|
||||
notes: "Partial payment - 50% of invoice".to_string(),
|
||||
attached_files: vec![],
|
||||
status: TransactionStatus::Confirmed,
|
||||
},
|
||||
];
|
||||
|
||||
state
|
||||
};
|
||||
|
||||
let state = use_state(|| initial_state);
|
||||
|
||||
// Create tabs content using the new components
|
||||
let mut tabs = HashMap::new();
|
||||
|
||||
match context {
|
||||
ViewContext::Business => {
|
||||
// Overview Tab
|
||||
tabs.insert("Overview".to_string(), html! {
|
||||
<OverviewTab state={state.clone()} />
|
||||
});
|
||||
|
||||
// Revenue Tab
|
||||
tabs.insert("Revenue".to_string(), html! {
|
||||
<RevenueTab state={state.clone()} />
|
||||
});
|
||||
|
||||
// Expenses Tab
|
||||
tabs.insert("Expenses".to_string(), html! {
|
||||
<ExpensesTab state={state.clone()} />
|
||||
});
|
||||
|
||||
// Tax Tab
|
||||
tabs.insert("Tax".to_string(), html! {
|
||||
<TaxTab state={state.clone()} />
|
||||
});
|
||||
|
||||
// Financial Reports Tab
|
||||
tabs.insert("Financial Reports".to_string(), html! {
|
||||
<FinancialReportsTab state={state.clone()} />
|
||||
});
|
||||
},
|
||||
ViewContext::Person => {
|
||||
// For personal context, show simplified version
|
||||
tabs.insert("Income Tracking".to_string(), html! {
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
{"Personal accounting features coming soon. Switch to Business context for full accounting functionality."}
|
||||
</div>
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (title, description) = match context {
|
||||
ViewContext::Business => ("Accounting", "Professional revenue & expense tracking with invoice generation"),
|
||||
ViewContext::Person => ("Accounting", "Personal income and expense tracking"),
|
||||
};
|
||||
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some(title.to_string())}
|
||||
description={Some(description.to_string())}
|
||||
tabs={Some(tabs)}
|
||||
default_tab={match context {
|
||||
ViewContext::Business => Some("Overview".to_string()),
|
||||
ViewContext::Person => Some("Income Tracking".to_string()),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
}
|
||||
755
platform/src/views/administration_view.rs
Normal file
755
platform/src/views/administration_view.rs
Normal file
@@ -0,0 +1,755 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::routing::ViewContext;
|
||||
use crate::components::{ViewComponent, EmptyState};
|
||||
use crate::services::mock_billing_api::{MockBillingApi, Plan};
|
||||
use web_sys::MouseEvent;
|
||||
use wasm_bindgen::JsCast;
|
||||
use gloo::timers::callback::Timeout;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AdministrationViewProps {
|
||||
pub context: ViewContext,
|
||||
}
|
||||
|
||||
#[function_component(AdministrationView)]
|
||||
pub fn administration_view(props: &AdministrationViewProps) -> Html {
|
||||
// Initialize mock billing API
|
||||
let billing_api = use_state(|| MockBillingApi::new());
|
||||
|
||||
// State for managing UI interactions
|
||||
let show_plan_modal = use_state(|| false);
|
||||
let show_cancel_modal = use_state(|| false);
|
||||
let show_add_payment_modal = use_state(|| false);
|
||||
let downloading_invoice = use_state(|| None::<String>);
|
||||
let selected_plan = use_state(|| None::<String>);
|
||||
let loading_action = use_state(|| None::<String>);
|
||||
|
||||
// Event handlers
|
||||
let on_change_plan = {
|
||||
let show_plan_modal = show_plan_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_plan_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel_subscription = {
|
||||
let show_cancel_modal = show_cancel_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_cancel_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let on_confirm_cancel_subscription = {
|
||||
let billing_api = billing_api.clone();
|
||||
let show_cancel_modal = show_cancel_modal.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
loading_action.set(Some("canceling".to_string()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let show_cancel_modal_clone = show_cancel_modal.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
api.current_subscription.status = "cancelled".to_string();
|
||||
billing_api_clone.set(api);
|
||||
loading_action_clone.set(None);
|
||||
show_cancel_modal_clone.set(false);
|
||||
web_sys::console::log_1(&"Subscription canceled successfully".into());
|
||||
}).forget();
|
||||
})
|
||||
};
|
||||
|
||||
let on_download_invoice = {
|
||||
let billing_api = billing_api.clone();
|
||||
let downloading_invoice = downloading_invoice.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(invoice_id) = button.get_attribute("data-invoice-id") {
|
||||
downloading_invoice.set(Some(invoice_id.clone()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let downloading_invoice_clone = downloading_invoice.clone();
|
||||
let invoice_id_clone = invoice_id.clone();
|
||||
|
||||
// Simulate download with timeout
|
||||
Timeout::new(500, move || {
|
||||
let api = (*billing_api_clone).clone();
|
||||
|
||||
// Find the invoice and get its PDF URL
|
||||
if let Some(invoice) = api.invoices.iter().find(|i| i.id == invoice_id_clone) {
|
||||
// Create a link and trigger download
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
if let Ok(anchor) = document.create_element("a") {
|
||||
if let Ok(anchor) = anchor.dyn_into::<web_sys::HtmlElement>() {
|
||||
anchor.set_attribute("href", &invoice.pdf_url).unwrap();
|
||||
anchor.set_attribute("download", &format!("invoice_{}.pdf", invoice_id_clone)).unwrap();
|
||||
anchor.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
web_sys::console::log_1(&"Invoice downloaded successfully".into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Invoice not found".into());
|
||||
}
|
||||
downloading_invoice_clone.set(None);
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_add_payment_method = {
|
||||
let show_add_payment_modal = show_add_payment_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_add_payment_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let on_confirm_add_payment_method = {
|
||||
let billing_api = billing_api.clone();
|
||||
let show_add_payment_modal = show_add_payment_modal.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
loading_action.set(Some("adding_payment".to_string()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let show_add_payment_modal_clone = show_add_payment_modal.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
|
||||
// Add a new payment method
|
||||
let new_method = crate::services::mock_billing_api::PaymentMethod {
|
||||
id: format!("card_{}", api.payment_methods.len() + 1),
|
||||
method_type: "Credit Card".to_string(),
|
||||
last_four: "•••• •••• •••• 4242".to_string(),
|
||||
expires: Some("12/28".to_string()),
|
||||
is_primary: false,
|
||||
};
|
||||
|
||||
api.payment_methods.push(new_method);
|
||||
billing_api_clone.set(api);
|
||||
loading_action_clone.set(None);
|
||||
show_add_payment_modal_clone.set(false);
|
||||
web_sys::console::log_1(&"Payment method added successfully".into());
|
||||
}).forget();
|
||||
})
|
||||
};
|
||||
|
||||
let on_edit_payment_method = {
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(method_id) = button.get_attribute("data-method") {
|
||||
let loading_action_clone = loading_action.clone();
|
||||
let method_id_clone = method_id.clone();
|
||||
|
||||
loading_action.set(Some(format!("editing_{}", method_id)));
|
||||
|
||||
// Simulate API call delay
|
||||
Timeout::new(1000, move || {
|
||||
loading_action_clone.set(None);
|
||||
web_sys::console::log_1(&format!("Edit payment method: {}", method_id_clone).into());
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_remove_payment_method = {
|
||||
let billing_api = billing_api.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(method_id) = button.get_attribute("data-method") {
|
||||
if web_sys::window()
|
||||
.unwrap()
|
||||
.confirm_with_message(&format!("Are you sure you want to remove this payment method?"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
let method_id_clone = method_id.clone();
|
||||
|
||||
loading_action.set(Some(format!("removing_{}", method_id)));
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
|
||||
// Remove the payment method
|
||||
if let Some(pos) = api.payment_methods.iter().position(|m| m.id == method_id_clone) {
|
||||
api.payment_methods.remove(pos);
|
||||
billing_api_clone.set(api);
|
||||
web_sys::console::log_1(&"Payment method removed successfully".into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Payment method not found".into());
|
||||
}
|
||||
|
||||
loading_action_clone.set(None);
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_select_plan = {
|
||||
let selected_plan = selected_plan.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(plan_id) = button.get_attribute("data-plan-id") {
|
||||
selected_plan.set(Some(plan_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_confirm_plan_change = {
|
||||
let billing_api = billing_api.clone();
|
||||
let selected_plan = selected_plan.clone();
|
||||
let show_plan_modal = show_plan_modal.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(plan_id) = (*selected_plan).clone() {
|
||||
loading_action.set(Some("changing_plan".to_string()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let show_plan_modal_clone = show_plan_modal.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
let plan_id_clone = plan_id.clone();
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
|
||||
// Change the plan
|
||||
if let Some(plan) = api.available_plans.iter().find(|p| p.id == plan_id_clone) {
|
||||
api.current_subscription.plan = plan.clone();
|
||||
billing_api_clone.set(api);
|
||||
web_sys::console::log_1(&"Plan changed successfully".into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Plan not found".into());
|
||||
}
|
||||
|
||||
loading_action_clone.set(None);
|
||||
show_plan_modal_clone.set(false);
|
||||
}).forget();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let close_modals = {
|
||||
let show_plan_modal = show_plan_modal.clone();
|
||||
let show_cancel_modal = show_cancel_modal.clone();
|
||||
let show_add_payment_modal = show_add_payment_modal.clone();
|
||||
let selected_plan = selected_plan.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_plan_modal.set(false);
|
||||
show_cancel_modal.set(false);
|
||||
show_add_payment_modal.set(false);
|
||||
selected_plan.set(None);
|
||||
})
|
||||
};
|
||||
// Create tabs content
|
||||
let mut tabs = HashMap::new();
|
||||
|
||||
// Organization Setup Tab
|
||||
tabs.insert("Organization Setup".to_string(), html! {
|
||||
<EmptyState
|
||||
icon={"building".to_string()}
|
||||
title={"Organization not configured".to_string()}
|
||||
description={"Set up your organization structure, hierarchy, and basic settings to get started.".to_string()}
|
||||
primary_action={Some(("Setup Organization".to_string(), "#".to_string()))}
|
||||
secondary_action={Some(("Import Settings".to_string(), "#".to_string()))}
|
||||
/>
|
||||
});
|
||||
|
||||
// Shareholders Tab
|
||||
tabs.insert("Shareholders".to_string(), html! {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-people me-2"></i>
|
||||
{"Shareholder Information"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Name"}</th>
|
||||
<th>{"Ownership %"}</th>
|
||||
<th>{"Shares"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Status"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
<i class="bi bi-person text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{"John Doe"}</div>
|
||||
<small class="text-muted">{"Founder & CEO"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="fw-bold">{"65%"}</span></td>
|
||||
<td>{"6,500"}</td>
|
||||
<td><span class="badge bg-primary">{"Ordinary"}</span></td>
|
||||
<td><span class="badge bg-success">{"Active"}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-info rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
<i class="bi bi-person text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{"Sarah Johnson"}</div>
|
||||
<small class="text-muted">{"Co-Founder & CTO"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="fw-bold">{"25%"}</span></td>
|
||||
<td>{"2,500"}</td>
|
||||
<td><span class="badge bg-primary">{"Ordinary"}</span></td>
|
||||
<td><span class="badge bg-success">{"Active"}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-warning rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
|
||||
<i class="bi bi-building text-dark"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{"Innovation Ventures"}</div>
|
||||
<small class="text-muted">{"Investment Fund"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="fw-bold">{"10%"}</span></td>
|
||||
<td>{"1,000"}</td>
|
||||
<td><span class="badge bg-warning text-dark">{"Preferred"}</span></td>
|
||||
<td><span class="badge bg-success">{"Active"}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
{"Total Authorized Shares: 10,000 | Issued Shares: 10,000 | Par Value: $1.00"}
|
||||
</small>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-1"></i>
|
||||
{"Add Shareholder"}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary">
|
||||
<i class="bi bi-download me-1"></i>
|
||||
{"Export Cap Table"}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i>
|
||||
{"Generate Certificate"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
});
|
||||
|
||||
// Members & Roles Tab
|
||||
tabs.insert("Members & Roles".to_string(), html! {
|
||||
<EmptyState
|
||||
icon={"person-badge".to_string()}
|
||||
title={"No team members found".to_string()}
|
||||
description={"Invite team members, assign roles, and control access permissions for your organization.".to_string()}
|
||||
primary_action={Some(("Invite Members".to_string(), "#".to_string()))}
|
||||
secondary_action={Some(("Manage Roles".to_string(), "#".to_string()))}
|
||||
/>
|
||||
});
|
||||
|
||||
// Integrations Tab
|
||||
tabs.insert("Integrations".to_string(), html! {
|
||||
<EmptyState
|
||||
icon={"diagram-3".to_string()}
|
||||
title={"No integrations configured".to_string()}
|
||||
description={"Connect with external services and configure API integrations to streamline your workflow.".to_string()}
|
||||
primary_action={Some(("Browse Integrations".to_string(), "#".to_string()))}
|
||||
secondary_action={Some(("API Documentation".to_string(), "#".to_string()))}
|
||||
/>
|
||||
});
|
||||
|
||||
// Billing and Payments Tab
|
||||
tabs.insert("Billing and Payments".to_string(), {
|
||||
let current_subscription = &billing_api.current_subscription;
|
||||
let current_plan = ¤t_subscription.plan;
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
// Subscription Tier Pane
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-star me-2"></i>
|
||||
{"Current Plan"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-3">
|
||||
<div class="badge bg-primary fs-6 px-3 py-2 mb-2">{¤t_plan.name}</div>
|
||||
<h3 class="text-primary mb-0">{format!("${:.0}", current_plan.price)}<small class="text-muted">{"/month"}</small></h3>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
{for current_plan.features.iter().map(|feature| html! {
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{feature}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">{format!("Status: {}", current_subscription.status)}</small>
|
||||
</div>
|
||||
<div class="mt-3 d-grid gap-2">
|
||||
<button
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
onclick={on_change_plan.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "changing_plan")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "changing_plan") {
|
||||
"Changing..."
|
||||
} else {
|
||||
"Change Plan"
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
onclick={on_cancel_subscription.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "canceling")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "canceling") {
|
||||
"Canceling..."
|
||||
} else {
|
||||
"Cancel Subscription"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
// Payments Table Pane
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-receipt me-2"></i>
|
||||
{"Payment History"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Date"}</th>
|
||||
<th>{"Description"}</th>
|
||||
<th>{"Amount"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Invoice"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for billing_api.invoices.iter().map(|invoice| html! {
|
||||
<tr>
|
||||
<td>{&invoice.date}</td>
|
||||
<td>{&invoice.description}</td>
|
||||
<td>{format!("${:.2}", invoice.amount)}</td>
|
||||
<td><span class="badge bg-success">{&invoice.status}</span></td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
onclick={on_download_invoice.clone()}
|
||||
data-invoice-id={invoice.id.clone()}
|
||||
disabled={downloading_invoice.as_ref().map_or(false, |id| id == &invoice.id)}
|
||||
>
|
||||
<i class={if downloading_invoice.as_ref().map_or(false, |id| id == &invoice.id) { "bi bi-arrow-repeat" } else { "bi bi-download" }}></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Methods Pane
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-credit-card me-2"></i>
|
||||
{"Payment Methods"}
|
||||
</h5>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={on_add_payment_method.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "adding_payment")}
|
||||
>
|
||||
<i class="bi bi-plus me-1"></i>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "adding_payment") {
|
||||
"Adding..."
|
||||
} else {
|
||||
"Add Method"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{for billing_api.payment_methods.iter().map(|method| html! {
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card border">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={format!("bg-{} rounded me-3 d-flex align-items-center justify-content-center",
|
||||
if method.method_type == "card" { "primary" } else { "info" })}
|
||||
style="width: 40px; height: 25px;">
|
||||
<i class={format!("bi bi-{} text-white",
|
||||
if method.method_type == "card" { "credit-card" } else { "bank" })}></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{&method.last_four}</div>
|
||||
<small class="text-muted">{&method.expires}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class={format!("badge bg-{}",
|
||||
if method.is_primary { "success" } else { "secondary" })}>
|
||||
{if method.is_primary { "Primary" } else { "Backup" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm me-2"
|
||||
onclick={on_edit_payment_method.clone()}
|
||||
data-method={method.id.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == &format!("editing_{}", method.id))}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == &format!("editing_{}", method.id)) {
|
||||
"Editing..."
|
||||
} else {
|
||||
"Edit"
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
onclick={on_remove_payment_method.clone()}
|
||||
data-method={method.id.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == &format!("removing_{}", method.id))}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == &format!("removing_{}", method.id)) {
|
||||
"Removing..."
|
||||
} else {
|
||||
"Remove"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<ViewComponent
|
||||
title={Some("Administration".to_string())}
|
||||
description={Some("Org setup, members, roles, integrations".to_string())}
|
||||
tabs={Some(tabs)}
|
||||
default_tab={Some("Organization Setup".to_string())}
|
||||
/>
|
||||
|
||||
// Plan Selection Modal
|
||||
if *show_plan_modal {
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Change Plan"}</h5>
|
||||
<button type="button" class="btn-close" onclick={close_modals.clone()}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
{for billing_api.available_plans.iter().map(|plan| html! {
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class={format!("card h-100 {}",
|
||||
if selected_plan.as_ref().map_or(false, |id| id == &plan.id) { "border-primary" } else { "" })}>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">{&plan.name}</h5>
|
||||
<h3 class="text-primary">{format!("${:.0}", plan.price)}<small class="text-muted">{"/month"}</small></h3>
|
||||
<ul class="list-unstyled mt-3">
|
||||
{for plan.features.iter().map(|feature| html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-check text-success me-1"></i>
|
||||
{feature}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
class={format!("btn btn-{} w-100",
|
||||
if selected_plan.as_ref().map_or(false, |id| id == &plan.id) { "primary" } else { "outline-primary" })}
|
||||
onclick={on_select_plan.clone()}
|
||||
data-plan-id={plan.id.clone()}
|
||||
>
|
||||
{if selected_plan.as_ref().map_or(false, |id| id == &plan.id) { "Selected" } else { "Select" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={close_modals.clone()}>{"Cancel"}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={on_confirm_plan_change.clone()}
|
||||
disabled={selected_plan.is_none() || loading_action.as_ref().map_or(false, |action| action == "changing_plan")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "changing_plan") {
|
||||
"Changing..."
|
||||
} else {
|
||||
"Change Plan"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Cancel Subscription Modal
|
||||
if *show_cancel_modal {
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Cancel Subscription"}</h5>
|
||||
<button type="button" class="btn-close" onclick={close_modals.clone()}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{"Are you sure you want to cancel your subscription? This action cannot be undone."}</p>
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{"Your subscription will remain active until the end of the current billing period."}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={close_modals.clone()}>{"Keep Subscription"}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onclick={on_confirm_cancel_subscription.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "canceling")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "canceling") {
|
||||
"Canceling..."
|
||||
} else {
|
||||
"Cancel Subscription"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Add Payment Method Modal
|
||||
if *show_add_payment_modal {
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Add Payment Method"}</h5>
|
||||
<button type="button" class="btn-close" onclick={close_modals.clone()}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"Card Number"}</label>
|
||||
<input type="text" class="form-control" placeholder="1234 5678 9012 3456" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"Expiry Date"}</label>
|
||||
<input type="text" class="form-control" placeholder="MM/YY" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"CVC"}</label>
|
||||
<input type="text" class="form-control" placeholder="123" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"Cardholder Name"}</label>
|
||||
<input type="text" class="form-control" placeholder="John Doe" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={close_modals.clone()}>{"Cancel"}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={on_confirm_add_payment_method.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "adding_payment")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "adding_payment") {
|
||||
"Adding..."
|
||||
} else {
|
||||
"Add Payment Method"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
421
platform/src/views/business_view.rs
Normal file
421
platform/src/views/business_view.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
use yew::prelude::*;
|
||||
use crate::routing::{ViewContext, AppView};
|
||||
use crate::models::*;
|
||||
use crate::services::CompanyService;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct BusinessViewProps {
|
||||
pub context: ViewContext,
|
||||
pub company_id: Option<u32>,
|
||||
pub on_navigate: Option<Callback<AppView>>,
|
||||
}
|
||||
|
||||
pub enum BusinessViewMsg {
|
||||
LoadCompany,
|
||||
CompanyLoaded(Company),
|
||||
LoadError(String),
|
||||
NavigateBack,
|
||||
}
|
||||
|
||||
pub struct BusinessView {
|
||||
company: Option<Company>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Component for BusinessView {
|
||||
type Message = BusinessViewMsg;
|
||||
type Properties = BusinessViewProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
// Load company data if company_id is provided
|
||||
if ctx.props().company_id.is_some() {
|
||||
ctx.link().send_message(BusinessViewMsg::LoadCompany);
|
||||
}
|
||||
|
||||
Self {
|
||||
company: None,
|
||||
loading: ctx.props().company_id.is_some(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
BusinessViewMsg::LoadCompany => {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
|
||||
if let Some(company_id) = ctx.props().company_id {
|
||||
// Load company data
|
||||
if let Some(company) = CompanyService::get_company_by_id(company_id) {
|
||||
ctx.link().send_message(BusinessViewMsg::CompanyLoaded(company));
|
||||
} else {
|
||||
ctx.link().send_message(BusinessViewMsg::LoadError(
|
||||
format!("Company with ID {} not found", company_id)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Use sample data if no company_id provided
|
||||
let sample_company = Company {
|
||||
id: 1,
|
||||
name: "TechCorp Solutions Ltd.".to_string(),
|
||||
company_type: CompanyType::StartupFZC,
|
||||
status: CompanyStatus::Active,
|
||||
registration_number: "BIZ-2024-001".to_string(),
|
||||
incorporation_date: "January 15, 2024".to_string(),
|
||||
email: Some("contact@techcorp.zdf".to_string()),
|
||||
phone: Some("+255 24 123 4567".to_string()),
|
||||
website: Some("https://techcorp.zdf".to_string()),
|
||||
address: Some("Stone Town Business District, Zanzibar".to_string()),
|
||||
industry: Some("Technology Services".to_string()),
|
||||
description: Some("Leading technology solutions provider in the digital freezone".to_string()),
|
||||
fiscal_year_end: Some("12-31".to_string()),
|
||||
shareholders: vec![
|
||||
Shareholder {
|
||||
name: "John Smith".to_string(),
|
||||
resident_id: "ID123456789".to_string(),
|
||||
percentage: 60.0,
|
||||
},
|
||||
Shareholder {
|
||||
name: "Sarah Johnson".to_string(),
|
||||
resident_id: "ID987654321".to_string(),
|
||||
percentage: 40.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
ctx.link().send_message(BusinessViewMsg::CompanyLoaded(sample_company));
|
||||
}
|
||||
true
|
||||
}
|
||||
BusinessViewMsg::CompanyLoaded(company) => {
|
||||
self.company = Some(company);
|
||||
self.loading = false;
|
||||
true
|
||||
}
|
||||
BusinessViewMsg::LoadError(error) => {
|
||||
self.error = Some(error);
|
||||
self.loading = false;
|
||||
true
|
||||
}
|
||||
BusinessViewMsg::NavigateBack => {
|
||||
if let Some(on_navigate) = &ctx.props().on_navigate {
|
||||
on_navigate.emit(AppView::Entities);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
if self.loading {
|
||||
return self.render_loading();
|
||||
}
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
return self.render_error(error, ctx);
|
||||
}
|
||||
|
||||
let company = self.company.as_ref().unwrap();
|
||||
|
||||
self.render_business_view(company, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
impl BusinessView {
|
||||
fn render_loading(&self) -> Html {
|
||||
html! {
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="text-muted">{"Loading business details..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_error(&self, error: &str, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-exclamation-triangle text-danger mb-3" style="font-size: 3rem;"></i>
|
||||
<h4 class="text-danger mb-3">{"Error Loading Business"}</h4>
|
||||
<p class="text-muted mb-4">{error}</p>
|
||||
<button
|
||||
class="btn btn-primary me-2"
|
||||
onclick={link.callback(|_| BusinessViewMsg::LoadCompany)}
|
||||
>
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>{"Retry"}
|
||||
</button>
|
||||
{if ctx.props().on_navigate.is_some() {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={link.callback(|_| BusinessViewMsg::NavigateBack)}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back to Entities"}
|
||||
</button>
|
||||
}
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_business_view(&self, company: &Company, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="container-fluid py-4">
|
||||
{if ctx.props().company_id.is_some() && ctx.props().on_navigate.is_some() {
|
||||
html! {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<button
|
||||
class="btn btn-outline-secondary me-3"
|
||||
onclick={link.callback(|_| BusinessViewMsg::NavigateBack)}
|
||||
>
|
||||
<i class="bi bi-arrow-left me-1"></i>{"Back to Entities"}
|
||||
</button>
|
||||
<h2 class="mb-0">{"Business Overview"}</h2>
|
||||
</div>
|
||||
<span class={company.status.get_badge_class()}>
|
||||
{company.status.to_string()}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-muted mb-0">{"Complete business information and registration details"}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="mb-1">{"Business Overview"}</h2>
|
||||
<p class="text-muted mb-0">{"Complete business information and registration details"}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-building me-2"></i>
|
||||
{"Business Information"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h6 class="text-muted">{"Company Details"}</h6>
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Legal Name:"}</td>
|
||||
<td>{&company.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Registration ID:"}</td>
|
||||
<td><code>{&company.registration_number}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Founded:"}</td>
|
||||
<td>{&company.incorporation_date}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Industry:"}</td>
|
||||
<td>{company.industry.as_ref().unwrap_or(&"Not specified".to_string())}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Status:"}</td>
|
||||
<td><span class={company.status.get_badge_class()}>{company.status.to_string()}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6 class="text-muted">{"Contact Information"}</h6>
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Address:"}</td>
|
||||
<td>{company.address.as_ref().unwrap_or(&"Not specified".to_string())}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Email:"}</td>
|
||||
<td>{company.email.as_ref().unwrap_or(&"Not specified".to_string())}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Phone:"}</td>
|
||||
<td>{company.phone.as_ref().unwrap_or(&"Not specified".to_string())}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Website:"}</td>
|
||||
<td>
|
||||
{if let Some(website) = &company.website {
|
||||
html! {
|
||||
<a href={website.clone()} target="_blank" class="text-decoration-none">
|
||||
{website} <i class="bi bi-box-arrow-up-right ms-1"></i>
|
||||
</a>
|
||||
}
|
||||
} else {
|
||||
html! { "Not specified" }
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Employees:"}</td>
|
||||
<td>{"12 Staff Members"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6 class="text-muted">{"Shareholders"}</h6>
|
||||
{if !company.shareholders.is_empty() {
|
||||
html! {
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
{for company.shareholders.iter().map(|shareholder| {
|
||||
html! {
|
||||
<tr>
|
||||
<td class="fw-bold">{&shareholder.name}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">
|
||||
{format!("{:.1}%", shareholder.percentage)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<p class="text-muted">{"No shareholders information available"}</p>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
{self.render_business_certificate(company)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_business_certificate(&self, company: &Company) -> Html {
|
||||
html! {
|
||||
// Business Registration Certificate Card (Vertical Mobile Wallet Style)
|
||||
<div class="card shadow-lg border-0" style="background: linear-gradient(135deg, #0099FF 0%, #00CC66 100%); aspect-ratio: 3/4; max-width: 300px;">
|
||||
<div class="card-body text-white p-4 d-flex flex-column h-100">
|
||||
<div class="text-center mb-3">
|
||||
<div class="bg-white bg-opacity-20 rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
|
||||
<i class="bi bi-award fs-3 text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<h6 class="text-white-50 mb-1 text-uppercase" style="font-size: 0.7rem; letter-spacing: 1px;">{"Zanzibar Digital Freezone"}</h6>
|
||||
<h5 class="fw-bold mb-0">{"Business Certificate"}</h5>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="mb-2">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Company Name"}</small>
|
||||
<div class="fw-bold" style="font-size: 0.9rem;">{&company.name}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Registration Number"}</small>
|
||||
<div class="font-monospace fw-bold fs-6">{&company.registration_number}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Incorporation Date"}</small>
|
||||
<div style="font-size: 0.9rem;">{&company.incorporation_date}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"SecPK Wallet"}</small>
|
||||
<div class="font-monospace" style="font-size: 0.75rem; word-break: break-all;">
|
||||
{"sp1k...7x9m"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Status"}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class={format!("badge {} me-2", match company.status {
|
||||
CompanyStatus::Active => "bg-success",
|
||||
CompanyStatus::PendingPayment => "bg-warning",
|
||||
CompanyStatus::Inactive => "bg-secondary",
|
||||
CompanyStatus::Suspended => "bg-danger",
|
||||
})} style="font-size: 0.7rem;">{company.status.to_string()}</span>
|
||||
<i class="bi bi-shield-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// QR Code Section
|
||||
<div class="text-center mb-2">
|
||||
<div class="bg-white rounded p-2 d-inline-block">
|
||||
<div class="bg-dark" style="width: 80px; height: 80px; background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTAgMTBIMjBWMjBIMTBWMTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNMzAgMTBINDBWMjBIMzBWMTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNTAgMTBINjBWMjBINTBWMTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNzAgMTBIODBWMjBINzBWMTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNMTAgMzBIMjBWNDBIMTBWMzBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNTAgMzBINjBWNDBINTBWMzBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNzAgMzBIODBWNDBINzBWMzBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNMTAgNTBIMjBWNjBIMTBWNTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNMzAgNTBINDBWNjBIMzBWNTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNTAgNTBINjBWNjBINTBWNTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNzAgNTBIODBWNjBINzBWNTBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNMTAgNzBIMjBWODBIMTBWNzBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNMzAgNzBINDBWODBIMzBWNzBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNTAgNzBINjBWODBINTBWNzBaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNNzAgNzBIODBWODBINzBWNzBaIiBmaWxsPSJibGFjayIvPgo8L3N2Zz4K'); background-size: cover;">
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.65rem;" class="text-white-50">{"Scan for Verification"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-2 border-top border-white border-opacity-25">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-white-50" style="font-size: 0.65rem;">{"Valid Until"}</small>
|
||||
<small class="fw-bold" style="font-size: 0.75rem;">{"Dec 31, 2024"}</small>
|
||||
</div>
|
||||
<div class="text-center mt-1">
|
||||
<small class="text-white-50" style="font-size: 0.65rem;">{"Digitally Verified"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[function_component(BusinessViewWrapper)]
|
||||
pub fn business_view(props: &BusinessViewProps) -> Html {
|
||||
html! {
|
||||
<BusinessView
|
||||
context={props.context.clone()}
|
||||
company_id={props.company_id}
|
||||
on_navigate={props.on_navigate.clone()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
460
platform/src/views/companies_view.rs
Normal file
460
platform/src/views/companies_view.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::routing::AppView;
|
||||
use crate::components::{ViewComponent, EmptyState, RegistrationWizard};
|
||||
use crate::models::*;
|
||||
use crate::services::{CompanyService, CompanyRegistration, RegistrationStatus};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CompaniesViewProps {
|
||||
pub on_navigate: Option<Callback<AppView>>,
|
||||
#[prop_or_default]
|
||||
pub show_registration: bool,
|
||||
#[prop_or_default]
|
||||
pub registration_success: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub registration_failure: bool,
|
||||
}
|
||||
|
||||
pub enum CompaniesViewMsg {
|
||||
LoadCompanies,
|
||||
CompaniesLoaded(Vec<Company>),
|
||||
LoadRegistrations,
|
||||
RegistrationsLoaded(Vec<CompanyRegistration>),
|
||||
SwitchToCompany(String),
|
||||
ShowRegistration,
|
||||
RegistrationComplete(Company),
|
||||
BackToCompanies,
|
||||
ViewCompany(u32),
|
||||
ContinueRegistration(CompanyRegistration),
|
||||
StartNewRegistration,
|
||||
DeleteRegistration(u32),
|
||||
ShowNewRegistrationForm,
|
||||
HideNewRegistrationForm,
|
||||
}
|
||||
|
||||
pub struct CompaniesView {
|
||||
companies: Vec<Company>,
|
||||
registrations: Vec<CompanyRegistration>,
|
||||
loading: bool,
|
||||
show_registration: bool,
|
||||
current_registration: Option<CompanyRegistration>,
|
||||
show_new_registration_form: bool,
|
||||
}
|
||||
|
||||
impl Component for CompaniesView {
|
||||
type Message = CompaniesViewMsg;
|
||||
type Properties = CompaniesViewProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
// Load companies and registrations on component creation
|
||||
ctx.link().send_message(CompaniesViewMsg::LoadCompanies);
|
||||
ctx.link().send_message(CompaniesViewMsg::LoadRegistrations);
|
||||
|
||||
Self {
|
||||
companies: Vec::new(),
|
||||
registrations: Vec::new(),
|
||||
loading: true,
|
||||
show_registration: ctx.props().show_registration,
|
||||
current_registration: None,
|
||||
show_new_registration_form: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
CompaniesViewMsg::LoadCompanies => {
|
||||
self.loading = true;
|
||||
// Load companies from service
|
||||
let companies = CompanyService::get_companies();
|
||||
ctx.link().send_message(CompaniesViewMsg::CompaniesLoaded(companies));
|
||||
false
|
||||
}
|
||||
CompaniesViewMsg::CompaniesLoaded(companies) => {
|
||||
self.companies = companies;
|
||||
self.loading = false;
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::LoadRegistrations => {
|
||||
// Load actual registrations from service
|
||||
let registrations = CompanyService::get_registrations();
|
||||
ctx.link().send_message(CompaniesViewMsg::RegistrationsLoaded(registrations));
|
||||
false
|
||||
}
|
||||
CompaniesViewMsg::RegistrationsLoaded(registrations) => {
|
||||
self.registrations = registrations;
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::SwitchToCompany(company_id) => {
|
||||
// Navigate to company view
|
||||
if let Some(on_navigate) = &ctx.props().on_navigate {
|
||||
if let Ok(id) = company_id.parse::<u32>() {
|
||||
on_navigate.emit(AppView::CompanyView(id));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
CompaniesViewMsg::ShowRegistration => {
|
||||
self.show_registration = true;
|
||||
self.current_registration = None; // Start fresh registration
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::StartNewRegistration => {
|
||||
self.show_registration = true;
|
||||
self.current_registration = None; // Start fresh registration
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::ContinueRegistration(registration) => {
|
||||
self.show_registration = true;
|
||||
self.current_registration = Some(registration);
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::RegistrationComplete(company) => {
|
||||
// Add new company to list and clear current registration
|
||||
let company_id = company.id;
|
||||
self.companies.push(company);
|
||||
self.current_registration = None;
|
||||
self.show_registration = false;
|
||||
|
||||
// Navigate to registration success step
|
||||
if let Some(on_navigate) = &ctx.props().on_navigate {
|
||||
on_navigate.emit(AppView::EntitiesRegisterSuccess(company_id));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::ViewCompany(company_id) => {
|
||||
// Navigate to company view
|
||||
if let Some(on_navigate) = &ctx.props().on_navigate {
|
||||
on_navigate.emit(AppView::CompanyView(company_id));
|
||||
}
|
||||
false
|
||||
}
|
||||
CompaniesViewMsg::BackToCompanies => {
|
||||
self.show_registration = false;
|
||||
self.current_registration = None;
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::DeleteRegistration(registration_id) => {
|
||||
// Remove registration from list
|
||||
self.registrations.retain(|r| r.id != registration_id);
|
||||
|
||||
// Update storage
|
||||
let _ = CompanyService::save_registrations(&self.registrations);
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::ShowNewRegistrationForm => {
|
||||
self.show_new_registration_form = true;
|
||||
true
|
||||
}
|
||||
CompaniesViewMsg::HideNewRegistrationForm => {
|
||||
self.show_new_registration_form = false;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
// Check if we should show success state
|
||||
if ctx.props().registration_success.is_some() {
|
||||
// Show success state
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some("Registration Successful".to_string())}
|
||||
description={Some("Your company registration has been completed successfully".to_string())}
|
||||
>
|
||||
<RegistrationWizard
|
||||
on_registration_complete={link.callback(CompaniesViewMsg::RegistrationComplete)}
|
||||
on_back_to_companies={link.callback(|_| CompaniesViewMsg::BackToCompanies)}
|
||||
success_company_id={ctx.props().registration_success}
|
||||
show_failure={false}
|
||||
force_fresh_start={false}
|
||||
continue_registration={None}
|
||||
continue_step={None}
|
||||
/>
|
||||
</ViewComponent>
|
||||
}
|
||||
} else if self.show_registration {
|
||||
// Registration view
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some("Register New Company".to_string())}
|
||||
description={Some("Complete the registration process to create your new company".to_string())}
|
||||
>
|
||||
<RegistrationWizard
|
||||
on_registration_complete={link.callback(CompaniesViewMsg::RegistrationComplete)}
|
||||
on_back_to_companies={link.callback(|_| CompaniesViewMsg::BackToCompanies)}
|
||||
success_company_id={None}
|
||||
show_failure={ctx.props().registration_failure}
|
||||
force_fresh_start={self.current_registration.is_none()}
|
||||
continue_registration={self.current_registration.as_ref().map(|r| r.form_data.clone())}
|
||||
continue_step={self.current_registration.as_ref().map(|r| r.current_step)}
|
||||
/>
|
||||
</ViewComponent>
|
||||
}
|
||||
} else {
|
||||
// Main companies view with unified table
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some("Companies".to_string())}
|
||||
description={Some("Manage your companies and registrations".to_string())}
|
||||
>
|
||||
{self.render_companies_content(ctx)}
|
||||
</ViewComponent>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompaniesView {
|
||||
fn render_companies_content(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
if self.loading {
|
||||
return html! {
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="text-muted">{"Loading companies..."}</p>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
if self.companies.is_empty() && self.registrations.is_empty() {
|
||||
return html! {
|
||||
<div class="text-center py-5">
|
||||
<EmptyState
|
||||
icon={"building".to_string()}
|
||||
title={"No companies found".to_string()}
|
||||
description={"Create and manage your owned companies and corporate entities for business operations.".to_string()}
|
||||
primary_action={None}
|
||||
secondary_action={None}
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
onclick={link.callback(|_| CompaniesViewMsg::StartNewRegistration)}
|
||||
>
|
||||
<i class="bi bi-plus-circle me-2"></i>{"Register Your First Company"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{self.render_companies_table(ctx)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_companies_table(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-building me-2"></i>{"Companies & Registrations"}
|
||||
</h5>
|
||||
<small class="text-muted">
|
||||
{format!("{} companies, {} pending registrations", self.companies.len(), self.registrations.len())}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
onclick={link.callback(|_| CompaniesViewMsg::StartNewRegistration)}
|
||||
>
|
||||
<i class="bi bi-plus-circle me-2"></i>{"New Registration"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{"Name"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Date"}</th>
|
||||
<th>{"Progress"}</th>
|
||||
<th class="text-end">{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
// Render active companies first
|
||||
{for self.companies.iter().map(|company| {
|
||||
let company_id = company.id;
|
||||
let on_view = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
link.emit(CompaniesViewMsg::ViewCompany(company_id));
|
||||
})
|
||||
};
|
||||
let on_switch = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
link.emit(CompaniesViewMsg::SwitchToCompany(company_id.to_string()));
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<tr key={format!("company-{}", company.id)}>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-building text-success me-2"></i>
|
||||
<strong>{&company.name}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>{company.company_type.to_string()}</td>
|
||||
<td>
|
||||
<span class={company.status.get_badge_class()}>
|
||||
{company.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td>{&company.incorporation_date}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{"Complete"}</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
onclick={on_view}
|
||||
title="View company details"
|
||||
>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
onclick={on_switch}
|
||||
title="Switch to this entity"
|
||||
>
|
||||
<i class="bi bi-box-arrow-in-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
|
||||
// Render pending registrations
|
||||
{for self.registrations.iter().map(|registration| {
|
||||
let registration_clone = registration.clone();
|
||||
let registration_id = registration.id;
|
||||
let can_continue = matches!(registration.status, RegistrationStatus::Draft | RegistrationStatus::PendingPayment | RegistrationStatus::PaymentFailed);
|
||||
|
||||
let on_continue = {
|
||||
let link = link.clone();
|
||||
let reg = registration_clone.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
link.emit(CompaniesViewMsg::ContinueRegistration(reg.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_delete = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
if web_sys::window()
|
||||
.unwrap()
|
||||
.confirm_with_message("Are you sure you want to delete this registration?")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
link.emit(CompaniesViewMsg::DeleteRegistration(registration_id));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<tr key={format!("registration-{}", registration.id)}>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-file-earmark-text text-warning me-2"></i>
|
||||
{®istration.company_name}
|
||||
<small class="text-muted ms-2">{"(Registration)"}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>{registration.company_type.to_string()}</td>
|
||||
<td>
|
||||
<span class={format!("badge {}", registration.status.get_badge_class())}>
|
||||
{registration.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td>{®istration.created_at}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress me-2" style="width: 60px; height: 8px;">
|
||||
<div
|
||||
class="progress-bar bg-info"
|
||||
style={format!("width: {}%", (registration.current_step as f32 / 5.0 * 100.0))}
|
||||
></div>
|
||||
</div>
|
||||
<small class="text-muted">{format!("{}/5", registration.current_step)}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
{if can_continue {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
onclick={on_continue}
|
||||
title="Continue registration"
|
||||
>
|
||||
<i class="bi bi-play-circle"></i>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
disabled={true}
|
||||
title="Registration complete"
|
||||
>
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</button>
|
||||
}
|
||||
}}
|
||||
<button
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick={on_delete}
|
||||
title="Delete registration"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(CompaniesViewWrapper)]
|
||||
pub fn companies_view(props: &CompaniesViewProps) -> Html {
|
||||
html! {
|
||||
<CompaniesView
|
||||
on_navigate={props.on_navigate.clone()}
|
||||
show_registration={props.show_registration}
|
||||
registration_success={props.registration_success}
|
||||
registration_failure={props.registration_failure}
|
||||
/>
|
||||
}
|
||||
}
|
||||
583
platform/src/views/contracts_view.rs
Normal file
583
platform/src/views/contracts_view.rs
Normal file
@@ -0,0 +1,583 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::routing::ViewContext;
|
||||
use crate::components::ViewComponent;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ContractStatus {
|
||||
Draft,
|
||||
PendingSignatures,
|
||||
Signed,
|
||||
Active,
|
||||
Expired,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ContractStatus {
|
||||
fn to_string(&self) -> &'static str {
|
||||
match self {
|
||||
ContractStatus::Draft => "Draft",
|
||||
ContractStatus::PendingSignatures => "Pending Signatures",
|
||||
ContractStatus::Signed => "Signed",
|
||||
ContractStatus::Active => "Active",
|
||||
ContractStatus::Expired => "Expired",
|
||||
ContractStatus::Cancelled => "Cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
ContractStatus::Draft => "bg-secondary",
|
||||
ContractStatus::PendingSignatures => "bg-warning text-dark",
|
||||
ContractStatus::Signed => "bg-success",
|
||||
ContractStatus::Active => "bg-success",
|
||||
ContractStatus::Expired => "bg-danger",
|
||||
ContractStatus::Cancelled => "bg-dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ContractSigner {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub status: String, // "Pending", "Signed", "Rejected"
|
||||
pub signed_at: Option<String>,
|
||||
pub comments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Contract {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub contract_type: String,
|
||||
pub status: ContractStatus,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub signers: Vec<ContractSigner>,
|
||||
pub terms_and_conditions: Option<String>,
|
||||
pub effective_date: Option<String>,
|
||||
pub expiration_date: Option<String>,
|
||||
}
|
||||
|
||||
impl Contract {
|
||||
fn signed_signers(&self) -> usize {
|
||||
self.signers.iter().filter(|s| s.status == "Signed").count()
|
||||
}
|
||||
|
||||
fn pending_signers(&self) -> usize {
|
||||
self.signers.iter().filter(|s| s.status == "Pending").count()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ContractsViewProps {
|
||||
pub context: ViewContext,
|
||||
}
|
||||
|
||||
pub enum ContractsMsg {
|
||||
CreateContract,
|
||||
EditContract(String),
|
||||
DeleteContract(String),
|
||||
ViewContract(String),
|
||||
FilterByStatus(String),
|
||||
FilterByType(String),
|
||||
SearchContracts(String),
|
||||
}
|
||||
|
||||
pub struct ContractsViewComponent {
|
||||
contracts: Vec<Contract>,
|
||||
filtered_contracts: Vec<Contract>,
|
||||
status_filter: String,
|
||||
type_filter: String,
|
||||
search_filter: String,
|
||||
}
|
||||
|
||||
impl Component for ContractsViewComponent {
|
||||
type Message = ContractsMsg;
|
||||
type Properties = ContractsViewProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
let contracts = Self::get_sample_contracts();
|
||||
let filtered_contracts = contracts.clone();
|
||||
|
||||
Self {
|
||||
contracts,
|
||||
filtered_contracts,
|
||||
status_filter: String::new(),
|
||||
type_filter: String::new(),
|
||||
search_filter: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
ContractsMsg::CreateContract => {
|
||||
// Handle create contract navigation
|
||||
true
|
||||
}
|
||||
ContractsMsg::EditContract(id) => {
|
||||
// Handle edit contract navigation
|
||||
true
|
||||
}
|
||||
ContractsMsg::DeleteContract(id) => {
|
||||
// Handle delete contract
|
||||
self.contracts.retain(|c| c.id != id);
|
||||
self.apply_filters();
|
||||
true
|
||||
}
|
||||
ContractsMsg::ViewContract(id) => {
|
||||
// Handle view contract navigation
|
||||
true
|
||||
}
|
||||
ContractsMsg::FilterByStatus(status) => {
|
||||
self.status_filter = status;
|
||||
self.apply_filters();
|
||||
true
|
||||
}
|
||||
ContractsMsg::FilterByType(contract_type) => {
|
||||
self.type_filter = contract_type;
|
||||
self.apply_filters();
|
||||
true
|
||||
}
|
||||
ContractsMsg::SearchContracts(query) => {
|
||||
self.search_filter = query;
|
||||
self.apply_filters();
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let context = &ctx.props().context;
|
||||
|
||||
let (title, description) = match context {
|
||||
ViewContext::Business => ("Legal Contracts", "Manage business agreements and legal documents"),
|
||||
ViewContext::Person => ("Contracts", "Personal contracts and agreements"),
|
||||
};
|
||||
|
||||
// Create tabs content
|
||||
let mut tabs = HashMap::new();
|
||||
|
||||
// Contracts Tab
|
||||
tabs.insert("Contracts".to_string(), self.render_contracts_tab(ctx));
|
||||
|
||||
// Create Contract Tab
|
||||
tabs.insert("Create Contract".to_string(), self.render_create_contract_tab(ctx));
|
||||
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={title.to_string()}
|
||||
description={description.to_string()}
|
||||
tabs={tabs}
|
||||
default_tab={"Contracts".to_string()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContractsViewComponent {
|
||||
fn get_sample_contracts() -> Vec<Contract> {
|
||||
vec![
|
||||
Contract {
|
||||
id: "1".to_string(),
|
||||
title: "Service Agreement - Web Development".to_string(),
|
||||
description: "Development of company website and maintenance".to_string(),
|
||||
contract_type: "Service Agreement".to_string(),
|
||||
status: ContractStatus::PendingSignatures,
|
||||
created_by: "John Smith".to_string(),
|
||||
created_at: "2024-01-15".to_string(),
|
||||
updated_at: "2024-01-16".to_string(),
|
||||
signers: vec![
|
||||
ContractSigner {
|
||||
id: "s1".to_string(),
|
||||
name: "Alice Johnson".to_string(),
|
||||
email: "alice@example.com".to_string(),
|
||||
status: "Signed".to_string(),
|
||||
signed_at: Some("2024-01-16 10:30".to_string()),
|
||||
comments: Some("Looks good!".to_string()),
|
||||
},
|
||||
ContractSigner {
|
||||
id: "s2".to_string(),
|
||||
name: "Bob Wilson".to_string(),
|
||||
email: "bob@example.com".to_string(),
|
||||
status: "Pending".to_string(),
|
||||
signed_at: None,
|
||||
comments: None,
|
||||
},
|
||||
],
|
||||
terms_and_conditions: Some("# Service Agreement\n\nThis agreement outlines...".to_string()),
|
||||
effective_date: Some("2024-02-01".to_string()),
|
||||
expiration_date: Some("2024-12-31".to_string()),
|
||||
},
|
||||
Contract {
|
||||
id: "2".to_string(),
|
||||
title: "Non-Disclosure Agreement".to_string(),
|
||||
description: "Confidentiality agreement for project collaboration".to_string(),
|
||||
contract_type: "Non-Disclosure Agreement".to_string(),
|
||||
status: ContractStatus::Signed,
|
||||
created_by: "Sarah Davis".to_string(),
|
||||
created_at: "2024-01-10".to_string(),
|
||||
updated_at: "2024-01-12".to_string(),
|
||||
signers: vec![
|
||||
ContractSigner {
|
||||
id: "s3".to_string(),
|
||||
name: "Mike Brown".to_string(),
|
||||
email: "mike@example.com".to_string(),
|
||||
status: "Signed".to_string(),
|
||||
signed_at: Some("2024-01-12 14:20".to_string()),
|
||||
comments: None,
|
||||
},
|
||||
ContractSigner {
|
||||
id: "s4".to_string(),
|
||||
name: "Lisa Green".to_string(),
|
||||
email: "lisa@example.com".to_string(),
|
||||
status: "Signed".to_string(),
|
||||
signed_at: Some("2024-01-12 16:45".to_string()),
|
||||
comments: Some("Agreed to all terms".to_string()),
|
||||
},
|
||||
],
|
||||
terms_and_conditions: Some("# Non-Disclosure Agreement\n\nThe parties agree...".to_string()),
|
||||
effective_date: Some("2024-01-12".to_string()),
|
||||
expiration_date: Some("2026-01-12".to_string()),
|
||||
},
|
||||
Contract {
|
||||
id: "3".to_string(),
|
||||
title: "Employment Contract - Software Engineer".to_string(),
|
||||
description: "Full-time employment agreement".to_string(),
|
||||
contract_type: "Employment Contract".to_string(),
|
||||
status: ContractStatus::Draft,
|
||||
created_by: "HR Department".to_string(),
|
||||
created_at: "2024-01-20".to_string(),
|
||||
updated_at: "2024-01-20".to_string(),
|
||||
signers: vec![],
|
||||
terms_and_conditions: Some("# Employment Contract\n\nPosition: Software Engineer...".to_string()),
|
||||
effective_date: Some("2024-02-15".to_string()),
|
||||
expiration_date: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn apply_filters(&mut self) {
|
||||
self.filtered_contracts = self.contracts
|
||||
.iter()
|
||||
.filter(|contract| {
|
||||
// Status filter
|
||||
if !self.status_filter.is_empty() && contract.status.to_string() != self.status_filter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if !self.type_filter.is_empty() && contract.contract_type != self.type_filter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if !self.search_filter.is_empty() {
|
||||
let query = self.search_filter.to_lowercase();
|
||||
if !contract.title.to_lowercase().contains(&query) &&
|
||||
!contract.description.to_lowercase().contains(&query) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn render_contracts_tab(&self, _ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
// Filters Section
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Filters"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">{"Status"}</label>
|
||||
<select class="form-select" id="status">
|
||||
<option value="">{"All Statuses"}</option>
|
||||
<option value="Draft">{"Draft"}</option>
|
||||
<option value="Pending Signatures">{"Pending Signatures"}</option>
|
||||
<option value="Signed">{"Signed"}</option>
|
||||
<option value="Active">{"Active"}</option>
|
||||
<option value="Expired">{"Expired"}</option>
|
||||
<option value="Cancelled">{"Cancelled"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">{"Contract Type"}</label>
|
||||
<select class="form-select" id="type">
|
||||
<option value="">{"All Types"}</option>
|
||||
<option value="Service Agreement">{"Service Agreement"}</option>
|
||||
<option value="Employment Contract">{"Employment Contract"}</option>
|
||||
<option value="Non-Disclosure Agreement">{"Non-Disclosure Agreement"}</option>
|
||||
<option value="Service Level Agreement">{"Service Level Agreement"}</option>
|
||||
<option value="Other">{"Other"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">{"Search"}</label>
|
||||
<input type="text" class="form-control" id="search"
|
||||
placeholder="Search by title or description" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<a href="#" class="btn btn-primary w-100">
|
||||
<i class="bi bi-plus-circle me-1"></i>{"Create New"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Contracts Table
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Contracts"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{self.render_contracts_table(_ctx)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_contracts_table(&self, ctx: &Context<Self>) -> Html {
|
||||
if self.filtered_contracts.is_empty() {
|
||||
return html! {
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-earmark-text fs-1 text-muted"></i>
|
||||
<p class="mt-3 text-muted">{"No contracts found"}</p>
|
||||
<a href="#" class="btn btn-primary mt-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>{"Create New Contract"}
|
||||
</a>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Contract Title"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Created By"}</th>
|
||||
<th>{"Signers"}</th>
|
||||
<th>{"Created"}</th>
|
||||
<th>{"Updated"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for self.filtered_contracts.iter().map(|contract| self.render_contract_row(contract, ctx))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_contract_row(&self, contract: &Contract, _ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<tr>
|
||||
<td>
|
||||
<a href="#" class="text-decoration-none">
|
||||
{&contract.title}
|
||||
</a>
|
||||
</td>
|
||||
<td>{&contract.contract_type}</td>
|
||||
<td>
|
||||
<span class={format!("badge {}", contract.status.badge_class())}>
|
||||
{contract.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td>{&contract.created_by}</td>
|
||||
<td>{format!("{}/{}", contract.signed_signers(), contract.signers.len())}</td>
|
||||
<td>{&contract.created_at}</td>
|
||||
<td>{&contract.updated_at}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<a href="#" class="btn btn-sm btn-primary" title="View">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{if matches!(contract.status, ContractStatus::Draft) {
|
||||
html! {
|
||||
<>
|
||||
<a href="#" class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="#" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_create_contract_tab(&self, _ctx: &Context<Self>) -> Html {
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Contract Details"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">
|
||||
{"Contract Title "}<span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="title" name="title" required=true />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="contract_type" class="form-label">
|
||||
{"Contract Type "}<span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<select class="form-select" id="contract_type" name="contract_type" required=true>
|
||||
<option value="" selected=true disabled=true>{"Select a contract type"}</option>
|
||||
<option value="Service Agreement">{"Service Agreement"}</option>
|
||||
<option value="Employment Contract">{"Employment Contract"}</option>
|
||||
<option value="Non-Disclosure Agreement">{"Non-Disclosure Agreement"}</option>
|
||||
<option value="Service Level Agreement">{"Service Level Agreement"}</option>
|
||||
<option value="Partnership Agreement">{"Partnership Agreement"}</option>
|
||||
<option value="Other">{"Other"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">
|
||||
{"Description "}<span class="text-danger">{"*"}</span>
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3" required=true></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">{"Contract Content (Markdown)"}</label>
|
||||
<textarea class="form-control" id="content" name="content" rows="10"
|
||||
placeholder="# Contract Title
|
||||
|
||||
## 1. Introduction
|
||||
This contract outlines the terms and conditions...
|
||||
|
||||
## 2. Scope of Work
|
||||
- Task 1
|
||||
- Task 2
|
||||
- Task 3
|
||||
|
||||
## 3. Payment Terms
|
||||
Payment will be made according to the following schedule:
|
||||
|
||||
| Milestone | Amount | Due Date |
|
||||
|-----------|--------|----------|
|
||||
| Start | $1,000 | Upon signing |
|
||||
| Completion | $2,000 | Upon delivery |
|
||||
|
||||
## 4. Terms and Conditions
|
||||
**Important:** All parties must agree to these terms.
|
||||
|
||||
> This is a blockquote for important notices.
|
||||
|
||||
---
|
||||
|
||||
*For questions, contact [support@example.com](mailto:support@example.com)*"></textarea>
|
||||
<div class="form-text">
|
||||
<strong>{"Markdown Support:"}</strong>{" You can use markdown formatting including headers (#), lists (-), tables (|), bold (**text**), italic (*text*), links, and more."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="effective_date" class="form-label">{"Effective Date"}</label>
|
||||
<input type="date" class="form-control" id="effective_date" name="effective_date" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="expiration_date" class="form-label">{"Expiration Date"}</label>
|
||||
<input type="date" class="form-control" id="expiration_date" name="expiration_date" />
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<button type="button" class="btn btn-outline-secondary me-md-2">{"Cancel"}</button>
|
||||
<button type="submit" class="btn btn-primary">{"Create Contract"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Tips"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{"Creating a new contract is just the first step. After creating the contract, you'll be able to:"}</p>
|
||||
<ul>
|
||||
<li>{"Add signers who need to approve the contract"}</li>
|
||||
<li>{"Edit the contract content"}</li>
|
||||
<li>{"Send the contract for signatures"}</li>
|
||||
<li>{"Track the signing progress"}</li>
|
||||
</ul>
|
||||
<p>{"The contract will be in "}<strong>{"Draft"}</strong>{" status until you send it for signatures."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Contract Templates"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{"You can use one of our pre-defined templates to get started quickly:"}</p>
|
||||
<div class="list-group">
|
||||
<button type="button" class="list-group-item list-group-item-action">
|
||||
{"Non-Disclosure Agreement"}
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action">
|
||||
{"Service Agreement"}
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action">
|
||||
{"Employment Contract"}
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action">
|
||||
{"Service Level Agreement"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(ContractsView)]
|
||||
pub fn contracts_view(props: &ContractsViewProps) -> Html {
|
||||
html! {
|
||||
<ContractsViewComponent context={props.context.clone()} />
|
||||
}
|
||||
}
|
||||
449
platform/src/views/entities_view.rs
Normal file
449
platform/src/views/entities_view.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::routing::AppView;
|
||||
use crate::components::{ViewComponent, EmptyState, CompaniesList, RegistrationWizard};
|
||||
use crate::models::*;
|
||||
use crate::services::{CompanyService, CompanyRegistration, RegistrationStatus};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EntitiesViewProps {
|
||||
pub on_navigate: Option<Callback<AppView>>,
|
||||
#[prop_or_default]
|
||||
pub show_registration: bool,
|
||||
#[prop_or_default]
|
||||
pub registration_success: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub registration_failure: bool,
|
||||
}
|
||||
|
||||
pub enum EntitiesViewMsg {
|
||||
LoadCompanies,
|
||||
CompaniesLoaded(Vec<Company>),
|
||||
LoadRegistrations,
|
||||
RegistrationsLoaded(Vec<CompanyRegistration>),
|
||||
SwitchToCompany(String),
|
||||
ShowRegistration,
|
||||
RegistrationComplete(Company),
|
||||
BackToCompanies,
|
||||
ViewCompany(u32),
|
||||
ContinueRegistration(CompanyRegistration),
|
||||
StartNewRegistration,
|
||||
DeleteRegistration(u32),
|
||||
}
|
||||
|
||||
pub struct EntitiesView {
|
||||
companies: Vec<Company>,
|
||||
registrations: Vec<CompanyRegistration>,
|
||||
loading: bool,
|
||||
show_registration: bool,
|
||||
current_registration: Option<CompanyRegistration>,
|
||||
}
|
||||
|
||||
impl Component for EntitiesView {
|
||||
type Message = EntitiesViewMsg;
|
||||
type Properties = EntitiesViewProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
// Load companies and registrations on component creation
|
||||
ctx.link().send_message(EntitiesViewMsg::LoadCompanies);
|
||||
ctx.link().send_message(EntitiesViewMsg::LoadRegistrations);
|
||||
|
||||
Self {
|
||||
companies: Vec::new(),
|
||||
registrations: Vec::new(),
|
||||
loading: true,
|
||||
show_registration: ctx.props().show_registration,
|
||||
current_registration: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
EntitiesViewMsg::LoadCompanies => {
|
||||
self.loading = true;
|
||||
// Load companies from service
|
||||
let companies = CompanyService::get_companies();
|
||||
ctx.link().send_message(EntitiesViewMsg::CompaniesLoaded(companies));
|
||||
false
|
||||
}
|
||||
EntitiesViewMsg::CompaniesLoaded(companies) => {
|
||||
self.companies = companies;
|
||||
self.loading = false;
|
||||
true
|
||||
}
|
||||
EntitiesViewMsg::LoadRegistrations => {
|
||||
// Load actual registrations from service
|
||||
let registrations = CompanyService::get_registrations();
|
||||
ctx.link().send_message(EntitiesViewMsg::RegistrationsLoaded(registrations));
|
||||
false
|
||||
}
|
||||
EntitiesViewMsg::RegistrationsLoaded(registrations) => {
|
||||
self.registrations = registrations;
|
||||
true
|
||||
}
|
||||
EntitiesViewMsg::SwitchToCompany(company_id) => {
|
||||
// Navigate to company view
|
||||
if let Some(on_navigate) = &ctx.props().on_navigate {
|
||||
if let Ok(id) = company_id.parse::<u32>() {
|
||||
on_navigate.emit(AppView::CompanyView(id));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
EntitiesViewMsg::ShowRegistration => {
|
||||
self.show_registration = true;
|
||||
self.current_registration = None; // Start fresh registration
|
||||
true
|
||||
}
|
||||
EntitiesViewMsg::StartNewRegistration => {
|
||||
self.show_registration = true;
|
||||
self.current_registration = None; // Start fresh registration
|
||||
true
|
||||
}
|
||||
EntitiesViewMsg::ContinueRegistration(registration) => {
|
||||
self.show_registration = true;
|
||||
self.current_registration = Some(registration);
|
||||
true
|
||||
}
|
||||
EntitiesViewMsg::RegistrationComplete(company) => {
|
||||
// Add new company to list and clear current registration
|
||||
let company_id = company.id;
|
||||
self.companies.push(company);
|
||||
self.current_registration = None;
|
||||
self.show_registration = false;
|
||||
|
||||
// Navigate to registration success step
|
||||
if let Some(on_navigate) = &ctx.props().on_navigate {
|
||||
on_navigate.emit(AppView::EntitiesRegisterSuccess(company_id));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
EntitiesViewMsg::ViewCompany(company_id) => {
|
||||
// Navigate to company view
|
||||
if let Some(on_navigate) = &ctx.props().on_navigate {
|
||||
on_navigate.emit(AppView::CompanyView(company_id));
|
||||
}
|
||||
false
|
||||
}
|
||||
EntitiesViewMsg::BackToCompanies => {
|
||||
self.show_registration = false;
|
||||
self.current_registration = None;
|
||||
true
|
||||
}
|
||||
EntitiesViewMsg::DeleteRegistration(registration_id) => {
|
||||
// Remove registration from list
|
||||
self.registrations.retain(|r| r.id != registration_id);
|
||||
|
||||
// Update storage
|
||||
let _ = CompanyService::save_registrations(&self.registrations);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
// Check if we should show success state
|
||||
if ctx.props().registration_success.is_some() {
|
||||
// Show success state
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some("Registration Successful".to_string())}
|
||||
description={Some("Your company registration has been completed successfully".to_string())}
|
||||
>
|
||||
<RegistrationWizard
|
||||
on_registration_complete={link.callback(EntitiesViewMsg::RegistrationComplete)}
|
||||
on_back_to_companies={link.callback(|_| EntitiesViewMsg::BackToCompanies)}
|
||||
success_company_id={ctx.props().registration_success}
|
||||
show_failure={false}
|
||||
force_fresh_start={false}
|
||||
continue_registration={None}
|
||||
continue_step={None}
|
||||
/>
|
||||
</ViewComponent>
|
||||
}
|
||||
} else if self.show_registration {
|
||||
// Registration view
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some("Register New Company".to_string())}
|
||||
description={Some("Complete the registration process to create your new company".to_string())}
|
||||
>
|
||||
<RegistrationWizard
|
||||
on_registration_complete={link.callback(EntitiesViewMsg::RegistrationComplete)}
|
||||
on_back_to_companies={link.callback(|_| EntitiesViewMsg::BackToCompanies)}
|
||||
success_company_id={None}
|
||||
show_failure={ctx.props().registration_failure}
|
||||
force_fresh_start={self.current_registration.is_none()}
|
||||
continue_registration={self.current_registration.as_ref().map(|r| r.form_data.clone())}
|
||||
continue_step={self.current_registration.as_ref().map(|r| r.current_step)}
|
||||
/>
|
||||
</ViewComponent>
|
||||
}
|
||||
} else {
|
||||
// Tabbed view with Companies and Register tabs
|
||||
let mut tabs = HashMap::new();
|
||||
|
||||
// Companies tab - shows established companies with detailed info
|
||||
tabs.insert("Companies".to_string(), self.render_companies_tab(ctx));
|
||||
|
||||
// Register tab - shows pending registrations in table format + new registration button
|
||||
tabs.insert("Register".to_string(), self.render_register_tab(ctx));
|
||||
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some("Companies".to_string())}
|
||||
description={Some("Manage your companies and registrations".to_string())}
|
||||
tabs={Some(tabs)}
|
||||
default_tab={Some("Companies".to_string())}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EntitiesView {
|
||||
fn render_companies_tab(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
if self.companies.is_empty() && !self.loading {
|
||||
html! {
|
||||
<div class="text-center py-5">
|
||||
<EmptyState
|
||||
icon={"building".to_string()}
|
||||
title={"No companies found".to_string()}
|
||||
description={"Create and manage your owned companies and corporate entities for business operations.".to_string()}
|
||||
primary_action={None}
|
||||
secondary_action={None}
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
onclick={link.callback(|_| EntitiesViewMsg::StartNewRegistration)}
|
||||
>
|
||||
<i class="bi bi-plus-circle me-2"></i>{"Register Your First Company"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else if self.loading {
|
||||
html! {
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="text-muted">{"Loading companies..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<CompaniesList
|
||||
companies={self.companies.clone()}
|
||||
on_view_company={link.callback(|id: u32| EntitiesViewMsg::SwitchToCompany(id.to_string()))}
|
||||
on_switch_to_entity={link.callback(|id: u32| EntitiesViewMsg::SwitchToCompany(id.to_string()))}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_register_tab(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
// Header with new registration button
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-plus-circle-fill text-success" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
<h4 class="mb-3">{"Start New Registration"}</h4>
|
||||
<p class="text-muted mb-4">
|
||||
{"Begin the process to register a new company or legal entity"}
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
onclick={link.callback(|_| EntitiesViewMsg::StartNewRegistration)}
|
||||
>
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>{"Start Registration"}
|
||||
</button>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">
|
||||
{"The registration process takes 5 steps and can be saved at any time"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Pending registrations table
|
||||
{self.render_registrations_table(ctx)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_registrations_table(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
if self.registrations.is_empty() {
|
||||
return html! {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>{"Pending Registrations"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-file-earmark-text display-4 text-muted mb-3"></i>
|
||||
<h6 class="text-muted">{"No pending registrations"}</h6>
|
||||
<p class="text-muted small">{"Your company registration applications will appear here"}</p>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>{"Pending Registrations"}
|
||||
</h5>
|
||||
<small class="text-muted">
|
||||
{format!("{} pending registrations", self.registrations.len())}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{"Company Name"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Created"}</th>
|
||||
<th>{"Progress"}</th>
|
||||
<th class="text-end">{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for self.registrations.iter().map(|registration| {
|
||||
let registration_clone = registration.clone();
|
||||
let registration_id = registration.id;
|
||||
let can_continue = matches!(registration.status, RegistrationStatus::Draft | RegistrationStatus::PendingPayment | RegistrationStatus::PaymentFailed);
|
||||
|
||||
let on_continue = {
|
||||
let link = link.clone();
|
||||
let reg = registration_clone.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
link.send_message(EntitiesViewMsg::ContinueRegistration(reg.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_delete = {
|
||||
let link = link.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
if web_sys::window()
|
||||
.unwrap()
|
||||
.confirm_with_message("Are you sure you want to delete this registration?")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
link.send_message(EntitiesViewMsg::DeleteRegistration(registration_id));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<tr key={format!("registration-{}", registration.id)}>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-file-earmark-text text-warning me-2"></i>
|
||||
<div>
|
||||
<strong>{®istration.company_name}</strong>
|
||||
{if registration.company_name.is_empty() || registration.company_name == "Draft Registration" {
|
||||
html! { <small class="text-muted d-block">{"Draft Registration"}</small> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{registration.company_type.to_string()}</td>
|
||||
<td>
|
||||
<span class={format!("badge {}", registration.status.get_badge_class())}>
|
||||
{registration.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td>{®istration.created_at}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress me-2" style="width: 80px; height: 8px;">
|
||||
<div
|
||||
class="progress-bar bg-info"
|
||||
style={format!("width: {}%", (registration.current_step as f32 / 5.0 * 100.0))}
|
||||
></div>
|
||||
</div>
|
||||
<small class="text-muted">{format!("{}/5", registration.current_step)}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
{if can_continue {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
onclick={on_continue}
|
||||
title="Continue registration"
|
||||
>
|
||||
<i class="bi bi-play-circle me-1"></i>{"Continue"}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
disabled={true}
|
||||
title="Registration complete"
|
||||
>
|
||||
<i class="bi bi-check-circle me-1"></i>{"Complete"}
|
||||
</button>
|
||||
}
|
||||
}}
|
||||
<button
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick={on_delete}
|
||||
title="Delete registration"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(EntitiesViewWrapper)]
|
||||
pub fn entities_view(props: &EntitiesViewProps) -> Html {
|
||||
html! {
|
||||
<EntitiesView
|
||||
on_navigate={props.on_navigate.clone()}
|
||||
show_registration={props.show_registration}
|
||||
registration_success={props.registration_success}
|
||||
registration_failure={props.registration_failure}
|
||||
/>
|
||||
}
|
||||
}
|
||||
652
platform/src/views/governance_view.rs
Normal file
652
platform/src/views/governance_view.rs
Normal file
@@ -0,0 +1,652 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::routing::ViewContext;
|
||||
use crate::components::{ViewComponent, EmptyState};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct GovernanceViewProps {
|
||||
pub context: ViewContext,
|
||||
}
|
||||
|
||||
pub enum GovernanceMsg {
|
||||
SwitchTab(String),
|
||||
CreateProposal,
|
||||
VoteOnProposal(u32, String),
|
||||
LoadProposals,
|
||||
}
|
||||
|
||||
pub struct GovernanceView {
|
||||
active_tab: String,
|
||||
proposals: Vec<Proposal>,
|
||||
loading: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Proposal {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub creator_name: String,
|
||||
pub status: ProposalStatus,
|
||||
pub vote_start_date: Option<String>,
|
||||
pub vote_end_date: Option<String>,
|
||||
pub created_at: String,
|
||||
pub yes_votes: u32,
|
||||
pub no_votes: u32,
|
||||
pub abstain_votes: u32,
|
||||
pub total_votes: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ProposalStatus {
|
||||
Draft,
|
||||
Active,
|
||||
Approved,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ProposalStatus {
|
||||
pub fn to_string(&self) -> &'static str {
|
||||
match self {
|
||||
ProposalStatus::Draft => "Draft",
|
||||
ProposalStatus::Active => "Active",
|
||||
ProposalStatus::Approved => "Approved",
|
||||
ProposalStatus::Rejected => "Rejected",
|
||||
ProposalStatus::Cancelled => "Cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
ProposalStatus::Draft => "badge bg-secondary",
|
||||
ProposalStatus::Active => "badge bg-success",
|
||||
ProposalStatus::Approved => "badge bg-primary",
|
||||
ProposalStatus::Rejected => "badge bg-danger",
|
||||
ProposalStatus::Cancelled => "badge bg-warning",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for GovernanceView {
|
||||
type Message = GovernanceMsg;
|
||||
type Properties = GovernanceViewProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
active_tab: "Overview".to_string(),
|
||||
proposals: Self::get_sample_proposals(),
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
GovernanceMsg::SwitchTab(tab) => {
|
||||
self.active_tab = tab;
|
||||
true
|
||||
}
|
||||
GovernanceMsg::CreateProposal => {
|
||||
// Handle create proposal logic
|
||||
true
|
||||
}
|
||||
GovernanceMsg::VoteOnProposal(_id, _vote_type) => {
|
||||
// Handle voting logic
|
||||
true
|
||||
}
|
||||
GovernanceMsg::LoadProposals => {
|
||||
self.loading = true;
|
||||
// Load proposals from service
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
// Create tabs content
|
||||
let mut tabs = HashMap::new();
|
||||
|
||||
// Overview Tab
|
||||
tabs.insert("Overview".to_string(), self.render_overview(ctx));
|
||||
|
||||
// Proposals Tab
|
||||
tabs.insert("Proposals".to_string(), self.render_proposals(ctx));
|
||||
|
||||
// Create Proposal Tab
|
||||
tabs.insert("Create Proposal".to_string(), self.render_create_proposal(ctx));
|
||||
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={"Governance".to_string()}
|
||||
description={"Voting, rules, proposals".to_string()}
|
||||
tabs={tabs}
|
||||
default_tab={"Overview".to_string()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GovernanceView {
|
||||
fn get_sample_proposals() -> Vec<Proposal> {
|
||||
vec![
|
||||
Proposal {
|
||||
id: 1,
|
||||
title: "Increase Block Rewards for Validators".to_string(),
|
||||
description: "Proposal to increase validator rewards by 15% to improve network security and encourage more participation.".to_string(),
|
||||
creator_name: "Alice Johnson".to_string(),
|
||||
status: ProposalStatus::Active,
|
||||
vote_start_date: Some("2024-01-15".to_string()),
|
||||
vote_end_date: Some("2024-01-22".to_string()),
|
||||
created_at: "2024-01-10".to_string(),
|
||||
yes_votes: 45,
|
||||
no_votes: 12,
|
||||
abstain_votes: 8,
|
||||
total_votes: 65,
|
||||
},
|
||||
Proposal {
|
||||
id: 2,
|
||||
title: "Community Development Fund Allocation".to_string(),
|
||||
description: "Allocate 100,000 tokens from treasury for community development initiatives and grants.".to_string(),
|
||||
creator_name: "Bob Smith".to_string(),
|
||||
status: ProposalStatus::Draft,
|
||||
vote_start_date: None,
|
||||
vote_end_date: None,
|
||||
created_at: "2024-01-12".to_string(),
|
||||
yes_votes: 0,
|
||||
no_votes: 0,
|
||||
abstain_votes: 0,
|
||||
total_votes: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn render_overview(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let default_date = "9999-12-31".to_string();
|
||||
let nearest_proposal = self.proposals.iter()
|
||||
.filter(|p| p.status == ProposalStatus::Active)
|
||||
.min_by_key(|p| p.vote_end_date.as_ref().unwrap_or(&default_date));
|
||||
|
||||
html! {
|
||||
<div>
|
||||
// Dashboard Main Content
|
||||
<div class="row mb-3">
|
||||
// Latest Proposal (left)
|
||||
<div class="col-lg-8 mb-4 mb-lg-0">
|
||||
{if let Some(proposal) = nearest_proposal {
|
||||
self.render_latest_proposal(proposal, link)
|
||||
} else {
|
||||
self.render_no_active_proposals()
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Your Vote (right narrow)
|
||||
<div class="col-lg-4">
|
||||
{self.render_your_vote()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Active Proposals Section (below)
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Active Proposals"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{for self.proposals.iter().take(3).map(|proposal| {
|
||||
self.render_proposal_card(proposal)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_latest_proposal(&self, proposal: &Proposal, _link: &html::Scope<Self>) -> Html {
|
||||
html! {
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">{"Latest Proposal"}</h5>
|
||||
<div>
|
||||
<span class="badge bg-warning text-dark me-2">
|
||||
{format!("Ends: {}", proposal.vote_end_date.as_ref().unwrap_or(&"TBD".to_string()))}
|
||||
</span>
|
||||
<a href="#" class="btn btn-sm btn-outline-primary">{"View Full Proposal"}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">{&proposal.title}</h4>
|
||||
<h6 class="card-subtitle mb-3 text-muted">{format!("Proposed by {}", &proposal.creator_name)}</h6>
|
||||
|
||||
<div class="mb-4">
|
||||
<p>{&proposal.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted">{"Proposal Details"}</h6>
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Status:"}</td>
|
||||
<td><span class={proposal.status.get_badge_class()}>{proposal.status.to_string()}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Created:"}</td>
|
||||
<td>{&proposal.created_at}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Voting Start:"}</td>
|
||||
<td>{proposal.vote_start_date.as_ref().unwrap_or(&"Not set".to_string())}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Voting End:"}</td>
|
||||
<td>{proposal.vote_end_date.as_ref().unwrap_or(&"Not set".to_string())}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted">{"Voting Statistics"}</h6>
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Total Votes:"}</td>
|
||||
<td>{proposal.total_votes}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Yes Votes:"}</td>
|
||||
<td class="text-success">{proposal.yes_votes}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"No Votes:"}</td>
|
||||
<td class="text-danger">{proposal.no_votes}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Abstain:"}</td>
|
||||
<td class="text-secondary">{proposal.abstain_votes}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_no_active_proposals(&self) -> Html {
|
||||
html! {
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
|
||||
<h5>{"No active proposals requiring votes"}</h5>
|
||||
<p class="text-muted">{"When new proposals are created, they will appear here for voting."}</p>
|
||||
<a href="#" class="btn btn-primary mt-3">{"Create Proposal"}</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_your_vote(&self) -> Html {
|
||||
// Get the latest active proposal for voting
|
||||
let active_proposal = self.proposals.iter()
|
||||
.find(|p| p.status == ProposalStatus::Active);
|
||||
|
||||
if let Some(proposal) = active_proposal {
|
||||
let proposal_id = proposal.id;
|
||||
let yes_percent = if proposal.total_votes > 0 {
|
||||
(proposal.yes_votes * 100 / proposal.total_votes)
|
||||
} else { 0 };
|
||||
let no_percent = if proposal.total_votes > 0 {
|
||||
(proposal.no_votes * 100 / proposal.total_votes)
|
||||
} else { 0 };
|
||||
let abstain_percent = if proposal.total_votes > 0 {
|
||||
(proposal.abstain_votes * 100 / proposal.total_votes)
|
||||
} else { 0 };
|
||||
|
||||
html! {
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Cast Your Vote"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-2">{"Current Results"}</h6>
|
||||
<div class="progress mb-2" style="height: 20px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style={format!("width: {}%", yes_percent)}>
|
||||
{format!("{}%", yes_percent)}
|
||||
</div>
|
||||
<div class="progress-bar bg-danger" role="progressbar" style={format!("width: {}%", no_percent)}>
|
||||
{format!("{}%", no_percent)}
|
||||
</div>
|
||||
<div class="progress-bar bg-secondary" role="progressbar" style={format!("width: {}%", abstain_percent)}>
|
||||
{format!("{}%", abstain_percent)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span>{format!("{} votes", proposal.total_votes)}</span>
|
||||
<span>{if proposal.total_votes >= 20 { "Quorum reached" } else { "Quorum needed" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-2">{"Your Voting Power"}</h6>
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary mb-1">{"1,250"}</h4>
|
||||
<small class="text-muted">{"tokens"}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Optional comment" />
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm"
|
||||
>
|
||||
{"Vote Yes"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
>
|
||||
{"Vote No"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
{"Abstain"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Your Vote"}</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-ballot fs-1 text-muted"></i>
|
||||
</div>
|
||||
<h6 class="mb-2">{"No Active Proposals"}</h6>
|
||||
<p class="text-muted small mb-3">{"No proposals are currently open for voting"}</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-2">{"Your Voting Power"}</h6>
|
||||
<h4 class="text-primary mb-1">{"1,250"}</h4>
|
||||
<small class="text-muted">{"tokens"}</small>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<a href="#" class="btn btn-outline-primary btn-sm">{"View Vote History"}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_proposal_card(&self, proposal: &Proposal) -> Html {
|
||||
html! {
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{&proposal.title}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">{format!("By {}", &proposal.creator_name)}</h6>
|
||||
<p class="card-text">{format!("{}...", &proposal.description.chars().take(100).collect::<String>())}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class={proposal.status.get_badge_class()}>
|
||||
{proposal.status.to_string()}
|
||||
</span>
|
||||
<a href="#" class="btn btn-sm btn-outline-primary">{"View Details"}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted text-center">
|
||||
<span>{format!("Voting ends: {}", proposal.vote_end_date.as_ref().unwrap_or(&"TBD".to_string()))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_proposals(&self, _ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
// Info Alert
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info alert-dismissible fade show">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<h5><i class="bi bi-info-circle"></i>{" About Proposals"}</h5>
|
||||
<p>{"Proposals are formal requests for changes to the platform that require community approval. Each proposal includes a detailed description, implementation plan, and voting period. Browse the list below to see all active and past proposals."}</p>
|
||||
<div class="mt-2">
|
||||
<a href="#" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-text"></i>{" Proposal Guidelines"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Filter Controls
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="status" class="form-label">{"Status"}</label>
|
||||
<select class="form-select" id="status">
|
||||
<option selected=true>{"All Statuses"}</option>
|
||||
<option value="Draft">{"Draft"}</option>
|
||||
<option value="Active">{"Active"}</option>
|
||||
<option value="Approved">{"Approved"}</option>
|
||||
<option value="Rejected">{"Rejected"}</option>
|
||||
<option value="Cancelled">{"Cancelled"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="search" class="form-label">{"Search"}</label>
|
||||
<input type="text" class="form-control" id="search" placeholder="Search by title or description" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">{"Filter"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Proposals List
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">{"All Proposals"}</h5>
|
||||
<a href="#" class="btn btn-sm btn-primary">{"Create New Proposal"}</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{if !self.proposals.is_empty() {
|
||||
html! {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Title"}</th>
|
||||
<th>{"Creator"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Created"}</th>
|
||||
<th>{"Voting Period"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for self.proposals.iter().map(|proposal| {
|
||||
html! {
|
||||
<tr>
|
||||
<td>{&proposal.title}</td>
|
||||
<td>{&proposal.creator_name}</td>
|
||||
<td>
|
||||
<span class={proposal.status.get_badge_class()}>
|
||||
{proposal.status.to_string()}
|
||||
</span>
|
||||
</td>
|
||||
<td>{&proposal.created_at}</td>
|
||||
<td>
|
||||
{if let (Some(start), Some(end)) = (&proposal.vote_start_date, &proposal.vote_end_date) {
|
||||
format!("{} to {}", start, end)
|
||||
} else {
|
||||
"Not set".to_string()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
<a href="#" class="btn btn-sm btn-primary">{"View"}</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="alert alert-info text-center py-5">
|
||||
<i class="bi bi-info-circle fs-1 mb-3"></i>
|
||||
<h5>{"No proposals found"}</h5>
|
||||
<p>{"There are no proposals in the system yet."}</p>
|
||||
<a href="#" class="btn btn-primary mt-3">{"Create New Proposal"}</a>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_create_proposal(&self, _ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<div>
|
||||
// Info Alert
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info alert-dismissible fade show">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<h5><i class="bi bi-info-circle"></i>{" About Creating Proposals"}</h5>
|
||||
<p>{"Creating a proposal is an important step in our community governance process. Well-crafted proposals clearly state the problem, solution, and implementation details. The community will review and vote on your proposal, so be thorough and thoughtful in your submission."}</p>
|
||||
<div class="mt-2">
|
||||
<a href="#" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-earmark-text"></i>{" Proposal Templates"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Proposal Form and Guidelines
|
||||
<div class="row mb-4">
|
||||
// Proposal Form Column
|
||||
<div class="col-lg-8">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"New Proposal"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">{"Title"}</label>
|
||||
<input type="text" class="form-control" id="title" placeholder="Enter a clear, concise title for your proposal" />
|
||||
<div class="form-text">{"Make it descriptive and specific"}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">{"Description"}</label>
|
||||
<textarea class="form-control" id="description" rows="8" placeholder="Provide a detailed description of your proposal..."></textarea>
|
||||
<div class="form-text">{"Explain the purpose, benefits, and implementation details"}</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="voting_start_date" class="form-label">{"Voting Start Date"}</label>
|
||||
<input type="date" class="form-control" id="voting_start_date" />
|
||||
<div class="form-text">{"When should voting begin?"}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="voting_end_date" class="form-label">{"Voting End Date"}</label>
|
||||
<input type="date" class="form-control" id="voting_end_date" />
|
||||
<div class="form-text">{"When should voting end?"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="draft" />
|
||||
<label class="form-check-label" for="draft">
|
||||
{"Save as draft (not ready for voting yet)"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary">{"Submit Proposal"}</button>
|
||||
<a href="#" class="btn btn-outline-secondary">{"Cancel"}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Guidelines Column
|
||||
<div class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Proposal Guidelines"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item bg-transparent">
|
||||
<strong>{"Be specific:"}</strong>{" Clearly state what you're proposing and why."}
|
||||
</li>
|
||||
<li class="list-group-item bg-transparent">
|
||||
<strong>{"Provide context:"}</strong>{" Explain the current situation and why change is needed."}
|
||||
</li>
|
||||
<li class="list-group-item bg-transparent">
|
||||
<strong>{"Consider implementation:"}</strong>{" Outline how your proposal could be implemented."}
|
||||
</li>
|
||||
<li class="list-group-item bg-transparent">
|
||||
<strong>{"Address concerns:"}</strong>{" Anticipate potential objections and address them."}
|
||||
</li>
|
||||
<li class="list-group-item bg-transparent">
|
||||
<strong>{"Be respectful:"}</strong>{" Focus on ideas, not individuals or groups."}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(GovernanceViewWrapper)]
|
||||
pub fn governance_view(props: &GovernanceViewProps) -> Html {
|
||||
html! {
|
||||
<GovernanceView context={props.context.clone()} />
|
||||
}
|
||||
}
|
||||
82
platform/src/views/home_view.rs
Normal file
82
platform/src/views/home_view.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use yew::prelude::*;
|
||||
use crate::components::FeatureCard;
|
||||
use crate::routing::ViewContext;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct HomeViewProps {
|
||||
pub context: ViewContext,
|
||||
}
|
||||
|
||||
#[function_component(HomeView)]
|
||||
pub fn home_view(props: &HomeViewProps) -> Html {
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title text-center mb-4">{"Zanzibar Digital Freezone"}</h1>
|
||||
<p class="card-text text-center lead mb-5">{"Convenience, Safety and Privacy"}</p>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
// Left Column (3 items)
|
||||
<div class="col-md-6">
|
||||
// Card 1: Frictionless Collaboration
|
||||
<FeatureCard
|
||||
title="Frictionless Collaboration"
|
||||
description="Direct communication and transactions between individuals and organizations, making processes efficient and cost-effective."
|
||||
icon="bi-people-fill"
|
||||
color_variant="primary"
|
||||
/>
|
||||
|
||||
// Card 2: Frictionless Banking
|
||||
<FeatureCard
|
||||
title="Frictionless Banking"
|
||||
description="Simplified financial transactions without the complications and fees of traditional banking systems."
|
||||
icon="bi-currency-exchange"
|
||||
color_variant="success"
|
||||
/>
|
||||
|
||||
// Card 3: Tax Efficiency
|
||||
<FeatureCard
|
||||
title="Tax Efficiency"
|
||||
description="Lower taxes making business operations more profitable and competitive in the global market."
|
||||
icon="bi-graph-up-arrow"
|
||||
color_variant="info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
// Right Column (2 items)
|
||||
<div class="col-md-6">
|
||||
// Card 4: Global Ecommerce
|
||||
<FeatureCard
|
||||
title="Global Ecommerce"
|
||||
description="Easily expand your business globally with streamlined operations and tools to reach customers worldwide."
|
||||
icon="bi-globe"
|
||||
color_variant="warning"
|
||||
/>
|
||||
|
||||
// Card 5: Clear Regulations
|
||||
<FeatureCard
|
||||
title="Clear Regulations"
|
||||
description="Clear regulations and efficient dispute resolution mechanisms providing a stable business environment."
|
||||
icon="bi-shield-check"
|
||||
color_variant="danger"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="https://info.ourworld.tf/zdfz"
|
||||
target="_blank"
|
||||
class="btn btn-primary btn-lg"
|
||||
>
|
||||
{"Learn More"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
18
platform/src/views/login_view.rs
Normal file
18
platform/src/views/login_view.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use yew::prelude::*;
|
||||
use crate::components::LoginForm;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LoginViewProps {
|
||||
pub on_login: Callback<(String, String)>, // (email, password)
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(LoginView)]
|
||||
pub fn login_view(props: &LoginViewProps) -> Html {
|
||||
html! {
|
||||
<LoginForm
|
||||
on_submit={props.on_login.clone()}
|
||||
error_message={props.error_message.clone()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
25
platform/src/views/mod.rs
Normal file
25
platform/src/views/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
pub mod home_view;
|
||||
pub mod login_view;
|
||||
pub mod placeholder_view;
|
||||
pub mod administration_view;
|
||||
pub mod person_administration_view;
|
||||
pub mod business_view;
|
||||
pub mod accounting_view;
|
||||
pub mod contracts_view;
|
||||
pub mod governance_view;
|
||||
pub mod treasury_view;
|
||||
pub mod residence_view;
|
||||
pub mod entities_view;
|
||||
pub mod resident_registration_view;
|
||||
|
||||
pub use home_view::*;
|
||||
pub use administration_view::*;
|
||||
pub use person_administration_view::*;
|
||||
pub use business_view::*;
|
||||
pub use accounting_view::*;
|
||||
pub use contracts_view::*;
|
||||
pub use governance_view::*;
|
||||
pub use treasury_view::*;
|
||||
pub use residence_view::*;
|
||||
pub use entities_view::*;
|
||||
pub use resident_registration_view::*;
|
||||
737
platform/src/views/person_administration_view.rs
Normal file
737
platform/src/views/person_administration_view.rs
Normal file
@@ -0,0 +1,737 @@
|
||||
use yew::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use crate::routing::ViewContext;
|
||||
use crate::components::{ViewComponent, EmptyState};
|
||||
use crate::services::mock_billing_api::{MockBillingApi, Plan};
|
||||
use web_sys::MouseEvent;
|
||||
use wasm_bindgen::JsCast;
|
||||
use gloo::timers::callback::Timeout;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PersonAdministrationViewProps {
|
||||
pub context: ViewContext,
|
||||
}
|
||||
|
||||
#[function_component(PersonAdministrationView)]
|
||||
pub fn person_administration_view(props: &PersonAdministrationViewProps) -> Html {
|
||||
// Initialize mock billing API
|
||||
let billing_api = use_state(|| MockBillingApi::new());
|
||||
|
||||
// State for managing UI interactions
|
||||
let show_plan_modal = use_state(|| false);
|
||||
let show_cancel_modal = use_state(|| false);
|
||||
let show_add_payment_modal = use_state(|| false);
|
||||
let downloading_invoice = use_state(|| None::<String>);
|
||||
let selected_plan = use_state(|| None::<String>);
|
||||
let loading_action = use_state(|| None::<String>);
|
||||
|
||||
// Event handlers
|
||||
let on_change_plan = {
|
||||
let show_plan_modal = show_plan_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_plan_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel_subscription = {
|
||||
let show_cancel_modal = show_cancel_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_cancel_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let on_confirm_cancel_subscription = {
|
||||
let billing_api = billing_api.clone();
|
||||
let show_cancel_modal = show_cancel_modal.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
loading_action.set(Some("canceling".to_string()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let show_cancel_modal_clone = show_cancel_modal.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
api.current_subscription.status = "cancelled".to_string();
|
||||
billing_api_clone.set(api);
|
||||
loading_action_clone.set(None);
|
||||
show_cancel_modal_clone.set(false);
|
||||
web_sys::console::log_1(&"Subscription canceled successfully".into());
|
||||
}).forget();
|
||||
})
|
||||
};
|
||||
|
||||
let on_download_invoice = {
|
||||
let billing_api = billing_api.clone();
|
||||
let downloading_invoice = downloading_invoice.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(invoice_id) = button.get_attribute("data-invoice-id") {
|
||||
downloading_invoice.set(Some(invoice_id.clone()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let downloading_invoice_clone = downloading_invoice.clone();
|
||||
let invoice_id_clone = invoice_id.clone();
|
||||
|
||||
// Simulate download with timeout
|
||||
Timeout::new(500, move || {
|
||||
let api = (*billing_api_clone).clone();
|
||||
|
||||
// Find the invoice and get its PDF URL
|
||||
if let Some(invoice) = api.invoices.iter().find(|i| i.id == invoice_id_clone) {
|
||||
// Create a link and trigger download
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
if let Ok(anchor) = document.create_element("a") {
|
||||
if let Ok(anchor) = anchor.dyn_into::<web_sys::HtmlElement>() {
|
||||
anchor.set_attribute("href", &invoice.pdf_url).unwrap();
|
||||
anchor.set_attribute("download", &format!("invoice_{}.pdf", invoice_id_clone)).unwrap();
|
||||
anchor.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
web_sys::console::log_1(&"Invoice downloaded successfully".into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Invoice not found".into());
|
||||
}
|
||||
downloading_invoice_clone.set(None);
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_add_payment_method = {
|
||||
let show_add_payment_modal = show_add_payment_modal.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_add_payment_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let on_confirm_add_payment_method = {
|
||||
let billing_api = billing_api.clone();
|
||||
let show_add_payment_modal = show_add_payment_modal.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
loading_action.set(Some("adding_payment".to_string()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let show_add_payment_modal_clone = show_add_payment_modal.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
|
||||
// Add a new payment method
|
||||
let new_method = crate::services::mock_billing_api::PaymentMethod {
|
||||
id: format!("card_{}", api.payment_methods.len() + 1),
|
||||
method_type: "Credit Card".to_string(),
|
||||
last_four: "•••• •••• •••• 4242".to_string(),
|
||||
expires: Some("12/28".to_string()),
|
||||
is_primary: false,
|
||||
};
|
||||
|
||||
api.payment_methods.push(new_method);
|
||||
billing_api_clone.set(api);
|
||||
loading_action_clone.set(None);
|
||||
show_add_payment_modal_clone.set(false);
|
||||
web_sys::console::log_1(&"Payment method added successfully".into());
|
||||
}).forget();
|
||||
})
|
||||
};
|
||||
|
||||
let on_edit_payment_method = {
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(method_id) = button.get_attribute("data-method") {
|
||||
let loading_action_clone = loading_action.clone();
|
||||
let method_id_clone = method_id.clone();
|
||||
|
||||
loading_action.set(Some(format!("editing_{}", method_id)));
|
||||
|
||||
// Simulate API call delay
|
||||
Timeout::new(1000, move || {
|
||||
loading_action_clone.set(None);
|
||||
web_sys::console::log_1(&format!("Edit payment method: {}", method_id_clone).into());
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_remove_payment_method = {
|
||||
let billing_api = billing_api.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(method_id) = button.get_attribute("data-method") {
|
||||
if web_sys::window()
|
||||
.unwrap()
|
||||
.confirm_with_message(&format!("Are you sure you want to remove this payment method?"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
let method_id_clone = method_id.clone();
|
||||
|
||||
loading_action.set(Some(format!("removing_{}", method_id)));
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
|
||||
// Remove the payment method
|
||||
if let Some(pos) = api.payment_methods.iter().position(|m| m.id == method_id_clone) {
|
||||
api.payment_methods.remove(pos);
|
||||
billing_api_clone.set(api);
|
||||
web_sys::console::log_1(&"Payment method removed successfully".into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Payment method not found".into());
|
||||
}
|
||||
|
||||
loading_action_clone.set(None);
|
||||
}).forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_select_plan = {
|
||||
let selected_plan = selected_plan.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(button) = target.dyn_into::<web_sys::HtmlElement>() {
|
||||
if let Some(plan_id) = button.get_attribute("data-plan-id") {
|
||||
selected_plan.set(Some(plan_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_confirm_plan_change = {
|
||||
let billing_api = billing_api.clone();
|
||||
let selected_plan = selected_plan.clone();
|
||||
let show_plan_modal = show_plan_modal.clone();
|
||||
let loading_action = loading_action.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(plan_id) = (*selected_plan).clone() {
|
||||
loading_action.set(Some("changing_plan".to_string()));
|
||||
|
||||
let billing_api_clone = billing_api.clone();
|
||||
let show_plan_modal_clone = show_plan_modal.clone();
|
||||
let loading_action_clone = loading_action.clone();
|
||||
let plan_id_clone = plan_id.clone();
|
||||
|
||||
// Simulate async operation with timeout
|
||||
Timeout::new(1000, move || {
|
||||
let mut api = (*billing_api_clone).clone();
|
||||
|
||||
// Change the plan
|
||||
if let Some(plan) = api.available_plans.iter().find(|p| p.id == plan_id_clone) {
|
||||
api.current_subscription.plan = plan.clone();
|
||||
billing_api_clone.set(api);
|
||||
web_sys::console::log_1(&"Plan changed successfully".into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Plan not found".into());
|
||||
}
|
||||
|
||||
loading_action_clone.set(None);
|
||||
show_plan_modal_clone.set(false);
|
||||
}).forget();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let close_modals = {
|
||||
let show_plan_modal = show_plan_modal.clone();
|
||||
let show_cancel_modal = show_cancel_modal.clone();
|
||||
let show_add_payment_modal = show_add_payment_modal.clone();
|
||||
let selected_plan = selected_plan.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_plan_modal.set(false);
|
||||
show_cancel_modal.set(false);
|
||||
show_add_payment_modal.set(false);
|
||||
selected_plan.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
// Create tabs content - Person-specific tabs
|
||||
let mut tabs = HashMap::new();
|
||||
|
||||
// Account Settings Tab (Person-specific)
|
||||
tabs.insert("Account Settings".to_string(), html! {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-person-gear me-2"></i>
|
||||
{"Personal Account Settings"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"Full Name"}</label>
|
||||
<input type="text" class="form-control" value="John Doe" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"Email Address"}</label>
|
||||
<input type="email" class="form-control" value="john.doe@example.com" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"Phone Number"}</label>
|
||||
<input type="tel" class="form-control" value="+1 (555) 123-4567" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"Preferred Language"}</label>
|
||||
<select class="form-select">
|
||||
<option selected=true>{"English"}</option>
|
||||
<option>{"French"}</option>
|
||||
<option>{"Spanish"}</option>
|
||||
<option>{"German"}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<label class="form-label">{"Time Zone"}</label>
|
||||
<select class="form-select">
|
||||
<option selected=true>{"UTC+00:00 (GMT)"}</option>
|
||||
<option>{"UTC-05:00 (EST)"}</option>
|
||||
<option>{"UTC-08:00 (PST)"}</option>
|
||||
<option>{"UTC+01:00 (CET)"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-primary me-2">{"Save Changes"}</button>
|
||||
<button class="btn btn-outline-secondary">{"Reset"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
});
|
||||
|
||||
// Privacy & Security Tab
|
||||
tabs.insert("Privacy & Security".to_string(), html! {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
{"Privacy & Security Settings"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<h6>{"Two-Factor Authentication"}</h6>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="twoFactorAuth" checked=true />
|
||||
<label class="form-check-label" for="twoFactorAuth">
|
||||
{"Enable two-factor authentication"}
|
||||
</label>
|
||||
</div>
|
||||
<small class="text-muted">{"Adds an extra layer of security to your account"}</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h6>{"Login Notifications"}</h6>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="loginNotifications" checked=true />
|
||||
<label class="form-check-label" for="loginNotifications">
|
||||
{"Email me when someone logs into my account"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h6>{"Data Privacy"}</h6>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="dataSharing" />
|
||||
<label class="form-check-label" for="dataSharing">
|
||||
{"Allow anonymous usage analytics"}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="marketingEmails" />
|
||||
<label class="form-check-label" for="marketingEmails">
|
||||
{"Receive marketing communications"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-primary me-2">{"Update Security Settings"}</button>
|
||||
<button class="btn btn-outline-danger">{"Download My Data"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
});
|
||||
|
||||
// Billing and Payments Tab (same as business but person-focused)
|
||||
tabs.insert("Billing and Payments".to_string(), {
|
||||
let current_subscription = &billing_api.current_subscription;
|
||||
let current_plan = ¤t_subscription.plan;
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
// Subscription Tier Pane
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-star me-2"></i>
|
||||
{"Current Plan"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-3">
|
||||
<div class="badge bg-primary fs-6 px-3 py-2 mb-2">{¤t_plan.name}</div>
|
||||
<h3 class="text-primary mb-0">{format!("${:.0}", current_plan.price)}<small class="text-muted">{"/month"}</small></h3>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
{for current_plan.features.iter().map(|feature| html! {
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{feature}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">{format!("Status: {}", current_subscription.status)}</small>
|
||||
</div>
|
||||
<div class="mt-3 d-grid gap-2">
|
||||
<button
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
onclick={on_change_plan.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "changing_plan")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "changing_plan") {
|
||||
"Changing..."
|
||||
} else {
|
||||
"Change Plan"
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
onclick={on_cancel_subscription.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "canceling")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "canceling") {
|
||||
"Canceling..."
|
||||
} else {
|
||||
"Cancel Subscription"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
// Payments Table Pane
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-receipt me-2"></i>
|
||||
{"Payment History"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Date"}</th>
|
||||
<th>{"Description"}</th>
|
||||
<th>{"Amount"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Invoice"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for billing_api.invoices.iter().map(|invoice| html! {
|
||||
<tr>
|
||||
<td>{&invoice.date}</td>
|
||||
<td>{&invoice.description}</td>
|
||||
<td>{format!("${:.2}", invoice.amount)}</td>
|
||||
<td><span class="badge bg-success">{&invoice.status}</span></td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
onclick={on_download_invoice.clone()}
|
||||
data-invoice-id={invoice.id.clone()}
|
||||
disabled={downloading_invoice.as_ref().map_or(false, |id| id == &invoice.id)}
|
||||
>
|
||||
<i class={if downloading_invoice.as_ref().map_or(false, |id| id == &invoice.id) { "bi bi-arrow-repeat" } else { "bi bi-download" }}></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Payment Methods Pane
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-credit-card me-2"></i>
|
||||
{"Payment Methods"}
|
||||
</h5>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={on_add_payment_method.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "adding_payment")}
|
||||
>
|
||||
<i class="bi bi-plus me-1"></i>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "adding_payment") {
|
||||
"Adding..."
|
||||
} else {
|
||||
"Add Method"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{for billing_api.payment_methods.iter().map(|method| html! {
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card border">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class={format!("bg-{} rounded me-3 d-flex align-items-center justify-content-center",
|
||||
if method.method_type == "card" { "primary" } else { "info" })}
|
||||
style="width: 40px; height: 25px;">
|
||||
<i class={format!("bi bi-{} text-white",
|
||||
if method.method_type == "card" { "credit-card" } else { "bank" })}></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{&method.last_four}</div>
|
||||
<small class="text-muted">{&method.expires}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class={format!("badge bg-{}",
|
||||
if method.is_primary { "success" } else { "secondary" })}>
|
||||
{if method.is_primary { "Primary" } else { "Backup" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm me-2"
|
||||
onclick={on_edit_payment_method.clone()}
|
||||
data-method={method.id.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == &format!("editing_{}", method.id))}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == &format!("editing_{}", method.id)) {
|
||||
"Editing..."
|
||||
} else {
|
||||
"Edit"
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
onclick={on_remove_payment_method.clone()}
|
||||
data-method={method.id.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == &format!("removing_{}", method.id))}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == &format!("removing_{}", method.id)) {
|
||||
"Removing..."
|
||||
} else {
|
||||
"Remove"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
});
|
||||
|
||||
// Integrations Tab
|
||||
tabs.insert("Integrations".to_string(), html! {
|
||||
<EmptyState
|
||||
icon={"diagram-3".to_string()}
|
||||
title={"No integrations configured".to_string()}
|
||||
description={"Connect with external services and configure API integrations for your personal account.".to_string()}
|
||||
primary_action={Some(("Browse Integrations".to_string(), "#".to_string()))}
|
||||
secondary_action={Some(("API Documentation".to_string(), "#".to_string()))}
|
||||
/>
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<ViewComponent
|
||||
title={Some("Administration".to_string())}
|
||||
description={Some("Account settings, billing, integrations".to_string())}
|
||||
tabs={Some(tabs)}
|
||||
default_tab={Some("Account Settings".to_string())}
|
||||
/>
|
||||
|
||||
// Plan Selection Modal
|
||||
if *show_plan_modal {
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Change Plan"}</h5>
|
||||
<button type="button" class="btn-close" onclick={close_modals.clone()}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
{for billing_api.available_plans.iter().map(|plan| html! {
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class={format!("card h-100 {}",
|
||||
if selected_plan.as_ref().map_or(false, |id| id == &plan.id) { "border-primary" } else { "" })}>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">{&plan.name}</h5>
|
||||
<h3 class="text-primary">{format!("${:.0}", plan.price)}<small class="text-muted">{"/month"}</small></h3>
|
||||
<ul class="list-unstyled mt-3">
|
||||
{for plan.features.iter().map(|feature| html! {
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-check text-success me-1"></i>
|
||||
{feature}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
class={format!("btn btn-{} w-100",
|
||||
if selected_plan.as_ref().map_or(false, |id| id == &plan.id) { "primary" } else { "outline-primary" })}
|
||||
onclick={on_select_plan.clone()}
|
||||
data-plan-id={plan.id.clone()}
|
||||
>
|
||||
{if selected_plan.as_ref().map_or(false, |id| id == &plan.id) { "Selected" } else { "Select" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={close_modals.clone()}>{"Cancel"}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={on_confirm_plan_change.clone()}
|
||||
disabled={selected_plan.is_none() || loading_action.as_ref().map_or(false, |action| action == "changing_plan")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "changing_plan") {
|
||||
"Changing..."
|
||||
} else {
|
||||
"Change Plan"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Cancel Subscription Modal
|
||||
if *show_cancel_modal {
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Cancel Subscription"}</h5>
|
||||
<button type="button" class="btn-close" onclick={close_modals.clone()}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{"Are you sure you want to cancel your subscription? This action cannot be undone."}</p>
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{"Your subscription will remain active until the end of the current billing period."}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={close_modals.clone()}>{"Keep Subscription"}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
onclick={on_confirm_cancel_subscription.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "canceling")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "canceling") {
|
||||
"Canceling..."
|
||||
} else {
|
||||
"Cancel Subscription"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Add Payment Method Modal
|
||||
if *show_add_payment_modal {
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{"Add Payment Method"}</h5>
|
||||
<button type="button" class="btn-close" onclick={close_modals.clone()}></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"Card Number"}</label>
|
||||
<input type="text" class="form-control" placeholder="1234 5678 9012 3456" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"Expiry Date"}</label>
|
||||
<input type="text" class="form-control" placeholder="MM/YY" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{"CVC"}</label>
|
||||
<input type="text" class="form-control" placeholder="123" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"Cardholder Name"}</label>
|
||||
<input type="text" class="form-control" placeholder="John Doe" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={close_modals.clone()}>{"Cancel"}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={on_confirm_add_payment_method.clone()}
|
||||
disabled={loading_action.as_ref().map_or(false, |action| action == "adding_payment")}
|
||||
>
|
||||
{if loading_action.as_ref().map_or(false, |action| action == "adding_payment") {
|
||||
"Adding..."
|
||||
} else {
|
||||
"Add Payment Method"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
}
|
||||
37
platform/src/views/placeholder_view.rs
Normal file
37
platform/src/views/placeholder_view.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use yew::prelude::*;
|
||||
use crate::routing::AppView;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PlaceholderViewProps {
|
||||
pub view: AppView,
|
||||
}
|
||||
|
||||
#[function_component(PlaceholderView)]
|
||||
pub fn placeholder_view(props: &PlaceholderViewProps) -> Html {
|
||||
let view_title = props.view.get_title(&crate::routing::ViewContext::Business);
|
||||
let view_icon = props.view.get_icon();
|
||||
|
||||
html! {
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class={classes!("bi", view_icon, "display-1", "text-muted", "mb-4")}></i>
|
||||
<h1 class="card-title mb-4">{view_title.clone()}</h1>
|
||||
<p class="card-text text-muted lead mb-4">
|
||||
{format!("Welcome to the {} section of Zanzibar Digital Freezone.", view_title)}
|
||||
</p>
|
||||
<p class="card-text text-muted">
|
||||
{"This section is currently under development. "}
|
||||
{"The UI layout and navigation are complete, and business logic will be implemented next."}
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<span class="badge bg-primary me-2">{"UI Complete"}</span>
|
||||
<span class="badge bg-warning">{"Business Logic Pending"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
146
platform/src/views/residence_view.rs
Normal file
146
platform/src/views/residence_view.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use yew::prelude::*;
|
||||
use crate::routing::ViewContext;
|
||||
use crate::components::ViewComponent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ResidenceViewProps {
|
||||
pub context: ViewContext,
|
||||
}
|
||||
|
||||
#[function_component(ResidenceView)]
|
||||
pub fn residence_view(props: &ResidenceViewProps) -> Html {
|
||||
html! {
|
||||
<ViewComponent
|
||||
title={Some("Personal Residence".to_string())}
|
||||
description={Some("Manage your residence status and documentation".to_string())}
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-house-heart me-2"></i>
|
||||
{"Residence Information"}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted">{"Personal Details"}</h6>
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Full Name:"}</td>
|
||||
<td>{"John Doe"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Residence ID:"}</td>
|
||||
<td><code>{"RES-ZNZ-2024-042"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Residency Since:"}</td>
|
||||
<td>{"March 10, 2024"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Nationality:"}</td>
|
||||
<td>{"Digital Nomad"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Status:"}</td>
|
||||
<td><span class="badge bg-success">{"Active Resident"}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted">{"Residence Details"}</h6>
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Property:"}</td>
|
||||
<td>{"Villa 42, Stone Town"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Address:"}</td>
|
||||
<td>{"Malindi Road, Stone Town, Zanzibar"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Postal Code:"}</td>
|
||||
<td>{"ZNZ-1001"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Phone:"}</td>
|
||||
<td>{"+255 77 123 4567"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold">{"Email:"}</td>
|
||||
<td>{"john.doe@resident.zdf"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
// Residence Card (Vertical Mobile Wallet Style)
|
||||
<div class="card shadow-lg border-0" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); aspect-ratio: 3/4; max-width: 300px;">
|
||||
<div class="card-body text-white p-4 d-flex flex-column h-100">
|
||||
<div class="text-center mb-3">
|
||||
<div class="bg-white bg-opacity-20 rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
|
||||
<i class="bi bi-house-heart fs-3 text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<h6 class="text-white-50 mb-1 text-uppercase" style="font-size: 0.7rem; letter-spacing: 1px;">{"Zanzibar Digital Freezone"}</h6>
|
||||
<h5 class="fw-bold mb-0">{"Residence Permit"}</h5>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="mb-3">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Resident Name"}</small>
|
||||
<div class="fw-bold">{"John Doe"}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Residence Number"}</small>
|
||||
<div class="font-monospace fw-bold fs-5">{"RES-ZNZ-2024-042"}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Residency Date"}</small>
|
||||
<div>{"March 10, 2024"}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Property"}</small>
|
||||
<div>{"Villa 42, Stone Town"}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-white-50 text-uppercase" style="font-size: 0.65rem;">{"Status"}</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge text-dark me-2">{"Active"}</span>
|
||||
<i class="bi bi-shield-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-3 border-top border-white border-opacity-25">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-white-50">{"Valid Until"}</small>
|
||||
<small class="fw-bold">{"Mar 10, 2025"}</small>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<small class="text-white-50">{"Digitally Verified"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ViewComponent>
|
||||
}
|
||||
}
|
||||
43
platform/src/views/resident_registration_view.rs
Normal file
43
platform/src/views/resident_registration_view.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use yew::prelude::*;
|
||||
use crate::components::entities::resident_registration::SimpleResidentWizard;
|
||||
use crate::models::company::DigitalResident;
|
||||
use crate::routing::AppView;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ResidentRegistrationViewProps {
|
||||
pub on_registration_complete: Callback<DigitalResident>,
|
||||
pub on_navigate: Callback<AppView>,
|
||||
#[prop_or_default]
|
||||
pub success_resident_id: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub show_failure: bool,
|
||||
}
|
||||
|
||||
#[function_component(ResidentRegistrationView)]
|
||||
pub fn resident_registration_view(props: &ResidentRegistrationViewProps) -> Html {
|
||||
let on_registration_complete = props.on_registration_complete.clone();
|
||||
let on_navigate = props.on_navigate.clone();
|
||||
|
||||
let on_back_to_parent = {
|
||||
let on_navigate = on_navigate.clone();
|
||||
Callback::from(move |_| {
|
||||
// Navigate to home or a registrations view
|
||||
on_navigate.emit(AppView::Home);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="container-fluid h-100">
|
||||
<div class="row h-100">
|
||||
<div class="col-12">
|
||||
<SimpleResidentWizard
|
||||
on_registration_complete={on_registration_complete}
|
||||
on_back_to_parent={on_back_to_parent}
|
||||
success_resident_id={props.success_resident_id}
|
||||
show_failure={props.show_failure}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
1102
platform/src/views/treasury_view.rs
Normal file
1102
platform/src/views/treasury_view.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user