feat: Enhance governance dashboard with activity tracking

- Add governance activity tracker to record user actions.
- Display recent activities on the governance dashboard.
- Add a dedicated page to view all governance activities.
- Improve header information and styling across governance pages.
- Track proposal creation and voting activities.
This commit is contained in:
Mahmoud-Emad 2025-05-25 16:02:34 +03:00
parent d12a082ca1
commit 70ca9f1605
14 changed files with 1050 additions and 458 deletions

View File

@ -1,3 +1,4 @@
use crate::db::governance_tracker;
use crate::db::proposals::{self, get_proposal_by_id};
use crate::models::governance::{Vote, VoteType, VotingResults};
use crate::utils::render_template;
@ -81,6 +82,15 @@ impl GovernanceController {
pub async fn index(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new();
ctx.insert("active_page", "governance");
ctx.insert("active_tab", "dashboard");
// Header data
ctx.insert("page_title", "Governance Dashboard");
ctx.insert(
"page_description",
"Participate in community decision-making",
);
ctx.insert("show_create_button", &false);
// Add user to context (will always be available with our mock user)
let user = Self::get_user_from_session(&session).unwrap();
@ -124,9 +134,14 @@ impl GovernanceController {
let stats = Self::calculate_statistics_from_database(&proposals_for_stats);
ctx.insert("stats", &stats);
// For now, we'll use empty recent activity
// In a real application, this would be populated from a database
let recent_activity = Vec::<serde_json::Value>::new();
// Get recent governance activities from our tracker
let recent_activity = match Self::get_recent_governance_activities() {
Ok(activities) => activities,
Err(e) => {
eprintln!("Failed to load recent activities: {}", e);
Vec::new()
}
};
ctx.insert("recent_activity", &recent_activity);
render_template(&tmpl, "governance/index.html", &ctx)
@ -142,6 +157,14 @@ impl GovernanceController {
ctx.insert("active_page", "governance");
ctx.insert("active_tab", "proposals");
// Header data
ctx.insert("page_title", "All Proposals");
ctx.insert(
"page_description",
"Browse and filter all governance proposals",
);
ctx.insert("show_create_button", &false);
// Add user to context if available
if let Some(user) = Self::get_user_from_session(&session) {
ctx.insert("user", &user);
@ -206,6 +229,15 @@ impl GovernanceController {
let proposal_id = path.into_inner();
let mut ctx = tera::Context::new();
ctx.insert("active_page", "governance");
ctx.insert("active_tab", "proposals");
// Header data
ctx.insert("page_title", "Proposal Details");
ctx.insert(
"page_description",
"View proposal information and cast your vote",
);
ctx.insert("show_create_button", &false);
// Add user to context if available
if let Some(user) = Self::get_user_from_session(&session) {
@ -259,6 +291,14 @@ impl GovernanceController {
ctx.insert("active_page", "governance");
ctx.insert("active_tab", "create");
// Header data
ctx.insert("page_title", "Create Proposal");
ctx.insert(
"page_description",
"Submit a new proposal for community voting",
);
ctx.insert("show_create_button", &false);
// Add user to context (will always be available with our mock user)
let user = Self::get_user_from_session(&session).unwrap();
ctx.insert("user", &user);
@ -330,6 +370,18 @@ impl GovernanceController {
"Proposal saved to DB: ID={}, title={:?}",
proposal_id, saved_proposal.title
);
// Track the proposal creation activity
let creation_activity =
crate::models::governance::GovernanceActivity::proposal_created(
proposal_id,
&saved_proposal.title,
&user_id,
&user_name,
);
let _ = governance_tracker::create_activity(creation_activity);
ctx.insert("success", "Proposal created successfully!");
}
Err(err) => {
@ -401,6 +453,25 @@ impl GovernanceController {
form.comment.as_ref().map(|s| s.to_string()), // Pass the comment from the form
) {
Ok(_) => {
// Record the vote activity
let user_name = user
.get("username")
.and_then(|v| v.as_str())
.unwrap_or("Unknown User");
// Track the vote cast activity
if let Ok(Some(proposal)) = get_proposal_by_id(proposal_id_u32) {
let vote_activity = crate::models::governance::GovernanceActivity::vote_cast(
proposal_id_u32,
&proposal.title,
user_name,
&form.vote_type,
1, // shares
);
let _ = governance_tracker::create_activity(vote_activity);
}
// Redirect to the proposal detail page with a success message
return Ok(HttpResponse::Found()
.append_header((
@ -422,6 +493,14 @@ impl GovernanceController {
ctx.insert("active_page", "governance");
ctx.insert("active_tab", "my_votes");
// Header data
ctx.insert("page_title", "My Votes");
ctx.insert(
"page_description",
"View your voting history and participation",
);
ctx.insert("show_create_button", &false);
// Add user to context (will always be available with our mock user)
let user = Self::get_user_from_session(&session).unwrap();
ctx.insert("user", &user);
@ -463,7 +542,106 @@ impl GovernanceController {
render_template(&tmpl, "governance/my_votes.html", &ctx)
}
// No more mock recent activity - we're using an empty vector in the index function
/// Handles the all activities page route
pub async fn all_activities(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new();
ctx.insert("active_page", "governance");
ctx.insert("active_tab", "activities");
// Header data
ctx.insert("page_title", "All Governance Activities");
ctx.insert(
"page_description",
"Complete history of governance actions and events",
);
ctx.insert("show_create_button", &false);
// Add user to context (will always be available with our mock user)
let user = Self::get_user_from_session(&session).unwrap();
ctx.insert("user", &user);
// Get all governance activities from the database
let activities = match Self::get_all_governance_activities() {
Ok(activities) => activities,
Err(e) => {
eprintln!("Failed to load all activities: {}", e);
Vec::new()
}
};
ctx.insert("activities", &activities);
render_template(&tmpl, "governance/all_activities.html", &ctx)
}
/// Get recent governance activities from the database
fn get_recent_governance_activities() -> Result<Vec<Value>, String> {
// Get real activities from the database (no demo data)
let activities = governance_tracker::get_recent_activities()?;
// Convert GovernanceActivity to the format expected by the template
let formatted_activities: Vec<Value> = activities
.into_iter()
.map(|activity| {
// Map activity type to appropriate icon
let (icon, action) = match activity.activity_type.as_str() {
"proposal_created" => ("bi-plus-circle-fill text-success", "created proposal"),
"vote_cast" => ("bi-check-circle-fill text-primary", "cast vote"),
"voting_started" => ("bi-play-circle-fill text-info", "started voting"),
"voting_ended" => ("bi-clock-fill text-warning", "ended voting"),
"proposal_status_changed" => ("bi-shield-check text-success", "changed status"),
"vote_option_added" => ("bi-list-ul text-secondary", "added vote option"),
_ => ("bi-circle-fill text-muted", "performed action"),
};
serde_json::json!({
"type": activity.activity_type,
"icon": icon,
"user": activity.actor_name,
"action": action,
"proposal_title": activity.proposal_title,
"timestamp": activity.timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"proposal_id": activity.proposal_id
})
})
.collect();
Ok(formatted_activities)
}
/// Get all governance activities from the database
fn get_all_governance_activities() -> Result<Vec<Value>, String> {
// Get all activities from the database
let activities = governance_tracker::get_all_activities()?;
// Convert GovernanceActivity to the format expected by the template
let formatted_activities: Vec<Value> = activities
.into_iter()
.map(|activity| {
// Map activity type to appropriate icon
let (icon, action) = match activity.activity_type.as_str() {
"proposal_created" => ("bi-plus-circle-fill text-success", "created proposal"),
"vote_cast" => ("bi-check-circle-fill text-primary", "cast vote"),
"voting_started" => ("bi-play-circle-fill text-info", "started voting"),
"voting_ended" => ("bi-clock-fill text-warning", "ended voting"),
"proposal_status_changed" => ("bi-shield-check text-success", "changed status"),
"vote_option_added" => ("bi-list-ul text-secondary", "added vote option"),
_ => ("bi-circle-fill text-muted", "performed action"),
};
serde_json::json!({
"type": activity.activity_type,
"icon": icon,
"user": activity.actor_name,
"action": action,
"proposal_title": activity.proposal_title,
"timestamp": activity.timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"proposal_id": activity.proposal_id
})
})
.collect();
Ok(formatted_activities)
}
/// Generate mock votes for a specific proposal
fn get_mock_votes_for_proposal(proposal_id: &str) -> Vec<Vote> {

View File

@ -0,0 +1,139 @@
use crate::models::governance::GovernanceActivity;
use std::path::PathBuf;
/// Database path for governance activities
pub const DB_PATH: &str = "/tmp/ourdb_governance_activities";
/// Returns a shared OurDB instance for activities
pub fn get_db() -> Result<heromodels::db::hero::OurDB, String> {
let db_path = PathBuf::from(DB_PATH);
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let db = heromodels::db::hero::OurDB::new(db_path, true)
.map_err(|e| format!("Failed to create activities DB: {:?}", e))?;
Ok(db)
}
/// Creates a new governance activity and saves it to the database using OurDB
pub fn create_activity(activity: GovernanceActivity) -> Result<(u32, GovernanceActivity), String> {
let db = get_db()?;
// Since OurDB doesn't support custom models directly, we'll use a simple key-value approach
// Store each activity with a unique key and serialize it as JSON
// First, get the next available ID by checking existing keys
let activity_id = get_next_activity_id(&db)?;
// Create the activity with the assigned ID
let mut new_activity = activity;
new_activity.id = Some(activity_id);
// Serialize the activity to JSON
let activity_json = serde_json::to_string(&new_activity)
.map_err(|e| format!("Failed to serialize activity: {}", e))?;
// Store in OurDB using a key-value approach
let key = format!("activity_{}", activity_id);
// Use OurDB's raw storage capabilities to store the JSON string
// Since we can't use collections directly, we'll store as raw data
let db_path = format!("{}/{}.json", DB_PATH, key);
std::fs::write(&db_path, &activity_json)
.map_err(|e| format!("Failed to write activity to DB: {}", e))?;
// Also maintain an index of activity IDs for efficient retrieval
update_activity_index(&db, activity_id)?;
println!(
"✅ Activity recorded: {} - {}",
new_activity.activity_type, new_activity.description
);
Ok((activity_id, new_activity))
}
/// Gets the next available activity ID
fn get_next_activity_id(_db: &heromodels::db::hero::OurDB) -> Result<u32, String> {
let index_path = format!("{}/activity_index.json", DB_PATH);
if std::path::Path::new(&index_path).exists() {
let content = std::fs::read_to_string(&index_path)
.map_err(|e| format!("Failed to read activity index: {}", e))?;
let index: Vec<u32> = serde_json::from_str(&content).unwrap_or_else(|_| Vec::new());
Ok(index.len() as u32 + 1)
} else {
Ok(1)
}
}
/// Updates the activity index with a new activity ID
fn update_activity_index(
_db: &heromodels::db::hero::OurDB,
activity_id: u32,
) -> Result<(), String> {
let index_path = format!("{}/activity_index.json", DB_PATH);
let mut index: Vec<u32> = if std::path::Path::new(&index_path).exists() {
let content = std::fs::read_to_string(&index_path)
.map_err(|e| format!("Failed to read activity index: {}", e))?;
serde_json::from_str(&content).unwrap_or_else(|_| Vec::new())
} else {
Vec::new()
};
index.push(activity_id);
let content = serde_json::to_string(&index)
.map_err(|e| format!("Failed to serialize activity index: {}", e))?;
std::fs::write(&index_path, content)
.map_err(|e| format!("Failed to write activity index: {}", e))?;
Ok(())
}
/// Gets all activities from the database using OurDB
pub fn get_all_activities() -> Result<Vec<GovernanceActivity>, String> {
let _db = get_db()?;
let index_path = format!("{}/activity_index.json", DB_PATH);
// Read the activity index to get all activity IDs
if !std::path::Path::new(&index_path).exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&index_path)
.map_err(|e| format!("Failed to read activity index: {}", e))?;
let activity_ids: Vec<u32> = serde_json::from_str(&content).unwrap_or_else(|_| Vec::new());
let mut activities = Vec::new();
// Load each activity by ID
for activity_id in activity_ids {
let activity_path = format!("{}/activity_{}.json", DB_PATH, activity_id);
if std::path::Path::new(&activity_path).exists() {
let activity_content = std::fs::read_to_string(&activity_path)
.map_err(|e| format!("Failed to read activity {}: {}", activity_id, e))?;
if let Ok(activity) = serde_json::from_str::<GovernanceActivity>(&activity_content) {
activities.push(activity);
}
}
}
Ok(activities)
}
/// Gets recent activities (last 10) sorted by timestamp using OurDB
pub fn get_recent_activities() -> Result<Vec<GovernanceActivity>, String> {
let mut activities = get_all_activities()?;
// Sort by timestamp (most recent first)
activities.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
// Take only the last 10
activities.truncate(10);
Ok(activities)
}

View File

@ -1 +1,2 @@
pub mod governance_tracker;
pub mod proposals;

View File

@ -1,10 +1,10 @@
use actix_files as fs;
use actix_web::{App, HttpServer, web};
use actix_web::middleware::Logger;
use tera::Tera;
use std::io;
use std::env;
use actix_web::{App, HttpServer, web};
use lazy_static::lazy_static;
use std::env;
use std::io;
use tera::Tera;
mod config;
mod controllers;
@ -15,9 +15,9 @@ mod routes;
mod utils;
// Import middleware components
use middleware::{RequestTimer, SecurityHeaders, JwtAuth};
use utils::redis_service;
use middleware::{JwtAuth, RequestTimer, SecurityHeaders};
use models::initialize_mock_data;
use utils::redis_service;
// Initialize lazy_static for in-memory storage
extern crate lazy_static;
@ -66,7 +66,8 @@ async fn main() -> io::Result<()> {
let bind_address = format!("{}:{}", config.server.host, port);
// Initialize Redis client
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
if let Err(e) = redis_service::init_redis_client(&redis_url) {
log::error!("Failed to initialize Redis client: {}", e);
log::warn!("Calendar functionality will not work properly without Redis");
@ -78,6 +79,9 @@ async fn main() -> io::Result<()> {
initialize_mock_data();
log::info!("DeFi mock data initialized successfully");
// Governance activity tracker is now ready to record real user activities
log::info!("Governance activity tracker initialized and ready");
log::info!("Starting server at http://{}", bind_address);
// Create and configure the HTTP server

View File

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Represents the status of a governance proposal
@ -144,7 +144,13 @@ pub struct Vote {
#[allow(dead_code)]
impl Vote {
/// Creates a new vote
pub fn new(proposal_id: String, voter_id: i32, voter_name: String, vote_type: VoteType, comment: Option<String>) -> Self {
pub fn new(
proposal_id: String,
voter_id: i32,
voter_name: String,
vote_type: VoteType,
comment: Option<String>,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
@ -202,6 +208,105 @@ pub struct VotingResults {
pub total_votes: usize,
}
/// Represents a governance activity in the system
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GovernanceActivity {
/// Unique identifier for the activity
pub id: Option<u32>,
/// Type of activity (proposal_created, vote_cast, etc.)
pub activity_type: String,
/// ID of the related proposal
pub proposal_id: u32,
/// Title of the related proposal
pub proposal_title: String,
/// Name of the user who performed the action
pub actor_name: String,
/// Description of the activity
pub description: String,
/// Date and time when the activity occurred
pub timestamp: DateTime<Utc>,
}
impl GovernanceActivity {
/// Creates a new governance activity
pub fn new(
activity_type: &str,
proposal_id: u32,
proposal_title: &str,
actor_name: &str,
description: &str,
) -> Self {
Self {
id: None,
activity_type: activity_type.to_string(),
proposal_id,
proposal_title: proposal_title.to_string(),
actor_name: actor_name.to_string(),
description: description.to_string(),
timestamp: Utc::now(),
}
}
/// Creates a proposal creation activity
pub fn proposal_created(
proposal_id: u32,
proposal_title: &str,
_creator_id: &str,
creator_name: &str,
) -> Self {
Self::new(
"proposal_created",
proposal_id,
proposal_title,
creator_name,
&format!("Proposal '{}' created by {}", proposal_title, creator_name),
)
}
/// Creates a vote cast activity
pub fn vote_cast(
proposal_id: u32,
proposal_title: &str,
voter_name: &str,
vote_option: &str,
shares: i64,
) -> Self {
Self::new(
"vote_cast",
proposal_id,
proposal_title,
voter_name,
&format!(
"{} voted '{}' with {} shares",
voter_name, vote_option, shares
),
)
}
/// Creates a proposal status change activity
pub fn proposal_status_changed(
proposal_id: u32,
proposal_title: &str,
new_status: &ProposalStatus,
reason: Option<&str>,
) -> Self {
let description = format!(
"Proposal '{}' status changed to {}{}",
proposal_title,
new_status,
reason.map(|r| format!(": {}", r)).unwrap_or_default()
);
Self::new(
"proposal_status_changed",
proposal_id,
proposal_title,
"System",
&description,
)
}
}
#[allow(dead_code)]
impl VotingResults {
/// Creates a new empty voting results object

View File

@ -1,28 +1,26 @@
use actix_web::web;
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use crate::controllers::home::HomeController;
use crate::controllers::auth::AuthController;
use crate::controllers::ticket::TicketController;
use crate::controllers::calendar::CalendarController;
use crate::controllers::governance::GovernanceController;
use crate::controllers::flow::FlowController;
use crate::controllers::contract::ContractController;
use crate::controllers::asset::AssetController;
use crate::controllers::marketplace::MarketplaceController;
use crate::controllers::defi::DefiController;
use crate::controllers::company::CompanyController;
use crate::middleware::JwtAuth;
use crate::SESSION_KEY;
use crate::controllers::asset::AssetController;
use crate::controllers::auth::AuthController;
use crate::controllers::calendar::CalendarController;
use crate::controllers::company::CompanyController;
use crate::controllers::contract::ContractController;
use crate::controllers::defi::DefiController;
use crate::controllers::flow::FlowController;
use crate::controllers::governance::GovernanceController;
use crate::controllers::home::HomeController;
use crate::controllers::marketplace::MarketplaceController;
use crate::controllers::ticket::TicketController;
use crate::middleware::JwtAuth;
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::web;
/// Configures all application routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
// Configure session middleware with the consistent key
let session_middleware = SessionMiddleware::builder(
CookieSessionStore::default(),
SESSION_KEY.clone()
)
.cookie_secure(false) // Set to true in production with HTTPS
.build();
let session_middleware =
SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone())
.cookie_secure(false) // Set to true in production with HTTPS
.build();
// Public routes that don't require authentication
cfg.service(
@ -33,56 +31,98 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/about", web::get().to(HomeController::about))
.route("/contact", web::get().to(HomeController::contact))
.route("/contact", web::post().to(HomeController::submit_contact))
// Auth routes
.route("/login", web::get().to(AuthController::login_page))
.route("/login", web::post().to(AuthController::login))
.route("/register", web::get().to(AuthController::register_page))
.route("/register", web::post().to(AuthController::register))
.route("/logout", web::get().to(AuthController::logout))
// Protected routes that require authentication
// These routes will be protected by the JwtAuth middleware in the main.rs file
.route("/editor", web::get().to(HomeController::editor))
// Ticket routes
.route("/tickets", web::get().to(TicketController::list_tickets))
.route("/tickets/new", web::get().to(TicketController::new_ticket))
.route("/tickets", web::post().to(TicketController::create_ticket))
.route("/tickets/{id}", web::get().to(TicketController::show_ticket))
.route("/tickets/{id}/comment", web::post().to(TicketController::add_comment))
.route("/tickets/{id}/status/{status}", web::post().to(TicketController::update_status))
.route(
"/tickets/{id}",
web::get().to(TicketController::show_ticket),
)
.route(
"/tickets/{id}/comment",
web::post().to(TicketController::add_comment),
)
.route(
"/tickets/{id}/status/{status}",
web::post().to(TicketController::update_status),
)
.route("/my-tickets", web::get().to(TicketController::my_tickets))
// Calendar routes
.route("/calendar", web::get().to(CalendarController::calendar))
.route("/calendar/events/new", web::get().to(CalendarController::new_event))
.route("/calendar/events", web::post().to(CalendarController::create_event))
.route("/calendar/events/{id}/delete", web::post().to(CalendarController::delete_event))
.route(
"/calendar/events/new",
web::get().to(CalendarController::new_event),
)
.route(
"/calendar/events",
web::post().to(CalendarController::create_event),
)
.route(
"/calendar/events/{id}/delete",
web::post().to(CalendarController::delete_event),
)
// Governance routes
.route("/governance", web::get().to(GovernanceController::index))
.route("/governance/proposals", web::get().to(GovernanceController::proposals))
.route("/governance/proposals/{id}", web::get().to(GovernanceController::proposal_detail))
.route("/governance/proposals/{id}/vote", web::post().to(GovernanceController::submit_vote))
.route("/governance/create", web::get().to(GovernanceController::create_proposal_form))
.route("/governance/create", web::post().to(GovernanceController::submit_proposal))
.route("/governance/my-votes", web::get().to(GovernanceController::my_votes))
.route(
"/governance/proposals",
web::get().to(GovernanceController::proposals),
)
.route(
"/governance/proposals/{id}",
web::get().to(GovernanceController::proposal_detail),
)
.route(
"/governance/proposals/{id}/vote",
web::post().to(GovernanceController::submit_vote),
)
.route(
"/governance/create",
web::get().to(GovernanceController::create_proposal_form),
)
.route(
"/governance/create",
web::post().to(GovernanceController::submit_proposal),
)
.route(
"/governance/my-votes",
web::get().to(GovernanceController::my_votes),
)
.route(
"/governance/activities",
web::get().to(GovernanceController::all_activities),
)
// Flow routes
.service(
web::scope("/flows")
.route("", web::get().to(FlowController::index))
.route("/list", web::get().to(FlowController::list_flows))
.route("/{id}", web::get().to(FlowController::flow_detail))
.route("/{id}/advance", web::post().to(FlowController::advance_flow_step))
.route("/{id}/stuck", web::post().to(FlowController::mark_flow_step_stuck))
.route("/{id}/step/{step_id}/log", web::post().to(FlowController::add_log_to_flow_step))
.route(
"/{id}/advance",
web::post().to(FlowController::advance_flow_step),
)
.route(
"/{id}/stuck",
web::post().to(FlowController::mark_flow_step_stuck),
)
.route(
"/{id}/step/{step_id}/log",
web::post().to(FlowController::add_log_to_flow_step),
)
.route("/create", web::get().to(FlowController::create_flow_form))
.route("/create", web::post().to(FlowController::create_flow))
.route("/my-flows", web::get().to(FlowController::my_flows))
.route("/my-flows", web::get().to(FlowController::my_flows)),
)
// Contract routes
.service(
web::scope("/contracts")
@ -91,9 +131,8 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/my", web::get().to(ContractController::my_contracts))
.route("/{id}", web::get().to(ContractController::detail))
.route("/create", web::get().to(ContractController::create_form))
.route("/create", web::post().to(ContractController::create))
.route("/create", web::post().to(ContractController::create)),
)
// Asset routes
.service(
web::scope("/assets")
@ -104,35 +143,72 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/create", web::post().to(AssetController::create))
.route("/test", web::get().to(AssetController::test))
.route("/{id}", web::get().to(AssetController::detail))
.route("/{id}/valuation", web::post().to(AssetController::add_valuation))
.route("/{id}/transaction", web::post().to(AssetController::add_transaction))
.route("/{id}/status/{status}", web::post().to(AssetController::update_status))
.route(
"/{id}/valuation",
web::post().to(AssetController::add_valuation),
)
.route(
"/{id}/transaction",
web::post().to(AssetController::add_transaction),
)
.route(
"/{id}/status/{status}",
web::post().to(AssetController::update_status),
),
)
// Marketplace routes
.service(
web::scope("/marketplace")
.route("", web::get().to(MarketplaceController::index))
.route("/listings", web::get().to(MarketplaceController::list_listings))
.route(
"/listings",
web::get().to(MarketplaceController::list_listings),
)
.route("/my", web::get().to(MarketplaceController::my_listings))
.route("/create", web::get().to(MarketplaceController::create_listing_form))
.route("/create", web::post().to(MarketplaceController::create_listing))
.route("/{id}", web::get().to(MarketplaceController::listing_detail))
.route("/{id}/bid", web::post().to(MarketplaceController::submit_bid))
.route("/{id}/purchase", web::post().to(MarketplaceController::purchase_listing))
.route("/{id}/cancel", web::post().to(MarketplaceController::cancel_listing))
.route(
"/create",
web::get().to(MarketplaceController::create_listing_form),
)
.route(
"/create",
web::post().to(MarketplaceController::create_listing),
)
.route(
"/{id}",
web::get().to(MarketplaceController::listing_detail),
)
.route(
"/{id}/bid",
web::post().to(MarketplaceController::submit_bid),
)
.route(
"/{id}/purchase",
web::post().to(MarketplaceController::purchase_listing),
)
.route(
"/{id}/cancel",
web::post().to(MarketplaceController::cancel_listing),
),
)
// DeFi routes
.service(
web::scope("/defi")
.route("", web::get().to(DefiController::index))
.route("/providing", web::post().to(DefiController::create_providing))
.route("/receiving", web::post().to(DefiController::create_receiving))
.route(
"/providing",
web::post().to(DefiController::create_providing),
)
.route(
"/receiving",
web::post().to(DefiController::create_receiving),
)
.route("/liquidity", web::post().to(DefiController::add_liquidity))
.route("/staking", web::post().to(DefiController::create_staking))
.route("/swap", web::post().to(DefiController::swap_tokens))
.route("/collateral", web::post().to(DefiController::create_collateral))
.route(
"/collateral",
web::post().to(DefiController::create_collateral),
),
)
// Company routes
.service(
@ -140,13 +216,15 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("", web::get().to(CompanyController::index))
.route("/register", web::post().to(CompanyController::register))
.route("/view/{id}", web::get().to(CompanyController::view_company))
.route("/switch/{id}", web::get().to(CompanyController::switch_entity))
)
.route(
"/switch/{id}",
web::get().to(CompanyController::switch_entity),
),
),
);
// Keep the /protected scope for any future routes that should be under that path
cfg.service(
web::scope("/protected")
.wrap(JwtAuth) // Apply JWT authentication middleware
web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
);
}

View File

@ -0,0 +1,18 @@
<!-- Governance Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-1">{{ page_title }}</h1>
<p class="text-muted mb-0">{{ page_description }}</p>
</div>
{% if show_create_button %}
<div>
<a href="/governance/create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Proposal
</a>
</div>
{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<!-- Governance Navigation Tabs -->
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {% if active_tab == 'dashboard' %}active{% endif %}" href="/governance">
<i class="bi bi-house"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'proposals' %}active{% endif %}" href="/governance/proposals">
<i class="bi bi-file-text"></i> All Proposals
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'create' %}active{% endif %}" href="/governance/create">
<i class="bi bi-plus-circle"></i> Create Proposal
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'my-votes' %}active{% endif %}" href="/governance/my-votes">
<i class="bi bi-check-circle"></i> My Votes
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'activities' %}active{% endif %}" href="/governance/activities">
<i class="bi bi-activity"></i> All Activities
</a>
</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}All Governance Activities{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
{% include "governance/_tabs.html" %}
<!-- Activities List -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-activity"></i> Governance Activity History
</h5>
</div>
<div class="card-body">
{% if activities %}
<div class="row">
<div class="col-12">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th width="50">Type</th>
<th>User</th>
<th>Action</th>
<th>Proposal</th>
<th width="150">Date</th>
</tr>
</thead>
<tbody>
{% for activity in activities %}
<tr>
<td>
<i class="{{ activity.icon }}"></i>
</td>
<td>
<strong>{{ activity.user }}</strong>
</td>
<td>
{{ activity.action }}
</td>
<td>
<a href="/governance/proposals/{{ activity.proposal_id }}"
class="text-decoration-none">
{{ activity.proposal_title }}
</a>
</td>
<td>
<small class="text-muted">
{{ activity.timestamp | date(format="%Y-%m-%d %H:%M") }}
</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-activity display-1 text-muted"></i>
<h4 class="mt-3">No Activities Yet</h4>
<p class="text-muted">
Governance activities will appear here as users create proposals and cast votes.
</p>
<a href="/governance/create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First Proposal
</a>
</div>
{% endif %}
</div>
</div>
<!-- Activity Statistics -->
{% if activities %}
<div class="row mt-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ activities | length }}</h5>
<p class="card-text text-muted">Total Activities</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-activity text-primary"></i>
</h5>
<p class="card-text text-muted">Activity Timeline</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-people text-success"></i>
</h5>
<p class="card-text text-muted">Community Engagement</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -4,25 +4,11 @@
{% block content %}
<div class="container-fluid">
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="/governance">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/proposals">All Proposals</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/my-votes">My Votes</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div>
</div>
{% include "governance/_tabs.html" %}
<!-- Info Alert -->
<div class="row">

View File

@ -3,25 +3,11 @@
{% block title %}Governance Dashboard{% endblock %}
{% block content %}
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
<div class="row mb-3">
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" href="/governance">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/proposals">All Proposals</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/my-votes">My Votes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div>
</div>
{% include "governance/_tabs.html" %}
<!-- Info Alert -->
<div class="row mb-2">
@ -159,7 +145,7 @@
</div>
</div>
<div class="card-footer text-center">
<a href="/governance/proposals" class="btn btn-sm btn-outline-info">View All Activity</a>
<a href="/governance/activities" class="btn btn-sm btn-outline-info">View All Activities</a>
</div>
</div>
</div>

View File

@ -3,25 +3,11 @@
{% block title %}My Votes - Governance Dashboard{% endblock %}
{% block content %}
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="/governance">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/proposals">All Proposals</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/governance/my-votes">My Votes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div>
</div>
{% include "governance/_tabs.html" %}
<!-- Info Alert -->
<div class="row">

View File

@ -35,6 +35,12 @@
{% block content %}
<div class="container-fluid">
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
{% include "governance/_tabs.html" %}
<div class="row mb-4">
<div class="col-12">
<nav aria-label="breadcrumb">
@ -194,7 +200,8 @@
{% if proposal.status == "Active" and user and user.id %}
<div class="mt-auto">
<h6 class="border-bottom pb-2 mb-3"><i class="bi bi-check2-square me-2"></i>Cast Your Vote</h6>
<form action="/governance/proposals/{{ proposal.base_data.id }}/vote" method="post">
<form action="/governance/proposals/{{ proposal.base_data.id }}/vote" method="post"
id="voteForm">
<div class="mb-3">
<div class="d-flex gap-2 mb-2">
<div class="form-check">
@ -243,26 +250,8 @@
<div class="row mt-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center flex-wrap">
<h5 class="mb-0 mb-md-0"><i class="bi bi-list-check me-2"></i>Votes</h5>
<div class="d-flex align-items-center">
<div class="input-group input-group-sm me-2 d-none d-md-flex" style="width: 200px;">
<span class="input-group-text bg-white">
<i class="bi bi-search"></i>
</span>
<input type="text" class="form-control border-start-0" id="voteSearch"
placeholder="Search votes...">
</div>
<div class="btn-group" role="group" aria-label="Filter votes">
<button type="button" class="btn btn-sm btn-outline-primary active"
data-filter="all">All</button>
<button type="button" class="btn btn-sm btn-outline-success"
data-filter="yes">Yes</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-filter="no">No</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-filter="abstain">Abstain</button>
</div>
</div>
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-list-check me-2"></i>Votes</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
@ -372,275 +361,255 @@
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Remove query parameters from URL without refreshing the page
if (window.location.search.includes('vote_success=true')) {
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Remove query parameters from URL without refreshing the page
if (window.location.search.includes('vote_success=true')) {
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
// Auto-hide the success alert after 5 seconds
const successAlert = document.querySelector('.alert-success');
if (successAlert) {
// Auto-hide the success alert after 5 seconds
const successAlert = document.querySelector('.alert-success');
if (successAlert) {
setTimeout(function () {
successAlert.classList.remove('show');
setTimeout(function () {
successAlert.classList.remove('show');
setTimeout(function () {
successAlert.remove();
}, 500);
}, 5000);
}
successAlert.remove();
}, 500);
}, 5000);
}
}
// Pagination functionality
const rowsPerPageSelect = document.getElementById('rowsPerPage');
const paginationControls = document.getElementById('paginationControls');
const votesTableBody = document.getElementById('votesTableBody');
const startRowElement = document.getElementById('startRow');
const endRowElement = document.getElementById('endRow');
const totalRowsElement = document.getElementById('totalRows');
const prevPageBtn = document.getElementById('prevPage');
const nextPageBtn = document.getElementById('nextPage');
let currentPage = 1;
let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10;
// Function to update pagination display
function updatePagination() {
if (!paginationControls) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Vote filtering using data-filter attributes
const filterButtons = document.querySelectorAll('[data-filter]');
const voteRows = document.querySelectorAll('.vote-row');
const searchInput = document.getElementById('voteSearch');
// Filter votes by type
filterButtons.forEach(button => {
button.addEventListener('click', function () {
// Update active button
filterButtons.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
// Reset to first page and update pagination
currentPage = 1;
updatePagination();
});
});
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function () {
const searchTerm = this.value.toLowerCase();
voteRows.forEach(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
if (voterName.includes(searchTerm) || comment.includes(searchTerm)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
// Reset pagination after search
currentPage = 1;
updatePagination();
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
// Pagination functionality
const rowsPerPageSelect = document.getElementById('rowsPerPage');
const paginationControls = document.getElementById('paginationControls');
const votesTableBody = document.getElementById('votesTableBody');
const startRowElement = document.getElementById('startRow');
const endRowElement = document.getElementById('endRow');
const totalRowsElement = document.getElementById('totalRows');
const prevPageBtn = document.getElementById('prevPage');
const nextPageBtn = document.getElementById('nextPage');
const totalRows = filteredRows.length;
let currentPage = 1;
let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10;
// Calculate total pages
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Function to update pagination display
function updatePagination() {
if (!paginationControls) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
const totalRows = filteredRows.length;
// Calculate total pages
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Update pagination controls
if (paginationControls) {
// Clear existing page links (except prev/next)
const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)');
pageLinks.forEach(link => link.remove());
// Add new page links
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Adjust if we're near the end
if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
// Insert page links before the next button
const nextPageElement = document.getElementById('nextPage');
for (let i = startPage; i <= endPage; i++) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
const a = document.createElement('a');
a.className = 'page-link';
a.href = '#';
a.textContent = i;
a.addEventListener('click', function (e) {
e.preventDefault();
currentPage = i;
updatePagination();
});
li.appendChild(a);
paginationControls.insertBefore(li, nextPageElement);
}
// Update prev/next buttons
prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
}
// Show current page
showCurrentPage();
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Function to show current page
function showCurrentPage() {
if (!votesTableBody) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
// Hide all rows first
voteRows.forEach(row => row.style.display = 'none');
// Calculate pagination
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Show only rows for current page
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
filteredRows.slice(start, end).forEach(row => row.style.display = '');
// Update pagination info
if (startRowElement && endRowElement && totalRowsElement) {
startRowElement.textContent = totalRows > 0 ? start + 1 : 0;
endRowElement.textContent = Math.min(end, totalRows);
totalRowsElement.textContent = totalRows;
}
}
// Event listeners for pagination
if (prevPageBtn) {
prevPageBtn.addEventListener('click', function (e) {
e.preventDefault();
if (currentPage > 1) {
currentPage--;
updatePagination();
}
});
}
if (nextPageBtn) {
nextPageBtn.addEventListener('click', function (e) {
e.preventDefault();
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
if (currentPage < totalPages) {
currentPage++;
updatePagination();
}
});
}
if (rowsPerPageSelect) {
rowsPerPageSelect.addEventListener('change', function () {
rowsPerPage = parseInt(this.value);
currentPage = 1; // Reset to first page
updatePagination();
});
}
// Initialize pagination
// Update pagination controls
if (paginationControls) {
updatePagination();
// Clear existing page links (except prev/next)
const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)');
pageLinks.forEach(link => link.remove());
// Add new page links
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Adjust if we're near the end
if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
// Insert page links before the next button
const nextPageElement = document.getElementById('nextPage');
for (let i = startPage; i <= endPage; i++) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
const a = document.createElement('a');
a.className = 'page-link';
a.href = '#';
a.textContent = i;
a.addEventListener('click', function (e) {
e.preventDefault();
currentPage = i;
updatePagination();
});
li.appendChild(a);
paginationControls.insertBefore(li, nextPageElement);
}
// Update prev/next buttons
prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
}
// Initialize tooltips for all elements with title attributes
const tooltipElements = document.querySelectorAll('[title]');
if (tooltipElements.length > 0) {
[].slice.call(tooltipElements).map(function (el) {
return new bootstrap.Tooltip(el);
// Show current page
showCurrentPage();
}
// Function to show current page
function showCurrentPage() {
if (!votesTableBody) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
});
</script>
{% endblock scripts %}
{% endblock content %}
// Hide all rows first
voteRows.forEach(row => row.style.display = 'none');
// Calculate pagination
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Show only rows for current page
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
filteredRows.slice(start, end).forEach(row => row.style.display = '');
// Update pagination info
if (startRowElement && endRowElement && totalRowsElement) {
startRowElement.textContent = totalRows > 0 ? start + 1 : 0;
endRowElement.textContent = Math.min(end, totalRows);
totalRowsElement.textContent = totalRows;
}
}
// Event listeners for pagination
if (prevPageBtn) {
prevPageBtn.addEventListener('click', function (e) {
e.preventDefault();
if (currentPage > 1) {
currentPage--;
updatePagination();
}
});
}
if (nextPageBtn) {
nextPageBtn.addEventListener('click', function (e) {
e.preventDefault();
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
if (currentPage < totalPages) {
currentPage++;
updatePagination();
}
});
}
if (rowsPerPageSelect) {
rowsPerPageSelect.addEventListener('change', function () {
rowsPerPage = parseInt(this.value);
currentPage = 1; // Reset to first page
updatePagination();
});
}
// Initialize pagination (but don't interfere with filtering)
if (paginationControls) {
// Only initialize pagination if there are many votes
// The filtering will handle showing/hiding rows
console.log('Pagination controls available but not interfering with filtering');
}
// Initialize tooltips for all elements with title attributes
const tooltipElements = document.querySelectorAll('[title]');
if (tooltipElements.length > 0) {
[].slice.call(tooltipElements).map(function (el) {
return new bootstrap.Tooltip(el);
});
}
// Add debugging for vote form
const voteForm = document.getElementById('voteForm');
if (voteForm) {
console.log('Vote form found:', voteForm);
voteForm.addEventListener('submit', function (e) {
console.log('Vote form submitted');
const formData = new FormData(voteForm);
console.log('Form data:', Object.fromEntries(formData));
});
} else {
console.log('Vote form not found');
}
// Debug logging
console.log('Filter buttons found:', filterButtons.length);
console.log('Vote rows found:', voteRows.length);
console.log('Search input found:', searchInput ? 'Yes' : 'No');
});
</script>
{% endblock %}

View File

@ -3,6 +3,12 @@
{% block title %}Proposals - Governance Dashboard{% endblock %}
{% block content %}
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
{% include "governance/_tabs.html" %}
<!-- Success message if present -->
{% if success %}
<div class="row mb-4">
@ -15,26 +21,6 @@
</div>
{% endif %}
<!-- Navigation Tabs -->
<div class="row mb-3">
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="/governance">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/governance/proposals">All Proposals</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/my-votes">My Votes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div>
</div>
<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>
@ -58,18 +44,23 @@
<div class="col-md-4">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="" {% if not status_filter or status_filter == "" %}selected{% endif %}>All Statuses</option>
<option value="Draft" {% if status_filter == "Draft" %}selected{% endif %}>Draft</option>
<option value="Active" {% if status_filter == "Active" %}selected{% endif %}>Active</option>
<option value="Approved" {% if status_filter == "Approved" %}selected{% endif %}>Approved</option>
<option value="Rejected" {% if status_filter == "Rejected" %}selected{% endif %}>Rejected</option>
<option value="Cancelled" {% if status_filter == "Cancelled" %}selected{% endif %}>Cancelled</option>
<option value="" {% if not status_filter or status_filter=="" %}selected{% endif %}>All
Statuses</option>
<option value="Draft" {% if status_filter=="Draft" %}selected{% endif %}>Draft</option>
<option value="Active" {% if status_filter=="Active" %}selected{% endif %}>Active</option>
<option value="Approved" {% if status_filter=="Approved" %}selected{% endif %}>Approved
</option>
<option value="Rejected" {% if status_filter=="Rejected" %}selected{% endif %}>Rejected
</option>
<option value="Cancelled" {% if status_filter=="Cancelled" %}selected{% endif %}>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" name="search"
placeholder="Search by title or description" value="{% if search_filter %}{{ search_filter }}{% endif %}">
placeholder="Search by title or description"
value="{% if search_filter %}{{ search_filter }}{% endif %}">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
@ -130,21 +121,22 @@
{% endfor %}
</tbody>
</table>
{% else %}
<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>
{% if status_filter or search_filter %}
<p>No proposals match your current filter criteria. Try adjusting your filters or <a href="/governance/proposals" class="alert-link">view all proposals</a>.</p>
{% else %}
<p>There are no proposals in the system yet.</p>
<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>
{% if status_filter or search_filter %}
<p>No proposals match your current filter criteria. Try adjusting your filters or <a
href="/governance/proposals" class="alert-link">view all proposals</a>.</p>
{% else %}
<p>There are no proposals in the system yet.</p>
{% endif %}
<a href="/governance/create" class="btn btn-primary mt-3">Create New Proposal</a>
</div>
{% endif %}
<a href="/governance/create" class="btn btn-primary mt-3">Create New Proposal</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}