diff --git a/actix_mvc_app/src/controllers/contract.rs b/actix_mvc_app/src/controllers/contract.rs new file mode 100644 index 0000000..2bd85ae --- /dev/null +++ b/actix_mvc_app/src/controllers/contract.rs @@ -0,0 +1,344 @@ +use actix_web::{web, HttpResponse, Responder, Result}; +use tera::{Context, Tera}; +use chrono::{Utc, Duration}; +use uuid::Uuid; +use serde::{Deserialize, Serialize}; + +use crate::models::contract::{Contract, ContractStatus, ContractType, ContractStatistics, ContractSigner, ContractRevision, SignerStatus, ContractFilter}; +use crate::controllers::auth::Claims; + +#[derive(Debug, Deserialize)] +pub struct ContractForm { + pub title: String, + pub description: String, + pub contract_type: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct SignerForm { + pub name: String, + pub email: String, +} + +pub struct ContractController; + +impl ContractController { + // Display the contracts dashboard + pub async fn index(tmpl: web::Data) -> Result { + let mut context = Context::new(); + + let contracts = Self::get_mock_contracts(); + let stats = ContractStatistics::new(&contracts); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + // Add stats + context.insert("stats", &serde_json::to_value(stats).unwrap()); + + // Add recent contracts + let recent_contracts: Vec> = contracts + .iter() + .take(5) + .map(|c| Self::contract_to_json(c)) + .collect(); + + context.insert("recent_contracts", &recent_contracts); + + // Add pending signature contracts + let pending_signature_contracts: Vec> = contracts + .iter() + .filter(|c| c.status == ContractStatus::PendingSignatures) + .map(|c| Self::contract_to_json(c)) + .collect(); + + context.insert("pending_signature_contracts", &pending_signature_contracts); + + // Add draft contracts + let draft_contracts: Vec> = contracts + .iter() + .filter(|c| c.status == ContractStatus::Draft) + .map(|c| Self::contract_to_json(c)) + .collect(); + + context.insert("draft_contracts", &draft_contracts); + + Ok(HttpResponse::Ok().content_type("text/html").body( + tmpl.render("contracts/index.html", &context).unwrap() + )) + } + + // Display the list of all contracts + pub async fn list(tmpl: web::Data) -> Result { + let mut context = Context::new(); + + let contracts = Self::get_mock_contracts(); + let contracts_data: Vec> = contracts + .iter() + .map(|c| Self::contract_to_json(c)) + .collect(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + context.insert("contracts", &contracts_data); + context.insert("filter", &"all"); + + Ok(HttpResponse::Ok().content_type("text/html").body( + tmpl.render("contracts/contracts.html", &context).unwrap() + )) + } + + // Display the list of user's contracts + pub async fn my_contracts(tmpl: web::Data) -> Result { + let mut context = Context::new(); + + let contracts = Self::get_mock_contracts(); + let contracts_data: Vec> = contracts + .iter() + .map(|c| Self::contract_to_json(c)) + .collect(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + context.insert("contracts", &contracts_data); + + Ok(HttpResponse::Ok().content_type("text/html").body( + tmpl.render("contracts/my_contracts.html", &context).unwrap() + )) + } + + // Display a specific contract + pub async fn detail(tmpl: web::Data, path: web::Path) -> Result { + let contract_id = path.into_inner(); + let mut context = Context::new(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + // Find the contract by ID + let contracts = Self::get_mock_contracts(); + let contract = contracts.iter().find(|c| c.id == contract_id); + + match contract { + Some(contract) => { + // Convert contract to JSON + let contract_json = Self::contract_to_json(contract); + + context.insert("contract", &contract_json); + context.insert("user_has_signed", &false); // Mock data + + Ok(HttpResponse::Ok().content_type("text/html").body( + tmpl.render("contracts/contract_detail.html", &context).unwrap() + )) + }, + None => { + Ok(HttpResponse::NotFound().finish()) + } + } + } + + // Display the create contract form + pub async fn create_form(tmpl: web::Data) -> Result { + let mut context = Context::new(); + + // Add active_page for navigation highlighting + context.insert("active_page", &"contracts"); + + // Add contract types for dropdown + let contract_types = vec![ + ("Service", "Service Agreement"), + ("Employment", "Employment Contract"), + ("NDA", "Non-Disclosure Agreement"), + ("SLA", "Service Level Agreement"), + ("Other", "Other") + ]; + + context.insert("contract_types", &contract_types); + + Ok(HttpResponse::Ok().content_type("text/html").body( + tmpl.render("contracts/create_contract.html", &context).unwrap() + )) + } + + // Process the create contract form + pub async fn create( + tmpl: web::Data, + form: web::Form, + ) -> Result { + // In a real application, we would save the contract to the database + // For now, we'll just redirect to the contracts list + + Ok(HttpResponse::Found().header("Location", "/contracts").finish()) + } + + // Helper method to convert Contract to a JSON object for templates + fn contract_to_json(contract: &Contract) -> serde_json::Map { + let mut map = serde_json::Map::new(); + + map.insert("id".to_string(), serde_json::Value::String(contract.id.clone())); + map.insert("title".to_string(), serde_json::Value::String(contract.title.clone())); + map.insert("description".to_string(), serde_json::Value::String(contract.description.clone())); + map.insert("status".to_string(), serde_json::Value::String(format!("{:?}", contract.status))); + map.insert("contract_type".to_string(), serde_json::Value::String(format!("{:?}", contract.contract_type))); + map.insert("created_at".to_string(), serde_json::Value::String(contract.created_at.format("%Y-%m-%d").to_string())); + map.insert("updated_at".to_string(), serde_json::Value::String(contract.updated_at.format("%Y-%m-%d").to_string())); + map.insert("created_by".to_string(), serde_json::Value::String("John Doe".to_string())); // Mock data + + // Add signers + let signers: Vec = contract.signers.iter() + .map(|s| { + let mut signer_map = serde_json::Map::new(); + signer_map.insert("id".to_string(), serde_json::Value::String(s.id.clone())); + signer_map.insert("name".to_string(), serde_json::Value::String(s.name.clone())); + signer_map.insert("email".to_string(), serde_json::Value::String(s.email.clone())); + signer_map.insert("status".to_string(), serde_json::Value::String(format!("{:?}", s.status))); + + if let Some(signed_at) = s.signed_at { + signer_map.insert("signed_at".to_string(), serde_json::Value::String(signed_at.format("%Y-%m-%d").to_string())); + } + + serde_json::Value::Object(signer_map) + }) + .collect(); + + map.insert("signers".to_string(), serde_json::Value::Array(signers)); + + // Add signed_signers count for templates + let signed_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Signed).count(); + map.insert("signed_signers".to_string(), serde_json::Value::Number(serde_json::Number::from(signed_signers))); + + // Add pending_signers count for templates + let pending_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Pending).count(); + map.insert("pending_signers".to_string(), serde_json::Value::Number(serde_json::Number::from(pending_signers))); + + // Add revisions + let revisions: Vec = contract.revisions.iter() + .map(|r| { + let mut revision_map = serde_json::Map::new(); + revision_map.insert("version".to_string(), serde_json::Value::Number(serde_json::Number::from(r.version))); + revision_map.insert("content".to_string(), serde_json::Value::String(r.content.clone())); + revision_map.insert("created_at".to_string(), serde_json::Value::String(r.created_at.format("%Y-%m-%d").to_string())); + revision_map.insert("created_by".to_string(), serde_json::Value::String(r.created_by.clone())); + + if let Some(comments) = &r.comments { + revision_map.insert("comments".to_string(), serde_json::Value::String(comments.clone())); + // Add notes field using comments since ContractRevision doesn't have a notes field + revision_map.insert("notes".to_string(), serde_json::Value::String(comments.clone())); + } + + serde_json::Value::Object(revision_map) + }) + .collect(); + + map.insert("revisions".to_string(), serde_json::Value::Array(revisions)); + + // Add effective and expiration dates if present + if let Some(effective_date) = &contract.effective_date { + map.insert("effective_date".to_string(), serde_json::Value::String(effective_date.format("%Y-%m-%d").to_string())); + } + + if let Some(expiration_date) = &contract.expiration_date { + map.insert("expiration_date".to_string(), serde_json::Value::String(expiration_date.format("%Y-%m-%d").to_string())); + } + + map + } + + // Generate mock contracts for testing + fn get_mock_contracts() -> Vec { + let now = Utc::now(); + + let mut contracts = Vec::new(); + + // Contract 1 - Draft + let mut contract1 = Contract::new( + "Employment Agreement - Marketing Manager".to_string(), + "Standard employment contract for the Marketing Manager position".to_string(), + ContractType::Employment, + "John Doe".to_string(), + Some("Acme Corp".to_string()) + ); + + contract1.effective_date = Some(now + Duration::days(30)); + contract1.expiration_date = Some(now + Duration::days(395)); // ~1 year + + contract1.add_revision( + "

This is a draft employment contract for the Marketing Manager position.

".to_string(), + "John Doe".to_string(), + Some("Initial draft".to_string()) + ); + + // Contract 2 - Pending Signatures + let mut contract2 = Contract::new( + "Non-Disclosure Agreement - Vendor XYZ".to_string(), + "NDA for our partnership with Vendor XYZ".to_string(), + ContractType::NDA, + "John Doe".to_string(), + Some("Acme Corp".to_string()) + ); + + contract2.effective_date = Some(now); + contract2.expiration_date = Some(now + Duration::days(730)); // 2 years + + contract2.add_revision( + "

This is the first version of the NDA with Vendor XYZ.

".to_string(), + "John Doe".to_string(), + Some("Initial draft".to_string()) + ); + + contract2.add_revision( + "

This is the revised version of the NDA with Vendor XYZ.

".to_string(), + "John Doe".to_string(), + Some("Added confidentiality period".to_string()) + ); + + contract2.add_signer("Jane Smith".to_string(), "jane@example.com".to_string()); + contract2.add_signer("Bob Johnson".to_string(), "bob@vendorxyz.com".to_string()); + + // Mark Jane as signed + if let Some(signer) = contract2.signers.iter_mut().next() { + signer.sign(None); + } + + // Send for signatures + let _ = contract2.send_for_signatures(); + + // Contract 3 - Signed + let mut contract3 = Contract::new( + "Service Agreement - Website Maintenance".to_string(), + "Agreement for ongoing website maintenance services".to_string(), + ContractType::Service, + "John Doe".to_string(), + Some("Acme Corp".to_string()) + ); + + contract3.effective_date = Some(now - Duration::days(7)); + contract3.expiration_date = Some(now + Duration::days(358)); // ~1 year from effective date + + contract3.add_revision( + "

This is a service agreement for website maintenance.

".to_string(), + "John Doe".to_string(), + Some("Initial version".to_string()) + ); + + contract3.add_signer("Jane Smith".to_string(), "jane@example.com".to_string()); + contract3.add_signer("Alice Brown".to_string(), "alice@webmaintenance.com".to_string()); + + // Mark both signers as signed + for signer in contract3.signers.iter_mut() { + signer.sign(None); + } + + // Mark as signed + contract3.status = ContractStatus::Signed; + + contracts.push(contract1); + contracts.push(contract2); + contracts.push(contract3); + + contracts + } +} diff --git a/actix_mvc_app/src/controllers/mod.rs b/actix_mvc_app/src/controllers/mod.rs index 830b081..6827645 100644 --- a/actix_mvc_app/src/controllers/mod.rs +++ b/actix_mvc_app/src/controllers/mod.rs @@ -5,6 +5,7 @@ pub mod ticket; pub mod calendar; pub mod governance; pub mod flow; +pub mod contract; // Re-export controllers for easier imports pub use home::HomeController; @@ -12,4 +13,5 @@ pub use auth::AuthController; pub use ticket::TicketController; pub use calendar::CalendarController; pub use governance::GovernanceController; -pub use flow::FlowController; \ No newline at end of file +pub use flow::FlowController; +pub use contract::ContractController; \ No newline at end of file diff --git a/actix_mvc_app/src/models/contract.rs b/actix_mvc_app/src/models/contract.rs new file mode 100644 index 0000000..7520a83 --- /dev/null +++ b/actix_mvc_app/src/models/contract.rs @@ -0,0 +1,291 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Contract status enum +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ContractStatus { + Draft, + PendingSignatures, + Signed, + Expired, + Cancelled +} + +impl ContractStatus { + pub fn as_str(&self) -> &str { + match self { + ContractStatus::Draft => "Draft", + ContractStatus::PendingSignatures => "Pending Signatures", + ContractStatus::Signed => "Signed", + ContractStatus::Expired => "Expired", + ContractStatus::Cancelled => "Cancelled", + } + } +} + +/// Contract type enum +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ContractType { + Service, + Employment, + NDA, + SLA, + Other +} + +impl ContractType { + pub fn as_str(&self) -> &str { + match self { + ContractType::Service => "Service Agreement", + ContractType::Employment => "Employment Contract", + ContractType::NDA => "Non-Disclosure Agreement", + ContractType::SLA => "Service Level Agreement", + ContractType::Other => "Other", + } + } +} + +/// Contract signer status +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SignerStatus { + Pending, + Signed, + Rejected +} + +impl SignerStatus { + pub fn as_str(&self) -> &str { + match self { + SignerStatus::Pending => "Pending", + SignerStatus::Signed => "Signed", + SignerStatus::Rejected => "Rejected", + } + } +} + +/// Contract signer +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractSigner { + pub id: String, + pub name: String, + pub email: String, + pub status: SignerStatus, + pub signed_at: Option>, + pub comments: Option, +} + +impl ContractSigner { + /// Creates a new contract signer + pub fn new(name: String, email: String) -> Self { + Self { + id: Uuid::new_v4().to_string(), + name, + email, + status: SignerStatus::Pending, + signed_at: None, + comments: None, + } + } + + /// Signs the contract + pub fn sign(&mut self, comments: Option) { + self.status = SignerStatus::Signed; + self.signed_at = Some(Utc::now()); + self.comments = comments; + } + + /// Rejects the contract + pub fn reject(&mut self, comments: Option) { + self.status = SignerStatus::Rejected; + self.signed_at = Some(Utc::now()); + self.comments = comments; + } +} + +/// Contract revision +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractRevision { + pub version: u32, + pub content: String, + pub created_at: DateTime, + pub created_by: String, + pub comments: Option, +} + +impl ContractRevision { + /// Creates a new contract revision + pub fn new(version: u32, content: String, created_by: String, comments: Option) -> Self { + Self { + version, + content, + created_at: Utc::now(), + created_by, + comments, + } + } +} + +/// Contract model +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Contract { + pub id: String, + pub title: String, + pub description: String, + pub contract_type: ContractType, + pub status: ContractStatus, + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by: String, + pub effective_date: Option>, + pub expiration_date: Option>, + pub signers: Vec, + pub revisions: Vec, + pub current_version: u32, + pub organization_id: Option, +} + +impl Contract { + /// Creates a new contract + pub fn new(title: String, description: String, contract_type: ContractType, created_by: String, organization_id: Option) -> Self { + Self { + id: Uuid::new_v4().to_string(), + title, + description, + contract_type, + status: ContractStatus::Draft, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by, + effective_date: None, + expiration_date: None, + signers: Vec::new(), + revisions: Vec::new(), + current_version: 0, + organization_id, + } + } + + /// Adds a signer to the contract + pub fn add_signer(&mut self, name: String, email: String) { + let signer = ContractSigner::new(name, email); + self.signers.push(signer); + self.updated_at = Utc::now(); + } + + /// Adds a revision to the contract + pub fn add_revision(&mut self, content: String, created_by: String, comments: Option) { + let new_version = self.current_version + 1; + let revision = ContractRevision::new(new_version, content, created_by, comments); + self.revisions.push(revision); + self.current_version = new_version; + self.updated_at = Utc::now(); + } + + /// Sends the contract for signatures + pub fn send_for_signatures(&mut self) -> Result<(), String> { + if self.revisions.is_empty() { + return Err("Cannot send contract without content".to_string()); + } + + if self.signers.is_empty() { + return Err("Cannot send contract without signers".to_string()); + } + + self.status = ContractStatus::PendingSignatures; + self.updated_at = Utc::now(); + Ok(()) + } + + /// Checks if all signers have signed + pub fn is_fully_signed(&self) -> bool { + if self.signers.is_empty() { + return false; + } + + self.signers.iter().all(|signer| signer.status == SignerStatus::Signed) + } + + /// Marks the contract as signed if all signers have signed + pub fn finalize_if_signed(&mut self) -> bool { + if self.is_fully_signed() { + self.status = ContractStatus::Signed; + self.updated_at = Utc::now(); + true + } else { + false + } + } + + /// Cancels the contract + pub fn cancel(&mut self) { + self.status = ContractStatus::Cancelled; + self.updated_at = Utc::now(); + } + + /// Gets the latest revision + pub fn latest_revision(&self) -> Option<&ContractRevision> { + self.revisions.last() + } + + /// Gets a specific revision + pub fn get_revision(&self, version: u32) -> Option<&ContractRevision> { + self.revisions.iter().find(|r| r.version == version) + } + + /// Gets the number of pending signers + pub fn pending_signers_count(&self) -> usize { + self.signers.iter().filter(|s| s.status == SignerStatus::Pending).count() + } + + /// Gets the number of signed signers + pub fn signed_signers_count(&self) -> usize { + self.signers.iter().filter(|s| s.status == SignerStatus::Signed).count() + } + + /// Gets the number of rejected signers + pub fn rejected_signers_count(&self) -> usize { + self.signers.iter().filter(|s| s.status == SignerStatus::Rejected).count() + } +} + +/// Contract filter for listing contracts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractFilter { + pub status: Option, + pub contract_type: Option, + pub created_by: Option, + pub organization_id: Option, +} + +/// Contract statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractStatistics { + pub total_contracts: usize, + pub draft_contracts: usize, + pub pending_signature_contracts: usize, + pub signed_contracts: usize, + pub expired_contracts: usize, + pub cancelled_contracts: usize, +} + +impl ContractStatistics { + /// Creates new contract statistics from a list of contracts + pub fn new(contracts: &[Contract]) -> Self { + let total_contracts = contracts.len(); + let draft_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Draft).count(); + let pending_signature_contracts = contracts.iter().filter(|c| c.status == ContractStatus::PendingSignatures).count(); + let signed_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Signed).count(); + let expired_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Expired).count(); + let cancelled_contracts = contracts.iter().filter(|c| c.status == ContractStatus::Cancelled).count(); + + Self { + total_contracts, + draft_contracts, + pending_signature_contracts, + signed_contracts, + expired_contracts, + cancelled_contracts, + } + } +} diff --git a/actix_mvc_app/src/models/mod.rs b/actix_mvc_app/src/models/mod.rs index 9ba73ac..0c57de6 100644 --- a/actix_mvc_app/src/models/mod.rs +++ b/actix_mvc_app/src/models/mod.rs @@ -4,10 +4,12 @@ pub mod ticket; pub mod calendar; pub mod governance; pub mod flow; +pub mod contract; // Re-export models for easier imports pub use user::User; pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority}; pub use calendar::{CalendarEvent, CalendarViewMode}; pub use governance::{Proposal, ProposalStatus, Vote, VoteType, VotingResults, ProposalFilter}; -pub use flow::{Flow, FlowStep, FlowLog, FlowStatus, FlowType, StepStatus, FlowFilter, FlowStatistics}; \ No newline at end of file +pub use flow::{Flow, FlowStep, FlowLog, FlowStatus, FlowType, StepStatus, FlowFilter, FlowStatistics}; +pub use contract::{Contract, ContractSigner, ContractRevision, ContractStatus, ContractType, SignerStatus, ContractFilter, ContractStatistics}; \ No newline at end of file diff --git a/actix_mvc_app/src/routes/mod.rs b/actix_mvc_app/src/routes/mod.rs index 45c1f5b..5eae7ea 100644 --- a/actix_mvc_app/src/routes/mod.rs +++ b/actix_mvc_app/src/routes/mod.rs @@ -6,6 +6,7 @@ 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::middleware::JwtAuth; use crate::SESSION_KEY; @@ -77,6 +78,17 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .route("/create", web::post().to(FlowController::create_flow)) .route("/my-flows", web::get().to(FlowController::my_flows)) ) + + // Contract routes + .service( + web::scope("/contracts") + .route("", web::get().to(ContractController::index)) + .route("/list", web::get().to(ContractController::list)) + .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)) + ) ); // Keep the /protected scope for any future routes that should be under that path diff --git a/actix_mvc_app/src/views/base.html b/actix_mvc_app/src/views/base.html index f911e9a..d7c8e82 100644 --- a/actix_mvc_app/src/views/base.html +++ b/actix_mvc_app/src/views/base.html @@ -64,6 +64,11 @@ Flows +