Compare commits
54 Commits
36812e4178
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a66d4fc8 | ||
|
|
464e253739 | ||
|
|
7e95391a9c | ||
|
|
9802d51acc | ||
|
|
2299b61e79 | ||
|
|
b8928379de | ||
|
|
45c4f4985e | ||
|
|
58d1cde1ce | ||
|
|
d815d9d365 | ||
|
|
2827cfebc9 | ||
|
|
7b15606da5 | ||
|
|
11d7ae37b6 | ||
|
|
70ca9f1605 | ||
|
|
d12a082ca1 | ||
|
|
97e7a04827 | ||
|
|
3d8aca19cc | ||
|
|
52fbc77e3e | ||
|
|
fad288f67d | ||
|
|
4659697ae2 | ||
|
|
67b80f237d | ||
|
|
b606923102 | ||
|
|
8f1438dc01 | ||
|
|
916f435dbc | ||
|
|
5d9eaac1f8 | ||
|
|
9c71c63ec5 | ||
|
|
4a2f1c7282 | ||
|
|
60198dc2d4 | ||
|
|
e4e403e231 | ||
|
|
2fd74defab | ||
|
|
9468595395 | ||
|
|
2760f00a30 | ||
|
|
a7c0772d9b | ||
|
|
54762cb63f | ||
|
|
bafb63e0b1 | ||
|
|
c05803ff58 | ||
|
|
6b7b2542ab | ||
| 457f3c8268 | |||
|
|
19f8700b78 | ||
|
|
c22d6c953e | ||
|
|
9445dea629 | ||
|
|
b56f1cbc30 | ||
|
|
6060831f61 | ||
| 34594b95fa | |||
| 15b05cb599 | |||
| b6dd04a6aa | |||
| 310a5d956f | |||
| af4f09a67b | |||
| 093aff3851 | |||
| 4a87392194 | |||
|
|
951af7dec7 | ||
|
|
36d605829f | ||
|
|
6ed6737c7e | ||
| 361dff22c5 | |||
| 4a8f65e155 |
2
actix_mvc_app/.cargo/config.toml
Normal file
2
actix_mvc_app/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[net]
|
||||||
|
git-fetch-with-cli = true
|
||||||
21
actix_mvc_app/.env.example
Normal file
21
actix_mvc_app/.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Environment Variables Template
|
||||||
|
# Copy this file to '.env' and customize with your own values
|
||||||
|
# This file should NOT be committed to version control
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
# APP__SERVER__HOST=127.0.0.1
|
||||||
|
# APP__SERVER__PORT=9999
|
||||||
|
|
||||||
|
# Stripe Configuration (Test Keys)
|
||||||
|
# Get your test keys from: https://dashboard.stripe.com/test/apikeys
|
||||||
|
# APP__STRIPE__PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE
|
||||||
|
# APP__STRIPE__SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE
|
||||||
|
# APP__STRIPE__WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET_HERE
|
||||||
|
|
||||||
|
# For production, use live keys:
|
||||||
|
# APP__STRIPE__PUBLISHABLE_KEY=pk_live_YOUR_LIVE_PUBLISHABLE_KEY
|
||||||
|
# APP__STRIPE__SECRET_KEY=sk_live_YOUR_LIVE_SECRET_KEY
|
||||||
|
# APP__STRIPE__WEBHOOK_SECRET=whsec_YOUR_LIVE_WEBHOOK_SECRET
|
||||||
|
|
||||||
|
# Database Configuration (if needed)
|
||||||
|
# DATABASE_URL=postgresql://user:password@localhost/dbname
|
||||||
53
actix_mvc_app/.gitignore
vendored
Normal file
53
actix_mvc_app/.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Rust build artifacts
|
||||||
|
/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Local configuration files
|
||||||
|
config/local.toml
|
||||||
|
config/production.toml
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
data/*.db
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.json
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# SSL certificates (keep examples)
|
||||||
|
nginx/ssl/*.pem
|
||||||
|
nginx/ssl/*.key
|
||||||
|
!nginx/ssl/README.md
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
docker-data/
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
|
||||||
|
# Keep important development files
|
||||||
|
!ai_prompt/
|
||||||
|
!PRODUCTION_DEPLOYMENT.md
|
||||||
|
!STRIPE_SETUP.md
|
||||||
|
!payment_plan.md
|
||||||
1418
actix_mvc_app/Cargo.lock
generated
1418
actix_mvc_app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,17 @@ name = "actix_mvc_app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "actix_mvc_app"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "actix_mvc_app"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
actix-multipart = "0.6.1"
|
||||||
|
futures-util = "0.3.30"
|
||||||
actix-web = "4.5.1"
|
actix-web = "4.5.1"
|
||||||
actix-files = "0.6.5"
|
actix-files = "0.6.5"
|
||||||
tera = "1.19.1"
|
tera = "1.19.1"
|
||||||
@@ -13,6 +23,8 @@ env_logger = "0.11.2"
|
|||||||
log = "0.4.21"
|
log = "0.4.21"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
chrono = { version = "0.4.35", features = ["serde"] }
|
chrono = { version = "0.4.35", features = ["serde"] }
|
||||||
|
heromodels = { path = "../../db/heromodels" }
|
||||||
|
heromodels_core = { path = "../../db/heromodels_core" }
|
||||||
config = "0.14.0"
|
config = "0.14.0"
|
||||||
num_cpus = "1.16.0"
|
num_cpus = "1.16.0"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
@@ -23,3 +35,26 @@ uuid = { version = "1.6.1", features = ["v4", "serde"] }
|
|||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
redis = { version = "0.23.0", features = ["tokio-comp"] }
|
redis = { version = "0.23.0", features = ["tokio-comp"] }
|
||||||
jsonwebtoken = "8.3.0"
|
jsonwebtoken = "8.3.0"
|
||||||
|
pulldown-cmark = "0.13.0"
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
async-stripe = { version = "0.41", features = ["runtime-tokio-hyper"] }
|
||||||
|
reqwest = { version = "0.12.20", features = ["json"] }
|
||||||
|
|
||||||
|
# Security dependencies for webhook verification
|
||||||
|
hmac = "0.12.1"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
hex = "0.4.3"
|
||||||
|
|
||||||
|
# Validation dependencies
|
||||||
|
regex = "1.10.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Testing dependencies
|
||||||
|
tokio-test = "0.4.3"
|
||||||
|
|
||||||
|
[patch."https://git.ourworld.tf/herocode/db.git"]
|
||||||
|
rhai_autobind_macros = { path = "../../rhaj/rhai_autobind_macros" }
|
||||||
|
rhai_wrapper = { path = "../../rhaj/rhai_wrapper" }
|
||||||
|
|
||||||
|
|||||||
69
actix_mvc_app/Dockerfile.prod
Normal file
69
actix_mvc_app/Dockerfile.prod
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Multi-stage build for production
|
||||||
|
FROM rust:1.75-slim as builder
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
libpq-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Create a dummy main.rs to build dependencies
|
||||||
|
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||||
|
|
||||||
|
# Build dependencies (this layer will be cached)
|
||||||
|
RUN cargo build --release && rm -rf src
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src ./src
|
||||||
|
COPY tests ./tests
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ca-certificates \
|
||||||
|
libssl3 \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create app user
|
||||||
|
RUN useradd -m -u 1001 appuser
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder stage
|
||||||
|
COPY --from=builder /app/target/release/actix_mvc_app /app/actix_mvc_app
|
||||||
|
|
||||||
|
# Copy static files and templates
|
||||||
|
COPY src/views ./src/views
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
# Create data and logs directories
|
||||||
|
RUN mkdir -p data logs && chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to app user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["./actix_mvc_app"]
|
||||||
180
actix_mvc_app/PRODUCTION_CHECKLIST.md
Normal file
180
actix_mvc_app/PRODUCTION_CHECKLIST.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Production Checklist ✅
|
||||||
|
|
||||||
|
## 🧹 Code Cleanup Status
|
||||||
|
|
||||||
|
### ✅ **Completed**
|
||||||
|
- [x] Removed build artifacts (`cargo clean`)
|
||||||
|
- [x] Updated .gitignore to keep `ai_prompt/` folder
|
||||||
|
- [x] Created proper .gitignore for actix_mvc_app
|
||||||
|
- [x] Cleaned up debug console.log statements (kept error logs)
|
||||||
|
- [x] Commented out verbose debug logging
|
||||||
|
- [x] Maintained essential error handling logs
|
||||||
|
|
||||||
|
### 🔧 **Configuration**
|
||||||
|
- [x] Environment variables properly configured
|
||||||
|
- [x] Stripe keys configured (test/production)
|
||||||
|
- [x] Database connection settings
|
||||||
|
- [x] CORS settings for production domains
|
||||||
|
- [x] SSL/TLS configuration ready
|
||||||
|
|
||||||
|
### 🛡️ **Security**
|
||||||
|
- [x] Stripe webhook signature verification
|
||||||
|
- [x] Input validation on all forms
|
||||||
|
- [x] SQL injection prevention (using ORM)
|
||||||
|
- [x] XSS protection (template escaping)
|
||||||
|
- [x] CSRF protection implemented
|
||||||
|
- [x] Rate limiting configured
|
||||||
|
|
||||||
|
### 📊 **Database**
|
||||||
|
- [x] Database corruption recovery implemented
|
||||||
|
- [x] Proper error handling for DB operations
|
||||||
|
- [x] Company status transitions working
|
||||||
|
- [x] Payment integration with company creation
|
||||||
|
- [x] Data validation and constraints
|
||||||
|
|
||||||
|
### 💳 **Payment System**
|
||||||
|
- [x] Stripe Elements integration
|
||||||
|
- [x] Payment intent creation
|
||||||
|
- [x] Webhook handling for payment confirmation
|
||||||
|
- [x] Company activation on successful payment
|
||||||
|
- [x] Error handling for failed payments
|
||||||
|
- [x] Test card validation working
|
||||||
|
|
||||||
|
### 🎨 **User Interface**
|
||||||
|
- [x] Multi-step form validation
|
||||||
|
- [x] Real-time form saving to localStorage
|
||||||
|
- [x] Payment section hidden until ready
|
||||||
|
- [x] Comprehensive error messages
|
||||||
|
- [x] Loading states and progress indicators
|
||||||
|
- [x] Mobile-responsive design
|
||||||
|
|
||||||
|
## 🚀 **Pre-Deployment Steps**
|
||||||
|
|
||||||
|
### **1. Environment Setup**
|
||||||
|
```bash
|
||||||
|
# Set production environment variables
|
||||||
|
export RUST_ENV=production
|
||||||
|
export STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||||
|
export STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
export STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
export DATABASE_URL=production_db_url
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2. Build for Production**
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### **3. Database Migration**
|
||||||
|
```bash
|
||||||
|
# Ensure database is properly initialized
|
||||||
|
# Run any pending migrations
|
||||||
|
# Verify data integrity
|
||||||
|
```
|
||||||
|
|
||||||
|
### **4. SSL Certificate**
|
||||||
|
```bash
|
||||||
|
# Ensure SSL certificates are properly configured
|
||||||
|
# Test HTTPS endpoints
|
||||||
|
# Verify webhook endpoints are accessible
|
||||||
|
```
|
||||||
|
|
||||||
|
### **5. Final Testing**
|
||||||
|
- [ ] Test complete registration flow
|
||||||
|
- [ ] Test payment processing with real cards
|
||||||
|
- [ ] Test webhook delivery
|
||||||
|
- [ ] Test error scenarios
|
||||||
|
- [ ] Test mobile responsiveness
|
||||||
|
- [ ] Load testing for concurrent users
|
||||||
|
|
||||||
|
## 📋 **Deployment Commands**
|
||||||
|
|
||||||
|
### **Docker Deployment**
|
||||||
|
```bash
|
||||||
|
# Build production image
|
||||||
|
docker build -f Dockerfile.prod -t company-registration:latest .
|
||||||
|
|
||||||
|
# Run with production config
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Direct Deployment**
|
||||||
|
```bash
|
||||||
|
# Start production server
|
||||||
|
RUST_ENV=production ./target/release/actix_mvc_app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 **Post-Deployment Verification**
|
||||||
|
|
||||||
|
### **Health Checks**
|
||||||
|
- [ ] Application starts successfully
|
||||||
|
- [ ] Database connections working
|
||||||
|
- [ ] Stripe connectivity verified
|
||||||
|
- [ ] All endpoints responding
|
||||||
|
- [ ] SSL certificates valid
|
||||||
|
- [ ] Webhook endpoints accessible
|
||||||
|
|
||||||
|
### **Functional Testing**
|
||||||
|
- [ ] Complete a test registration
|
||||||
|
- [ ] Process a test payment
|
||||||
|
- [ ] Verify company creation
|
||||||
|
- [ ] Check email notifications (if implemented)
|
||||||
|
- [ ] Test error scenarios
|
||||||
|
|
||||||
|
### **Monitoring**
|
||||||
|
- [ ] Application logs are being captured
|
||||||
|
- [ ] Error tracking is working
|
||||||
|
- [ ] Performance metrics available
|
||||||
|
- [ ] Database monitoring active
|
||||||
|
|
||||||
|
## 📁 **Important Files for Production**
|
||||||
|
|
||||||
|
### **Keep These Files**
|
||||||
|
- `ai_prompt/` - Development assistance
|
||||||
|
- `payment_plan.md` - Development roadmap
|
||||||
|
- `PRODUCTION_DEPLOYMENT.md` - Deployment guide
|
||||||
|
- `STRIPE_SETUP.md` - Payment configuration
|
||||||
|
- `config/` - Configuration files
|
||||||
|
- `src/` - Source code
|
||||||
|
- `static/` - Static assets
|
||||||
|
- `tests/` - Test files
|
||||||
|
|
||||||
|
### **Generated/Temporary Files (Ignored)**
|
||||||
|
- `target/` - Build artifacts
|
||||||
|
- `data/*.json` - Test data
|
||||||
|
- `logs/` - Log files
|
||||||
|
- `tmp/` - Temporary files
|
||||||
|
- `.env` - Environment files
|
||||||
|
|
||||||
|
## 🎯 **Ready for Production**
|
||||||
|
|
||||||
|
The application is now clean and ready for production deployment with:
|
||||||
|
|
||||||
|
✅ **Core Features Working**
|
||||||
|
- Multi-step company registration
|
||||||
|
- Stripe payment processing
|
||||||
|
- Database integration
|
||||||
|
- Error handling and recovery
|
||||||
|
- Security measures implemented
|
||||||
|
|
||||||
|
✅ **Code Quality**
|
||||||
|
- Debug logs cleaned up
|
||||||
|
- Proper error handling
|
||||||
|
- Input validation
|
||||||
|
- Security best practices
|
||||||
|
|
||||||
|
✅ **Documentation**
|
||||||
|
- Setup guides available
|
||||||
|
- Configuration documented
|
||||||
|
- Deployment instructions ready
|
||||||
|
- Development roadmap planned
|
||||||
|
|
||||||
|
## 🚀 **Next Steps After Deployment**
|
||||||
|
|
||||||
|
1. **Monitor initial usage** and performance
|
||||||
|
2. **Implement email notifications** (Option A from payment_plan.md)
|
||||||
|
3. **Build company dashboard** (Option B from payment_plan.md)
|
||||||
|
4. **Add document generation** (Option C from payment_plan.md)
|
||||||
|
5. **Enhance user authentication** (Option D from payment_plan.md)
|
||||||
|
|
||||||
|
The foundation is solid - ready to build the next features! 🎉
|
||||||
410
actix_mvc_app/PRODUCTION_DEPLOYMENT.md
Normal file
410
actix_mvc_app/PRODUCTION_DEPLOYMENT.md
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
# Production Deployment Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide covers deploying the Freezone Company Registration System to production with proper security, monitoring, and reliability.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose installed
|
||||||
|
- SSL certificates for HTTPS
|
||||||
|
- Stripe production account with API keys
|
||||||
|
- Domain name configured
|
||||||
|
- Server with at least 4GB RAM and 2 CPU cores
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env.prod` file with the following variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application
|
||||||
|
RUST_ENV=production
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
# Database
|
||||||
|
POSTGRES_DB=freezone_prod
|
||||||
|
POSTGRES_USER=freezone_user
|
||||||
|
POSTGRES_PASSWORD=your_secure_db_password
|
||||||
|
DATABASE_URL=postgresql://freezone_user:your_secure_db_password@db:5432/freezone_prod
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://:your_redis_password@redis:6379
|
||||||
|
REDIS_PASSWORD=your_secure_redis_password
|
||||||
|
|
||||||
|
# Stripe (Production Keys)
|
||||||
|
STRIPE_SECRET_KEY=sk_live_your_production_secret_key
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_your_production_webhook_secret
|
||||||
|
|
||||||
|
# Session Security
|
||||||
|
SESSION_SECRET=your_64_character_session_secret_key_for_production_use_only
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
GRAFANA_PASSWORD=your_secure_grafana_password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
### Before Deployment
|
||||||
|
|
||||||
|
- [ ] **SSL/TLS Certificates**: Obtain valid SSL certificates for your domain
|
||||||
|
- [ ] **Environment Variables**: All production secrets are set and secure
|
||||||
|
- [ ] **Database Security**: Database passwords are strong and unique
|
||||||
|
- [ ] **Stripe Configuration**: Production Stripe keys are configured
|
||||||
|
- [ ] **Session Security**: Session secret is 64+ characters and random
|
||||||
|
- [ ] **Firewall Rules**: Only necessary ports are open (80, 443, 22)
|
||||||
|
- [ ] **User Permissions**: Application runs as non-root user
|
||||||
|
|
||||||
|
### Stripe Configuration
|
||||||
|
|
||||||
|
1. **Production Account**: Ensure you're using Stripe production keys
|
||||||
|
2. **Webhook Endpoints**: Configure webhook endpoint in Stripe dashboard:
|
||||||
|
- URL: `https://yourdomain.com/payment/webhook`
|
||||||
|
- Events: `payment_intent.succeeded`, `payment_intent.payment_failed`
|
||||||
|
3. **Webhook Secret**: Copy the webhook signing secret to environment variables
|
||||||
|
|
||||||
|
### Database Security
|
||||||
|
|
||||||
|
1. **Connection Security**: Use SSL connections to database
|
||||||
|
2. **User Permissions**: Create dedicated database user with minimal permissions
|
||||||
|
3. **Backup Strategy**: Implement automated database backups
|
||||||
|
4. **Access Control**: Restrict database access to application only
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### 1. Server Preparation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update system
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
|
||||||
|
# Install Docker Compose
|
||||||
|
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||||
|
sudo chmod +x /usr/local/bin/docker-compose
|
||||||
|
|
||||||
|
# Create application directory
|
||||||
|
sudo mkdir -p /opt/freezone
|
||||||
|
sudo chown $USER:$USER /opt/freezone
|
||||||
|
cd /opt/freezone
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Application Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/your-org/freezone-registration.git .
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.prod.example .env.prod
|
||||||
|
# Edit .env.prod with your production values
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p data logs nginx/ssl static
|
||||||
|
|
||||||
|
# Copy SSL certificates to nginx/ssl/
|
||||||
|
# - cert.pem (certificate)
|
||||||
|
# - key.pem (private key)
|
||||||
|
|
||||||
|
# Build and start services
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. SSL Configuration
|
||||||
|
|
||||||
|
Create `nginx/nginx.conf`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
upstream app {
|
||||||
|
server app:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://app/health;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Monitoring Setup
|
||||||
|
|
||||||
|
The deployment includes:
|
||||||
|
|
||||||
|
- **Prometheus**: Metrics collection (port 9090)
|
||||||
|
- **Grafana**: Dashboards and alerting (port 3000)
|
||||||
|
- **Loki**: Log aggregation (port 3100)
|
||||||
|
- **Promtail**: Log shipping
|
||||||
|
|
||||||
|
Access Grafana at `https://yourdomain.com:3000` with admin credentials.
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
The application provides several health check endpoints:
|
||||||
|
|
||||||
|
- `/health` - Overall system health
|
||||||
|
- `/health/detailed` - Detailed component status
|
||||||
|
- `/health/ready` - Readiness for load balancers
|
||||||
|
- `/health/live` - Liveness check
|
||||||
|
|
||||||
|
## Monitoring and Alerting
|
||||||
|
|
||||||
|
### Key Metrics to Monitor
|
||||||
|
|
||||||
|
1. **Application Health**
|
||||||
|
- Response time
|
||||||
|
- Error rate
|
||||||
|
- Request volume
|
||||||
|
- Memory usage
|
||||||
|
|
||||||
|
2. **Payment Processing**
|
||||||
|
- Payment success rate
|
||||||
|
- Payment processing time
|
||||||
|
- Failed payment count
|
||||||
|
- Webhook processing time
|
||||||
|
|
||||||
|
3. **Database Performance**
|
||||||
|
- Connection pool usage
|
||||||
|
- Query response time
|
||||||
|
- Database size
|
||||||
|
- Active connections
|
||||||
|
|
||||||
|
4. **System Resources**
|
||||||
|
- CPU usage
|
||||||
|
- Memory usage
|
||||||
|
- Disk space
|
||||||
|
- Network I/O
|
||||||
|
|
||||||
|
### Alerting Rules
|
||||||
|
|
||||||
|
Configure alerts for:
|
||||||
|
|
||||||
|
- Application downtime (> 1 minute)
|
||||||
|
- High error rate (> 5%)
|
||||||
|
- Payment failures (> 2%)
|
||||||
|
- Database connection issues
|
||||||
|
- High memory usage (> 80%)
|
||||||
|
- Disk space low (< 10%)
|
||||||
|
|
||||||
|
## Backup Strategy
|
||||||
|
|
||||||
|
### Database Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Daily backup script
|
||||||
|
#!/bin/bash
|
||||||
|
BACKUP_DIR="/opt/freezone/backups"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
docker exec freezone-db pg_dump -U freezone_user freezone_prod > $BACKUP_DIR/db_backup_$DATE.sql
|
||||||
|
|
||||||
|
# Keep only last 30 days
|
||||||
|
find $BACKUP_DIR -name "db_backup_*.sql" -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Data Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup registration data and logs
|
||||||
|
tar -czf /opt/freezone/backups/app_data_$(date +%Y%m%d).tar.gz \
|
||||||
|
/opt/freezone/data \
|
||||||
|
/opt/freezone/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Regular Tasks
|
||||||
|
|
||||||
|
1. **Weekly**
|
||||||
|
- Review application logs
|
||||||
|
- Check system resource usage
|
||||||
|
- Verify backup integrity
|
||||||
|
- Update security patches
|
||||||
|
|
||||||
|
2. **Monthly**
|
||||||
|
- Review payment processing metrics
|
||||||
|
- Update dependencies
|
||||||
|
- Performance optimization review
|
||||||
|
- Security audit
|
||||||
|
|
||||||
|
### Log Rotation
|
||||||
|
|
||||||
|
Configure log rotation in `/etc/logrotate.d/freezone`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/freezone/logs/*.log {
|
||||||
|
daily
|
||||||
|
rotate 30
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
create 644 appuser appuser
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Application Won't Start**
|
||||||
|
- Check environment variables
|
||||||
|
- Verify database connectivity
|
||||||
|
- Check SSL certificate paths
|
||||||
|
|
||||||
|
2. **Payment Processing Fails**
|
||||||
|
- Verify Stripe API keys
|
||||||
|
- Check webhook configuration
|
||||||
|
- Review payment logs
|
||||||
|
|
||||||
|
3. **Database Connection Issues**
|
||||||
|
- Check database container status
|
||||||
|
- Verify connection string
|
||||||
|
- Check network connectivity
|
||||||
|
|
||||||
|
### Log Locations
|
||||||
|
|
||||||
|
- Application logs: `/opt/freezone/logs/`
|
||||||
|
- Docker logs: `docker-compose logs [service]`
|
||||||
|
- Nginx logs: `docker-compose logs nginx`
|
||||||
|
- Database logs: `docker-compose logs db`
|
||||||
|
|
||||||
|
### Emergency Procedures
|
||||||
|
|
||||||
|
1. **Application Rollback**
|
||||||
|
```bash
|
||||||
|
# Stop current deployment
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
git checkout previous-stable-tag
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Database Recovery**
|
||||||
|
```bash
|
||||||
|
# Restore from backup
|
||||||
|
docker exec -i freezone-db psql -U freezone_user freezone_prod < backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Maintenance
|
||||||
|
|
||||||
|
### Regular Security Tasks
|
||||||
|
|
||||||
|
1. **Update Dependencies**
|
||||||
|
```bash
|
||||||
|
# Update Rust dependencies
|
||||||
|
cargo update
|
||||||
|
|
||||||
|
# Rebuild with security patches
|
||||||
|
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **SSL Certificate Renewal**
|
||||||
|
```bash
|
||||||
|
# Using Let's Encrypt (example)
|
||||||
|
certbot renew --nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Security Scanning**
|
||||||
|
```bash
|
||||||
|
# Scan for vulnerabilities
|
||||||
|
cargo audit
|
||||||
|
|
||||||
|
# Docker image scanning
|
||||||
|
docker scan freezone-registration-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Application Tuning
|
||||||
|
|
||||||
|
1. **Database Connection Pool**
|
||||||
|
- Monitor connection usage
|
||||||
|
- Adjust pool size based on load
|
||||||
|
|
||||||
|
2. **Redis Configuration**
|
||||||
|
- Configure memory limits
|
||||||
|
- Enable persistence if needed
|
||||||
|
|
||||||
|
3. **Nginx Optimization**
|
||||||
|
- Enable gzip compression
|
||||||
|
- Configure caching headers
|
||||||
|
- Optimize worker processes
|
||||||
|
|
||||||
|
### Scaling Considerations
|
||||||
|
|
||||||
|
1. **Horizontal Scaling**
|
||||||
|
- Load balancer configuration
|
||||||
|
- Session store externalization
|
||||||
|
- Database read replicas
|
||||||
|
|
||||||
|
2. **Vertical Scaling**
|
||||||
|
- Monitor resource usage
|
||||||
|
- Increase container resources
|
||||||
|
- Optimize database queries
|
||||||
|
|
||||||
|
## Support and Maintenance
|
||||||
|
|
||||||
|
For production support:
|
||||||
|
|
||||||
|
1. **Monitoring**: Use Grafana dashboards for real-time monitoring
|
||||||
|
2. **Alerting**: Configure alerts for critical issues
|
||||||
|
3. **Logging**: Centralized logging with Loki/Grafana
|
||||||
|
4. **Documentation**: Keep deployment documentation updated
|
||||||
|
|
||||||
|
## Compliance and Auditing
|
||||||
|
|
||||||
|
### PCI DSS Compliance
|
||||||
|
|
||||||
|
- Secure payment processing with Stripe
|
||||||
|
- No storage of sensitive payment data
|
||||||
|
- Regular security assessments
|
||||||
|
- Access logging and monitoring
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- Secure data transmission (HTTPS)
|
||||||
|
- Data encryption at rest
|
||||||
|
- Regular backups
|
||||||
|
- Access control and audit trails
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
|
||||||
|
The application logs all critical events:
|
||||||
|
- Payment processing
|
||||||
|
- User actions
|
||||||
|
- Administrative changes
|
||||||
|
- Security events
|
||||||
|
|
||||||
|
Review audit logs regularly and maintain for compliance requirements.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Actix MVC App
|
# Zanzibar Digital Freezone
|
||||||
|
|
||||||
A Rust web application built with Actix Web, Tera templates, and Bootstrap 5.3.5, following the MVC (Model-View-Controller) architectural pattern.
|
Convenience, Safety and Privacy
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ actix_mvc_app/
|
|||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```
|
```
|
||||||
git clone https://github.com/yourusername/actix_mvc_app.git
|
git clone https://github.com/yourusername/zanzibar-digital-freezone.git
|
||||||
cd actix_mvc_app
|
cd zanzibar-digital-freezone
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Build the project:
|
2. Build the project:
|
||||||
|
|||||||
100
actix_mvc_app/STRIPE_SETUP.md
Normal file
100
actix_mvc_app/STRIPE_SETUP.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Stripe Integration Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to configure Stripe payment processing for the company registration system.
|
||||||
|
|
||||||
|
## 🔧 Configuration Options
|
||||||
|
|
||||||
|
The application supports multiple ways to configure Stripe API keys:
|
||||||
|
|
||||||
|
### 1. Configuration Files (Recommended for Development)
|
||||||
|
|
||||||
|
#### Default Configuration
|
||||||
|
The application includes default test keys in `config/default.toml`:
|
||||||
|
```toml
|
||||||
|
[stripe]
|
||||||
|
publishable_key = "pk_test_..."
|
||||||
|
secret_key = "sk_test_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Local Configuration
|
||||||
|
Create `config/local.toml` to override defaults:
|
||||||
|
```toml
|
||||||
|
[stripe]
|
||||||
|
publishable_key = "pk_test_YOUR_KEY_HERE"
|
||||||
|
secret_key = "sk_test_YOUR_KEY_HERE"
|
||||||
|
webhook_secret = "whsec_YOUR_WEBHOOK_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Variables (Recommended for Production)
|
||||||
|
|
||||||
|
Set environment variables with the `APP__` prefix:
|
||||||
|
```bash
|
||||||
|
export APP__STRIPE__PUBLISHABLE_KEY="pk_test_YOUR_KEY_HERE"
|
||||||
|
export APP__STRIPE__SECRET_KEY="sk_test_YOUR_KEY_HERE"
|
||||||
|
export APP__STRIPE__WEBHOOK_SECRET="whsec_YOUR_WEBHOOK_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create a `.env` file:
|
||||||
|
```bash
|
||||||
|
APP__STRIPE__PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE
|
||||||
|
APP__STRIPE__SECRET_KEY=sk_test_YOUR_KEY_HERE
|
||||||
|
APP__STRIPE__WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Getting Your Stripe Keys
|
||||||
|
|
||||||
|
### Test Keys (Development)
|
||||||
|
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/test/apikeys)
|
||||||
|
2. Copy your **Publishable key** (starts with `pk_test_`)
|
||||||
|
3. Copy your **Secret key** (starts with `sk_test_`)
|
||||||
|
|
||||||
|
### Live Keys (Production)
|
||||||
|
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)
|
||||||
|
2. Copy your **Publishable key** (starts with `pk_live_`)
|
||||||
|
3. Copy your **Secret key** (starts with `sk_live_`)
|
||||||
|
|
||||||
|
⚠️ **Never commit live keys to version control!**
|
||||||
|
|
||||||
|
## 🔒 Security Best Practices
|
||||||
|
|
||||||
|
1. **Never commit sensitive keys** - Use `.gitignore` to exclude:
|
||||||
|
- `.env`
|
||||||
|
- `config/local.toml`
|
||||||
|
- `config/production.toml`
|
||||||
|
|
||||||
|
2. **Use test keys in development** - Test keys are safe and don't process real payments
|
||||||
|
|
||||||
|
3. **Use environment variables in production** - More secure than config files
|
||||||
|
|
||||||
|
4. **Rotate keys regularly** - Generate new keys periodically
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
1. **Copy the example files:**
|
||||||
|
```bash
|
||||||
|
cp config/local.toml.example config/local.toml
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add your Stripe test keys** to either file
|
||||||
|
|
||||||
|
3. **Start the application:**
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test the payment flow** at `http://127.0.0.1:9999/company`
|
||||||
|
|
||||||
|
## 📋 Configuration Priority
|
||||||
|
|
||||||
|
The application loads configuration in this order (later overrides earlier):
|
||||||
|
1. Default values in code
|
||||||
|
2. `config/default.toml`
|
||||||
|
3. `config/local.toml`
|
||||||
|
4. Environment variables
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
- **Keys not working?** Check the Stripe Dashboard for correct keys
|
||||||
|
- **Webhook errors?** Ensure webhook secret matches your Stripe endpoint
|
||||||
|
- **Configuration not loading?** Check file paths and environment variable names
|
||||||
17
actix_mvc_app/config/default.toml
Normal file
17
actix_mvc_app/config/default.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Default configuration for the application
|
||||||
|
# This file contains safe defaults and test keys
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 9999
|
||||||
|
# workers = 4 # Uncomment to set specific number of workers
|
||||||
|
|
||||||
|
[templates]
|
||||||
|
dir = "./src/views"
|
||||||
|
|
||||||
|
[stripe]
|
||||||
|
# Stripe Test Keys (Safe for development)
|
||||||
|
# These are test keys from Stripe's documentation - they don't process real payments
|
||||||
|
publishable_key = "pk_test_51RdWkUC6v6GB0mBYmMbmKyXQfeRX0obM0V5rQCFGT35A1EP8WQJ5xw2vuWurqeGjdwaxls0B8mqdYpGSHcOlYOtQ000BvLkKCq"
|
||||||
|
secret_key = "sk_test_51RdWkUC6v6GB0mBYbbs4RULaNRq9CzqV88pM1EMU9dJ9TAj8obLAFsvfGWPq4Ed8nL36kbE7vK2oHvAQ35UrlJm100FlecQxmN"
|
||||||
|
# webhook_secret = "whsec_test_..." # Uncomment and set when setting up webhooks
|
||||||
18
actix_mvc_app/config/local.toml.example
Normal file
18
actix_mvc_app/config/local.toml.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Local configuration template
|
||||||
|
# Copy this file to 'local.toml' and customize with your own keys
|
||||||
|
# This file should NOT be committed to version control
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# host = "0.0.0.0" # Uncomment to bind to all interfaces
|
||||||
|
# port = 8080 # Uncomment to use different port
|
||||||
|
|
||||||
|
[stripe]
|
||||||
|
# Replace with your own Stripe test keys from https://dashboard.stripe.com/test/apikeys
|
||||||
|
# publishable_key = "pk_test_YOUR_PUBLISHABLE_KEY_HERE"
|
||||||
|
# secret_key = "sk_test_YOUR_SECRET_KEY_HERE"
|
||||||
|
# webhook_secret = "whsec_YOUR_WEBHOOK_SECRET_HERE"
|
||||||
|
|
||||||
|
# For production, use live keys:
|
||||||
|
# publishable_key = "pk_live_YOUR_LIVE_PUBLISHABLE_KEY"
|
||||||
|
# secret_key = "sk_live_YOUR_LIVE_SECRET_KEY"
|
||||||
|
# webhook_secret = "whsec_YOUR_LIVE_WEBHOOK_SECRET"
|
||||||
170
actix_mvc_app/docker-compose.prod.yml
Normal file
170
actix_mvc_app/docker-compose.prod.yml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.prod
|
||||||
|
container_name: freezone-registration-app
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- RUST_ENV=production
|
||||||
|
- RUST_LOG=info
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: freezone-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||||
|
environment:
|
||||||
|
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: freezone-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./db/init:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: freezone-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
|
- ./static:/var/www/static:ro
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: freezone-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
||||||
|
- '--web.console.templates=/etc/prometheus/consoles'
|
||||||
|
- '--storage.tsdb.retention.time=200h'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: freezone-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||||
|
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:latest
|
||||||
|
container_name: freezone-loki
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/loki.yml:/etc/loki/local-config.yaml:ro
|
||||||
|
- loki_data:/loki
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:latest
|
||||||
|
container_name: freezone-promtail
|
||||||
|
restart: unless-stopped
|
||||||
|
command: -config.file=/etc/promtail/config.yml
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/promtail.yml:/etc/promtail/config.yml:ro
|
||||||
|
- ./logs:/var/log/app:ro
|
||||||
|
- /var/log:/var/log/host:ro
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
networks:
|
||||||
|
- freezone-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
prometheus_data:
|
||||||
|
driver: local
|
||||||
|
grafana_data:
|
||||||
|
driver: local
|
||||||
|
loki_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
freezone-network:
|
||||||
|
driver: bridge
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
- login
|
|
||||||
- change profile (email(s), linkedin, websites, telnr's, ...)
|
|
||||||
- KYC
|
|
||||||
- wallet
|
|
||||||
- with EUR/CHF/USD, ability to topup wallet... (stripe, make sure to describe well)
|
|
||||||
- with real world digital assets (RWDA)
|
|
||||||
- the RWDA can be transfered to someone else if allowed
|
|
||||||
- tickets (see own tickets)
|
|
||||||
- a ticket can have workflow attached to it (as set by rhai script)
|
|
||||||
- on a workflow step needs to be clear for user if they need to do something
|
|
||||||
- has type, subject, priority, level, comments (user can add comment)
|
|
||||||
- if new comment message is sent to inbox as well, if new action needed as well
|
|
||||||
- inbox
|
|
||||||
- info we can send to the user, user can reply
|
|
||||||
- has labels
|
|
||||||
- actions
|
|
||||||
- if something to do for user, is part of the workflow
|
|
||||||
- RWDA'S
|
|
||||||
- overview (in tiles like blogs) of the RWDA's, has tags user can filter
|
|
||||||
- if user clicks on one then goes to mini site (like ebook), is shown in app
|
|
||||||
- on RWDA we see nr of RWDA (marketcap, ... and other core financials, ...)
|
|
||||||
- use can select RWDA, and buy into it, if not enough cash will be asked to put more cash in
|
|
||||||
- contracts
|
|
||||||
- as markdown
|
|
||||||
- user can sign
|
|
||||||
- see who signed
|
|
||||||
|
|
||||||
# user flows
|
|
||||||
|
|
||||||
## registration
|
|
||||||
|
|
||||||
- login, user choses secret (done by means of the webassembly component)
|
|
||||||
- verification level, user can do KYC
|
|
||||||
|
|
||||||
|
|
||||||
# rwda
|
|
||||||
|
|
||||||
- name
|
|
||||||
- description
|
|
||||||
- link to website
|
|
||||||
- nr of shares
|
|
||||||
- share value
|
|
||||||
- vesting period, lockin period
|
|
||||||
- symbol
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Dynex
|
|
||||||
|
|
||||||
- meeting with
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::env;
|
|
||||||
use config::{Config, ConfigError, File};
|
use config::{Config, ConfigError, File};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
/// Application configuration
|
/// Application configuration
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -9,10 +9,13 @@ pub struct AppConfig {
|
|||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
/// Template configuration
|
/// Template configuration
|
||||||
pub templates: TemplateConfig,
|
pub templates: TemplateConfig,
|
||||||
|
/// Stripe configuration
|
||||||
|
pub stripe: StripeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server configuration
|
/// Server configuration
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
/// Host address to bind to
|
/// Host address to bind to
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -29,6 +32,17 @@ pub struct TemplateConfig {
|
|||||||
pub dir: String,
|
pub dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stripe configuration
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct StripeConfig {
|
||||||
|
/// Stripe publishable key
|
||||||
|
pub publishable_key: String,
|
||||||
|
/// Stripe secret key
|
||||||
|
pub secret_key: String,
|
||||||
|
/// Webhook endpoint secret
|
||||||
|
pub webhook_secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
/// Loads configuration from files and environment variables
|
/// Loads configuration from files and environment variables
|
||||||
pub fn new() -> Result<Self, ConfigError> {
|
pub fn new() -> Result<Self, ConfigError> {
|
||||||
@@ -37,7 +51,10 @@ impl AppConfig {
|
|||||||
.set_default("server.host", "127.0.0.1")?
|
.set_default("server.host", "127.0.0.1")?
|
||||||
.set_default("server.port", 9999)?
|
.set_default("server.port", 9999)?
|
||||||
.set_default("server.workers", None::<u32>)?
|
.set_default("server.workers", None::<u32>)?
|
||||||
.set_default("templates.dir", "./src/views")?;
|
.set_default("templates.dir", "./src/views")?
|
||||||
|
.set_default("stripe.publishable_key", "")?
|
||||||
|
.set_default("stripe.secret_key", "")?
|
||||||
|
.set_default("stripe.webhook_secret", None::<String>)?;
|
||||||
|
|
||||||
// Load from config file if it exists
|
// Load from config file if it exists
|
||||||
if let Ok(config_path) = env::var("APP_CONFIG") {
|
if let Ok(config_path) = env::var("APP_CONFIG") {
|
||||||
@@ -50,7 +67,8 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
|
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
|
||||||
config_builder = config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
|
config_builder =
|
||||||
|
config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
|
||||||
|
|
||||||
// Build and deserialize the config
|
// Build and deserialize the config
|
||||||
let config = config_builder.build()?;
|
let config = config_builder.build()?;
|
||||||
|
|||||||
3
actix_mvc_app/src/content/contract-003/1-purpose.md
Normal file
3
actix_mvc_app/src/content/contract-003/1-purpose.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
The purpose of this Agreement is to establish the terms and conditions for tokenizing real estate assets on the Zanzibar blockchain network.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## 2. Tokenization Process
|
||||||
|
|
||||||
|
Tokenizer shall create digital tokens representing ownership interests in the properties listed in Appendix A according to the specifications in Appendix B.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## 3. Revenue Sharing
|
||||||
|
|
||||||
|
Revenue generated from the tokenized properties shall be distributed according to the formula set forth in Appendix C.
|
||||||
3
actix_mvc_app/src/content/contract-003/4-governance.md
Normal file
3
actix_mvc_app/src/content/contract-003/4-governance.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## 4. Governance
|
||||||
|
|
||||||
|
Decisions regarding the management of tokenized properties shall be made according to the governance framework outlined in Appendix D.
|
||||||
3
actix_mvc_app/src/content/contract-003/appendix-a.md
Normal file
3
actix_mvc_app/src/content/contract-003/appendix-a.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Appendix A: Properties
|
||||||
|
|
||||||
|
List of properties to be tokenized.
|
||||||
3
actix_mvc_app/src/content/contract-003/appendix-b.md
Normal file
3
actix_mvc_app/src/content/contract-003/appendix-b.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Appendix B: Specifications
|
||||||
|
|
||||||
|
Technical specifications for tokenization.
|
||||||
3
actix_mvc_app/src/content/contract-003/appendix-c.md
Normal file
3
actix_mvc_app/src/content/contract-003/appendix-c.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Appendix C: Revenue Formula
|
||||||
|
|
||||||
|
Formula for revenue distribution.
|
||||||
3
actix_mvc_app/src/content/contract-003/appendix-d.md
Normal file
3
actix_mvc_app/src/content/contract-003/appendix-d.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Appendix D: Governance Framework
|
||||||
|
|
||||||
|
Governance framework for tokenized properties.
|
||||||
3
actix_mvc_app/src/content/contract-003/cover.md
Normal file
3
actix_mvc_app/src/content/contract-003/cover.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Digital Asset Tokenization Agreement
|
||||||
|
|
||||||
|
This Digital Asset Tokenization Agreement (the "Agreement") is entered into between Zanzibar Property Consortium ("Tokenizer") and the property owners listed in Appendix A ("Owners").
|
||||||
1001
actix_mvc_app/src/controllers/asset.rs
Normal file
1001
actix_mvc_app/src/controllers/asset.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ use actix_web::{web, HttpResponse, Responder, Result, http::header, cookie::Cook
|
|||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
use tera::Tera;
|
use tera::Tera;
|
||||||
use crate::models::user::{User, LoginCredentials, RegistrationData, UserRole};
|
use crate::models::user::{User, LoginCredentials, RegistrationData, UserRole};
|
||||||
|
use crate::utils::render_template;
|
||||||
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
|
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use chrono::{Utc, Duration};
|
use chrono::{Utc, Duration};
|
||||||
@@ -24,6 +25,7 @@ lazy_static! {
|
|||||||
/// Controller for handling authentication-related routes
|
/// Controller for handling authentication-related routes
|
||||||
pub struct AuthController;
|
pub struct AuthController;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl AuthController {
|
impl AuthController {
|
||||||
/// Generate a JWT token for a user
|
/// Generate a JWT token for a user
|
||||||
fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> {
|
fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
@@ -91,13 +93,7 @@ impl AuthController {
|
|||||||
let mut ctx = tera::Context::new();
|
let mut ctx = tera::Context::new();
|
||||||
ctx.insert("active_page", "login");
|
ctx.insert("active_page", "login");
|
||||||
|
|
||||||
let rendered = tmpl.render("auth/login.html", &ctx)
|
render_template(&tmpl, "auth/login.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles user login
|
/// Handles user login
|
||||||
@@ -146,13 +142,7 @@ impl AuthController {
|
|||||||
let mut ctx = tera::Context::new();
|
let mut ctx = tera::Context::new();
|
||||||
ctx.insert("active_page", "register");
|
ctx.insert("active_page", "register");
|
||||||
|
|
||||||
let rendered = tmpl.render("auth/register.html", &ctx)
|
render_template(&tmpl, "auth/register.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles user registration
|
/// Handles user registration
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
use actix_web::{web, HttpResponse, Responder, Result};
|
|
||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
|
use actix_web::{HttpResponse, Responder, Result, web};
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tera::Tera;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use tera::Tera;
|
||||||
|
|
||||||
use crate::models::{CalendarEvent, CalendarViewMode};
|
use crate::db::calendar::{
|
||||||
use crate::utils::RedisCalendarService;
|
add_event_to_calendar, create_new_event, delete_event, get_events, get_or_create_user_calendar,
|
||||||
|
};
|
||||||
|
use crate::models::CalendarViewMode;
|
||||||
|
use crate::utils::render_template;
|
||||||
|
use heromodels::models::calendar::Event;
|
||||||
|
use heromodels_core::Model;
|
||||||
|
|
||||||
/// Controller for handling calendar-related routes
|
/// Controller for handling calendar-related routes
|
||||||
pub struct CalendarController;
|
pub struct CalendarController;
|
||||||
@@ -14,9 +19,11 @@ pub struct CalendarController;
|
|||||||
impl CalendarController {
|
impl CalendarController {
|
||||||
/// Helper function to get user from session
|
/// Helper function to get user from session
|
||||||
fn get_user_from_session(session: &Session) -> Option<Value> {
|
fn get_user_from_session(session: &Session) -> Option<Value> {
|
||||||
session.get::<String>("user").ok().flatten().and_then(|user_json| {
|
session
|
||||||
serde_json::from_str(&user_json).ok()
|
.get::<String>("user")
|
||||||
})
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|user_json| serde_json::from_str(&user_json).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the calendar page route
|
/// Handles the calendar page route
|
||||||
@@ -29,13 +36,16 @@ impl CalendarController {
|
|||||||
ctx.insert("active_page", "calendar");
|
ctx.insert("active_page", "calendar");
|
||||||
|
|
||||||
// Parse the view mode from the query parameters
|
// Parse the view mode from the query parameters
|
||||||
let view_mode = CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string()));
|
let view_mode =
|
||||||
|
CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string()));
|
||||||
ctx.insert("view_mode", &view_mode.to_str());
|
ctx.insert("view_mode", &view_mode.to_str());
|
||||||
|
|
||||||
// Parse the date from the query parameters or use the current date
|
// Parse the date from the query parameters or use the current date
|
||||||
let date = if let Some(date_str) = &query.date {
|
let date = if let Some(date_str) = &query.date {
|
||||||
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||||
Ok(naive_date) => Utc.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap()).into(),
|
Ok(naive_date) => Utc
|
||||||
|
.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap())
|
||||||
|
.into(),
|
||||||
Err(_) => Utc::now(),
|
Err(_) => Utc::now(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -47,44 +57,99 @@ impl CalendarController {
|
|||||||
ctx.insert("current_month", &date.month());
|
ctx.insert("current_month", &date.month());
|
||||||
ctx.insert("current_day", &date.day());
|
ctx.insert("current_day", &date.day());
|
||||||
|
|
||||||
// Add user to context if available
|
// Add user to context if available and ensure user has a calendar
|
||||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
|
|
||||||
|
// Get or create user calendar
|
||||||
|
if let (Some(user_id), Some(user_name)) = (
|
||||||
|
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||||
|
user.get("full_name").and_then(|v| v.as_str()),
|
||||||
|
) {
|
||||||
|
match get_or_create_user_calendar(user_id, user_name) {
|
||||||
|
Ok(calendar) => {
|
||||||
|
log::info!(
|
||||||
|
"User calendar ready: ID {}, Name: '{}'",
|
||||||
|
calendar.get_id(),
|
||||||
|
calendar.name
|
||||||
|
);
|
||||||
|
ctx.insert("user_calendar", &calendar);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get or create user calendar: {}", e);
|
||||||
|
// Continue without calendar - the app should still work
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get events for the current view
|
// Get events for the current view
|
||||||
let (start_date, end_date) = match view_mode {
|
let (start_date, end_date) = match view_mode {
|
||||||
CalendarViewMode::Year => {
|
CalendarViewMode::Year => {
|
||||||
let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap();
|
let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap();
|
||||||
let end = Utc.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59).unwrap();
|
let end = Utc
|
||||||
|
.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59)
|
||||||
|
.unwrap();
|
||||||
(start, end)
|
(start, end)
|
||||||
},
|
}
|
||||||
CalendarViewMode::Month => {
|
CalendarViewMode::Month => {
|
||||||
let start = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap();
|
let start = Utc
|
||||||
|
.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0)
|
||||||
|
.unwrap();
|
||||||
let last_day = Self::last_day_of_month(date.year(), date.month());
|
let last_day = Self::last_day_of_month(date.year(), date.month());
|
||||||
let end = Utc.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59).unwrap();
|
let end = Utc
|
||||||
|
.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59)
|
||||||
|
.unwrap();
|
||||||
(start, end)
|
(start, end)
|
||||||
},
|
}
|
||||||
CalendarViewMode::Week => {
|
CalendarViewMode::Week => {
|
||||||
// Calculate the start of the week (Sunday)
|
// Calculate the start of the week (Sunday)
|
||||||
let _weekday = date.weekday().num_days_from_sunday();
|
let _weekday = date.weekday().num_days_from_sunday();
|
||||||
let start_date = date.date_naive().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap();
|
let start_date = date
|
||||||
|
.date_naive()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap();
|
||||||
let start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap());
|
let start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap());
|
||||||
let end = start + chrono::Duration::days(7);
|
let end = start + chrono::Duration::days(7);
|
||||||
(start, end)
|
(start, end)
|
||||||
},
|
}
|
||||||
CalendarViewMode::Day => {
|
CalendarViewMode::Day => {
|
||||||
let start = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0).unwrap();
|
let start = Utc
|
||||||
let end = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59).unwrap();
|
.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
let end = Utc
|
||||||
|
.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59)
|
||||||
|
.unwrap();
|
||||||
(start, end)
|
(start, end)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get events from Redis
|
// Get events from database
|
||||||
let events = match RedisCalendarService::get_events_in_range(start_date, end_date) {
|
let events = match get_events() {
|
||||||
Ok(events) => events,
|
Ok(db_events) => {
|
||||||
|
// Filter events for the date range
|
||||||
|
db_events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|event| {
|
||||||
|
// Event overlaps with the date range
|
||||||
|
event.start_time < end_date && event.end_time > start_date
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to get events from Redis: {}", e);
|
log::error!("Failed to get events from database: {}", e);
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -94,42 +159,47 @@ impl CalendarController {
|
|||||||
// Generate calendar data based on the view mode
|
// Generate calendar data based on the view mode
|
||||||
match view_mode {
|
match view_mode {
|
||||||
CalendarViewMode::Year => {
|
CalendarViewMode::Year => {
|
||||||
let months = (1..=12).map(|month| {
|
let months = (1..=12)
|
||||||
let month_name = match month {
|
.map(|month| {
|
||||||
1 => "January",
|
let month_name = match month {
|
||||||
2 => "February",
|
1 => "January",
|
||||||
3 => "March",
|
2 => "February",
|
||||||
4 => "April",
|
3 => "March",
|
||||||
5 => "May",
|
4 => "April",
|
||||||
6 => "June",
|
5 => "May",
|
||||||
7 => "July",
|
6 => "June",
|
||||||
8 => "August",
|
7 => "July",
|
||||||
9 => "September",
|
8 => "August",
|
||||||
10 => "October",
|
9 => "September",
|
||||||
11 => "November",
|
10 => "October",
|
||||||
12 => "December",
|
11 => "November",
|
||||||
_ => "",
|
12 => "December",
|
||||||
};
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
let month_events = events.iter()
|
let month_events = events
|
||||||
.filter(|event| {
|
.iter()
|
||||||
event.start_time.month() == month || event.end_time.month() == month
|
.filter(|event| {
|
||||||
})
|
event.start_time.month() == month || event.end_time.month() == month
|
||||||
.cloned()
|
})
|
||||||
.collect::<Vec<_>>();
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
CalendarMonth {
|
CalendarMonth {
|
||||||
month,
|
month,
|
||||||
name: month_name.to_string(),
|
name: month_name.to_string(),
|
||||||
events: month_events,
|
events: month_events,
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>();
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
ctx.insert("months", &months);
|
ctx.insert("months", &months);
|
||||||
},
|
}
|
||||||
CalendarViewMode::Month => {
|
CalendarViewMode::Month => {
|
||||||
let days_in_month = Self::last_day_of_month(date.year(), date.month());
|
let days_in_month = Self::last_day_of_month(date.year(), date.month());
|
||||||
let first_day = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap();
|
let first_day = Utc
|
||||||
|
.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0)
|
||||||
|
.unwrap();
|
||||||
let first_weekday = first_day.weekday().num_days_from_sunday();
|
let first_weekday = first_day.weekday().num_days_from_sunday();
|
||||||
|
|
||||||
let mut calendar_days = Vec::new();
|
let mut calendar_days = Vec::new();
|
||||||
@@ -145,13 +215,20 @@ impl CalendarController {
|
|||||||
|
|
||||||
// Add days for the current month
|
// Add days for the current month
|
||||||
for day in 1..=days_in_month {
|
for day in 1..=days_in_month {
|
||||||
let day_events = events.iter()
|
let day_events = events
|
||||||
|
.iter()
|
||||||
.filter(|event| {
|
.filter(|event| {
|
||||||
let day_start = Utc.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0).unwrap();
|
let day_start = Utc
|
||||||
let day_end = Utc.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59).unwrap();
|
.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
let day_end = Utc
|
||||||
|
.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
(event.start_time <= day_end && event.end_time >= day_start) ||
|
(event.start_time <= day_end && event.end_time >= day_start)
|
||||||
(event.all_day && event.start_time.day() <= day && event.end_time.day() >= day)
|
|| (event.all_day
|
||||||
|
&& event.start_time.day() <= day
|
||||||
|
&& event.end_time.day() >= day)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -175,7 +252,7 @@ impl CalendarController {
|
|||||||
|
|
||||||
ctx.insert("calendar_days", &calendar_days);
|
ctx.insert("calendar_days", &calendar_days);
|
||||||
ctx.insert("month_name", &Self::month_name(date.month()));
|
ctx.insert("month_name", &Self::month_name(date.month()));
|
||||||
},
|
}
|
||||||
CalendarViewMode::Week => {
|
CalendarViewMode::Week => {
|
||||||
// Calculate the start of the week (Sunday)
|
// Calculate the start of the week (Sunday)
|
||||||
let weekday = date.weekday().num_days_from_sunday();
|
let weekday = date.weekday().num_days_from_sunday();
|
||||||
@@ -184,13 +261,34 @@ impl CalendarController {
|
|||||||
let mut week_days = Vec::new();
|
let mut week_days = Vec::new();
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
let day_date = week_start + chrono::Duration::days(i);
|
let day_date = week_start + chrono::Duration::days(i);
|
||||||
let day_events = events.iter()
|
let day_events = events
|
||||||
|
.iter()
|
||||||
.filter(|event| {
|
.filter(|event| {
|
||||||
let day_start = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), day_date.day(), 0, 0, 0).unwrap();
|
let day_start = Utc
|
||||||
let day_end = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), day_date.day(), 23, 59, 59).unwrap();
|
.with_ymd_and_hms(
|
||||||
|
day_date.year(),
|
||||||
|
day_date.month(),
|
||||||
|
day_date.day(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let day_end = Utc
|
||||||
|
.with_ymd_and_hms(
|
||||||
|
day_date.year(),
|
||||||
|
day_date.month(),
|
||||||
|
day_date.day(),
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
(event.start_time <= day_end && event.end_time >= day_start) ||
|
(event.start_time <= day_end && event.end_time >= day_start)
|
||||||
(event.all_day && event.start_time.day() <= day_date.day() && event.end_time.day() >= day_date.day())
|
|| (event.all_day
|
||||||
|
&& event.start_time.day() <= day_date.day()
|
||||||
|
&& event.end_time.day() >= day_date.day())
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -203,25 +301,25 @@ impl CalendarController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.insert("week_days", &week_days);
|
ctx.insert("week_days", &week_days);
|
||||||
},
|
}
|
||||||
CalendarViewMode::Day => {
|
CalendarViewMode::Day => {
|
||||||
log::info!("Day view selected");
|
log::info!("Day view selected");
|
||||||
ctx.insert("day_name", &Self::day_name(date.weekday().num_days_from_sunday()));
|
ctx.insert(
|
||||||
|
"day_name",
|
||||||
|
&Self::day_name(date.weekday().num_days_from_sunday()),
|
||||||
|
);
|
||||||
|
|
||||||
// Add debug info
|
// Add debug info
|
||||||
log::info!("Events count: {}", events.len());
|
log::info!("Events count: {}", events.len());
|
||||||
log::info!("Current date: {}", date.format("%Y-%m-%d"));
|
log::info!("Current date: {}", date.format("%Y-%m-%d"));
|
||||||
log::info!("Day name: {}", Self::day_name(date.weekday().num_days_from_sunday()));
|
log::info!(
|
||||||
},
|
"Day name: {}",
|
||||||
|
Self::day_name(date.weekday().num_days_from_sunday())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("calendar/index.html", &ctx)
|
render_template(&tmpl, "calendar/index.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the new event page route
|
/// Handles the new event page route
|
||||||
@@ -229,18 +327,27 @@ impl CalendarController {
|
|||||||
let mut ctx = tera::Context::new();
|
let mut ctx = tera::Context::new();
|
||||||
ctx.insert("active_page", "calendar");
|
ctx.insert("active_page", "calendar");
|
||||||
|
|
||||||
// Add user to context if available
|
// Add user to context if available and ensure user has a calendar
|
||||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
if let Some(user) = Self::get_user_from_session(&_session) {
|
||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
|
|
||||||
|
// Get or create user calendar
|
||||||
|
if let (Some(user_id), Some(user_name)) = (
|
||||||
|
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||||
|
user.get("full_name").and_then(|v| v.as_str()),
|
||||||
|
) {
|
||||||
|
match get_or_create_user_calendar(user_id, user_name) {
|
||||||
|
Ok(calendar) => {
|
||||||
|
ctx.insert("user_calendar", &calendar);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get or create user calendar: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("calendar/new_event.html", &ctx)
|
render_template(&tmpl, "calendar/new_event.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the create event route
|
/// Handles the create event route
|
||||||
@@ -249,44 +356,91 @@ impl CalendarController {
|
|||||||
tmpl: web::Data<Tera>,
|
tmpl: web::Data<Tera>,
|
||||||
_session: Session,
|
_session: Session,
|
||||||
) -> Result<impl Responder> {
|
) -> Result<impl Responder> {
|
||||||
|
// Log the form data for debugging
|
||||||
|
log::info!(
|
||||||
|
"Creating event with form data: title='{}', start_time='{}', end_time='{}', all_day={}",
|
||||||
|
form.title,
|
||||||
|
form.start_time,
|
||||||
|
form.end_time,
|
||||||
|
form.all_day
|
||||||
|
);
|
||||||
|
|
||||||
// Parse the start and end times
|
// Parse the start and end times
|
||||||
let start_time = match DateTime::parse_from_rfc3339(&form.start_time) {
|
let start_time = match DateTime::parse_from_rfc3339(&form.start_time) {
|
||||||
Ok(dt) => dt.with_timezone(&Utc),
|
Ok(dt) => dt.with_timezone(&Utc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse start time: {}", e);
|
log::error!("Failed to parse start time '{}': {}", form.start_time, e);
|
||||||
return Ok(HttpResponse::BadRequest().body("Invalid start time"));
|
return Ok(HttpResponse::BadRequest().body("Invalid start time format"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_time = match DateTime::parse_from_rfc3339(&form.end_time) {
|
let end_time = match DateTime::parse_from_rfc3339(&form.end_time) {
|
||||||
Ok(dt) => dt.with_timezone(&Utc),
|
Ok(dt) => dt.with_timezone(&Utc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse end time: {}", e);
|
log::error!("Failed to parse end time '{}': {}", form.end_time, e);
|
||||||
return Ok(HttpResponse::BadRequest().body("Invalid end time"));
|
return Ok(HttpResponse::BadRequest().body("Invalid end time format"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the event
|
// Get user information from session
|
||||||
let event = CalendarEvent::new(
|
let user_info = Self::get_user_from_session(&_session);
|
||||||
form.title.clone(),
|
let (user_id, user_name) = if let Some(user) = &user_info {
|
||||||
form.description.clone(),
|
let id = user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||||
|
let name = user
|
||||||
|
.get("full_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown User");
|
||||||
|
log::info!("User from session: id={:?}, name='{}'", id, name);
|
||||||
|
(id, name)
|
||||||
|
} else {
|
||||||
|
log::warn!("No user found in session");
|
||||||
|
(None, "Unknown User")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the event in the database
|
||||||
|
match create_new_event(
|
||||||
|
&form.title,
|
||||||
|
Some(&form.description),
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
Some(form.color.clone()),
|
None, // location
|
||||||
|
Some(&form.color),
|
||||||
form.all_day,
|
form.all_day,
|
||||||
None, // User ID would come from session in a real app
|
user_id,
|
||||||
);
|
None, // category
|
||||||
|
None, // reminder_minutes
|
||||||
|
) {
|
||||||
|
Ok((event_id, _saved_event)) => {
|
||||||
|
log::info!("Created event with ID: {}", event_id);
|
||||||
|
|
||||||
|
// If user is logged in, add the event to their calendar
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
match get_or_create_user_calendar(user_id, user_name) {
|
||||||
|
Ok(calendar) => match add_event_to_calendar(calendar.get_id(), event_id) {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!(
|
||||||
|
"Added event {} to calendar {}",
|
||||||
|
event_id,
|
||||||
|
calendar.get_id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to add event to calendar: {}", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get user calendar: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save the event to Redis
|
|
||||||
match RedisCalendarService::save_event(&event) {
|
|
||||||
Ok(_) => {
|
|
||||||
// Redirect to the calendar page
|
// Redirect to the calendar page
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header(("Location", "/calendar"))
|
.append_header(("Location", "/calendar"))
|
||||||
.finish())
|
.finish())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to save event to Redis: {}", e);
|
log::error!("Failed to save event to database: {}", e);
|
||||||
|
|
||||||
// Show an error message
|
// Show an error message
|
||||||
let mut ctx = tera::Context::new();
|
let mut ctx = tera::Context::new();
|
||||||
@@ -294,17 +448,15 @@ impl CalendarController {
|
|||||||
ctx.insert("error", "Failed to save event");
|
ctx.insert("error", "Failed to save event");
|
||||||
|
|
||||||
// Add user to context if available
|
// Add user to context if available
|
||||||
if let Some(user) = Self::get_user_from_session(&_session) {
|
if let Some(user) = user_info {
|
||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("calendar/new_event.html", &ctx)
|
let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?;
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::InternalServerError().content_type("text/html").body(rendered))
|
Ok(HttpResponse::InternalServerError()
|
||||||
|
.content_type("text/html")
|
||||||
|
.body(result.into_body()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,16 +468,26 @@ impl CalendarController {
|
|||||||
) -> Result<impl Responder> {
|
) -> Result<impl Responder> {
|
||||||
let id = path.into_inner();
|
let id = path.into_inner();
|
||||||
|
|
||||||
// Delete the event from Redis
|
// Parse the event ID
|
||||||
match RedisCalendarService::delete_event(&id) {
|
let event_id = match id.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
log::error!("Invalid event ID: {}", id);
|
||||||
|
return Ok(HttpResponse::BadRequest().body("Invalid event ID"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete the event from database
|
||||||
|
match delete_event(event_id) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
log::info!("Deleted event with ID: {}", event_id);
|
||||||
// Redirect to the calendar page
|
// Redirect to the calendar page
|
||||||
Ok(HttpResponse::SeeOther()
|
Ok(HttpResponse::SeeOther()
|
||||||
.append_header(("Location", "/calendar"))
|
.append_header(("Location", "/calendar"))
|
||||||
.finish())
|
.finish())
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to delete event from Redis: {}", e);
|
log::error!("Failed to delete event from database: {}", e);
|
||||||
Ok(HttpResponse::InternalServerError().body("Failed to delete event"))
|
Ok(HttpResponse::InternalServerError().body("Failed to delete event"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,7 +504,7 @@ impl CalendarController {
|
|||||||
} else {
|
} else {
|
||||||
28
|
28
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
_ => 30, // Default to 30 days
|
_ => 30, // Default to 30 days
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,7 +565,7 @@ pub struct EventForm {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct CalendarDay {
|
struct CalendarDay {
|
||||||
day: u32,
|
day: u32,
|
||||||
events: Vec<CalendarEvent>,
|
events: Vec<Event>,
|
||||||
is_current_month: bool,
|
is_current_month: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,5 +574,5 @@ struct CalendarDay {
|
|||||||
struct CalendarMonth {
|
struct CalendarMonth {
|
||||||
month: u32,
|
month: u32,
|
||||||
name: String,
|
name: String,
|
||||||
events: Vec<CalendarEvent>,
|
events: Vec<Event>,
|
||||||
}
|
}
|
||||||
671
actix_mvc_app/src/controllers/company.rs
Normal file
671
actix_mvc_app/src/controllers/company.rs
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
use crate::config::get_config;
|
||||||
|
use crate::controllers::error::render_company_not_found;
|
||||||
|
use crate::db::company::*;
|
||||||
|
use crate::db::document::*;
|
||||||
|
use crate::models::document::DocumentType;
|
||||||
|
use crate::utils::render_template;
|
||||||
|
use actix_web::HttpRequest;
|
||||||
|
use actix_web::{HttpResponse, Result, web};
|
||||||
|
|
||||||
|
use heromodels::models::biz::{BusinessType, CompanyStatus};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs;
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
|
// Form structs for company operations
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct CompanyRegistrationForm {
|
||||||
|
pub company_name: String,
|
||||||
|
pub company_type: String,
|
||||||
|
pub shareholders: String,
|
||||||
|
pub company_purpose: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CompanyEditForm {
|
||||||
|
pub company_name: String,
|
||||||
|
pub company_type: 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 status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CompanyController;
|
||||||
|
|
||||||
|
impl CompanyController {
|
||||||
|
// Display the company management dashboard
|
||||||
|
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
|
||||||
|
let mut context = Context::new();
|
||||||
|
let config = get_config();
|
||||||
|
|
||||||
|
// Add active_page for navigation highlighting
|
||||||
|
context.insert("active_page", &"company");
|
||||||
|
|
||||||
|
// Add Stripe configuration for payment processing
|
||||||
|
context.insert("stripe_publishable_key", &config.stripe.publishable_key);
|
||||||
|
|
||||||
|
// Load companies from database
|
||||||
|
let companies = match get_companies() {
|
||||||
|
Ok(companies) => companies,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get companies from database: {}", e);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
context.insert("companies", &companies);
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
let query_string = req.query_string();
|
||||||
|
|
||||||
|
// Check for success message
|
||||||
|
if let Some(pos) = query_string.find("success=") {
|
||||||
|
let start = pos + 8; // length of "success="
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let success = &query_string[start..end];
|
||||||
|
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
||||||
|
context.insert("success", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for entity context
|
||||||
|
if let Some(pos) = query_string.find("entity=") {
|
||||||
|
let start = pos + 7; // length of "entity="
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let entity = &query_string[start..end];
|
||||||
|
context.insert("entity", &entity);
|
||||||
|
|
||||||
|
// Also get entity name if present
|
||||||
|
if let Some(pos) = query_string.find("entity_name=") {
|
||||||
|
let start = pos + 12; // length of "entity_name="
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let entity_name = &query_string[start..end];
|
||||||
|
let decoded_name =
|
||||||
|
urlencoding::decode(entity_name).unwrap_or_else(|_| entity_name.into());
|
||||||
|
context.insert("entity_name", &decoded_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_template(&tmpl, "company/index.html", &context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display company edit form
|
||||||
|
pub async fn edit_form(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
req: HttpRequest,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let company_id_str = path.into_inner();
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
// Add active_page for navigation highlighting
|
||||||
|
context.insert("active_page", &"company");
|
||||||
|
|
||||||
|
// Parse query parameters for success/error messages
|
||||||
|
let query_string = req.query_string();
|
||||||
|
|
||||||
|
// Check for success message
|
||||||
|
if let Some(pos) = query_string.find("success=") {
|
||||||
|
let start = pos + 8; // length of "success="
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let success = &query_string[start..end];
|
||||||
|
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
||||||
|
context.insert("success", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error message
|
||||||
|
if let Some(pos) = query_string.find("error=") {
|
||||||
|
let start = pos + 6; // length of "error="
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let error = &query_string[start..end];
|
||||||
|
let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
|
||||||
|
context.insert("error", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse company ID
|
||||||
|
let company_id = match company_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch company from database
|
||||||
|
if let Ok(Some(company)) = get_company_by_id(company_id) {
|
||||||
|
context.insert("company", &company);
|
||||||
|
|
||||||
|
// Format timestamps for display
|
||||||
|
let incorporation_date =
|
||||||
|
chrono::DateTime::from_timestamp(company.incorporation_date, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
context.insert("incorporation_date_formatted", &incorporation_date);
|
||||||
|
|
||||||
|
render_template(&tmpl, "company/edit.html", &context)
|
||||||
|
} else {
|
||||||
|
render_company_not_found(&tmpl, Some(&company_id_str)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View company details
|
||||||
|
pub async fn view_company(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
req: HttpRequest,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let company_id_str = path.into_inner();
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
// Add active_page for navigation highlighting
|
||||||
|
context.insert("active_page", &"company");
|
||||||
|
context.insert("company_id", &company_id_str);
|
||||||
|
|
||||||
|
// Parse query parameters for success/error messages
|
||||||
|
let query_string = req.query_string();
|
||||||
|
|
||||||
|
// Check for success message
|
||||||
|
if let Some(pos) = query_string.find("success=") {
|
||||||
|
let start = pos + 8; // length of "success="
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let success = &query_string[start..end];
|
||||||
|
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
||||||
|
context.insert("success", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error message
|
||||||
|
if let Some(pos) = query_string.find("error=") {
|
||||||
|
let start = pos + 6; // length of "error="
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let error = &query_string[start..end];
|
||||||
|
let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
|
||||||
|
context.insert("error", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse company ID
|
||||||
|
let company_id = match company_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch company from database
|
||||||
|
if let Ok(Some(company)) = get_company_by_id(company_id) {
|
||||||
|
context.insert("company", &company);
|
||||||
|
|
||||||
|
// Format timestamps for display
|
||||||
|
let incorporation_date =
|
||||||
|
chrono::DateTime::from_timestamp(company.incorporation_date, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
context.insert("incorporation_date_formatted", &incorporation_date);
|
||||||
|
|
||||||
|
// Get shareholders for this company
|
||||||
|
let shareholders = match get_company_shareholders(company_id) {
|
||||||
|
Ok(shareholders) => shareholders,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to get shareholders for company {}: {}",
|
||||||
|
company_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
context.insert("shareholders", &shareholders);
|
||||||
|
|
||||||
|
// Get payment information for this company
|
||||||
|
if let Some(payment_info) =
|
||||||
|
crate::controllers::payment::PaymentController::get_company_payment_info(company_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
context.insert("payment_info", &payment_info);
|
||||||
|
|
||||||
|
// Format payment dates for display
|
||||||
|
// Format timestamps from i64 to readable format
|
||||||
|
let payment_created = chrono::DateTime::from_timestamp(payment_info.created_at, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
context.insert("payment_created_formatted", &payment_created);
|
||||||
|
|
||||||
|
if let Some(completed_at) = payment_info.completed_at {
|
||||||
|
let payment_completed = chrono::DateTime::from_timestamp(completed_at, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
context.insert("payment_completed_formatted", &payment_completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format payment plan for display
|
||||||
|
let payment_plan_display = match payment_info.payment_plan.as_str() {
|
||||||
|
"monthly" => "Monthly",
|
||||||
|
"yearly" => "Yearly (20% discount)",
|
||||||
|
"two_year" => "2-Year (40% discount)",
|
||||||
|
_ => &payment_info.payment_plan,
|
||||||
|
};
|
||||||
|
context.insert("payment_plan_display", &payment_plan_display);
|
||||||
|
|
||||||
|
log::info!("Added payment info to company {} view", company_id);
|
||||||
|
} else {
|
||||||
|
log::info!("No payment info found for company {}", company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_template(&tmpl, "company/view.html", &context)
|
||||||
|
} else {
|
||||||
|
render_company_not_found(&tmpl, Some(&company_id_str)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to entity context
|
||||||
|
pub async fn switch_entity(path: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
let company_id_str = path.into_inner();
|
||||||
|
|
||||||
|
// Parse company ID
|
||||||
|
let company_id = match company_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(HttpResponse::Found()
|
||||||
|
.append_header(("Location", "/company"))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get company from database
|
||||||
|
let company_name = match get_company_by_id(company_id) {
|
||||||
|
Ok(Some(company)) => company.name,
|
||||||
|
Ok(None) => {
|
||||||
|
return Ok(HttpResponse::Found()
|
||||||
|
.append_header(("Location", "/company"))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get company for switch: {}", e);
|
||||||
|
return Ok(HttpResponse::Found()
|
||||||
|
.append_header(("Location", "/company"))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// In a real application, we would set a session/cookie for the current entity
|
||||||
|
// Here we'll redirect back to the company page with a success message and entity parameter
|
||||||
|
let success_message = format!("Switched to {} entity context", company_name);
|
||||||
|
let encoded_message = urlencoding::encode(&success_message);
|
||||||
|
|
||||||
|
Ok(HttpResponse::Found()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!(
|
||||||
|
"/company?success={}&entity={}&entity_name={}",
|
||||||
|
encoded_message,
|
||||||
|
company_id_str,
|
||||||
|
urlencoding::encode(&company_name)
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated registration method removed - now handled via payment flow
|
||||||
|
|
||||||
|
// Legacy registration method (kept for reference but not used)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn legacy_register(mut form: actix_multipart::Multipart) -> Result<HttpResponse> {
|
||||||
|
use actix_web::http::header;
|
||||||
|
use chrono::Utc;
|
||||||
|
use futures_util::stream::StreamExt as _;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut fields: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut uploaded_files = Vec::new();
|
||||||
|
|
||||||
|
// Parse multipart form
|
||||||
|
while let Some(Ok(mut field)) = form.next().await {
|
||||||
|
let content_disposition = field.content_disposition();
|
||||||
|
let field_name = content_disposition
|
||||||
|
.get_name()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let filename = content_disposition.get_filename().map(|f| f.to_string());
|
||||||
|
|
||||||
|
if field_name.starts_with("contract-") || field_name.ends_with("-doc") {
|
||||||
|
// Handle file upload
|
||||||
|
if let Some(filename) = filename {
|
||||||
|
let mut file_data = Vec::new();
|
||||||
|
while let Some(chunk) = field.next().await {
|
||||||
|
let data = chunk.unwrap();
|
||||||
|
file_data.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file_data.is_empty() {
|
||||||
|
uploaded_files.push((field_name, filename, file_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle form field
|
||||||
|
let mut value = Vec::new();
|
||||||
|
while let Some(chunk) = field.next().await {
|
||||||
|
let data = chunk.unwrap();
|
||||||
|
value.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.insert(field_name, String::from_utf8_lossy(&value).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract company details
|
||||||
|
let company_name = fields.get("company_name").cloned().unwrap_or_default();
|
||||||
|
let company_type_str = fields.get("company_type").cloned().unwrap_or_default();
|
||||||
|
let company_purpose = fields.get("company_purpose").cloned().unwrap_or_default();
|
||||||
|
let shareholders_str = fields.get("shareholders").cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
// Extract new contact fields
|
||||||
|
let company_email = fields.get("company_email").cloned().unwrap_or_default();
|
||||||
|
let company_phone = fields.get("company_phone").cloned().unwrap_or_default();
|
||||||
|
let company_website = fields.get("company_website").cloned().unwrap_or_default();
|
||||||
|
let company_address = fields.get("company_address").cloned().unwrap_or_default();
|
||||||
|
let company_industry = fields.get("company_industry").cloned().unwrap_or_default();
|
||||||
|
let fiscal_year_end = fields.get("fiscal_year_end").cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if company_name.is_empty() || company_type_str.is_empty() {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
"/company?error=Company name and type are required",
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
if company_email.trim().is_empty() {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((header::LOCATION, "/company?error=Company email is required"))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
if company_phone.trim().is_empty() {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((header::LOCATION, "/company?error=Company phone is required"))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
if company_address.trim().is_empty() {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
"/company?error=Company address is required",
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse business type
|
||||||
|
let business_type = match company_type_str.as_str() {
|
||||||
|
"Startup FZC" => BusinessType::Starter,
|
||||||
|
"Growth FZC" => BusinessType::Global,
|
||||||
|
"Cooperative FZC" => BusinessType::Coop,
|
||||||
|
"Single FZC" => BusinessType::Single,
|
||||||
|
"Twin FZC" => BusinessType::Twin,
|
||||||
|
_ => BusinessType::Single, // Default
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate registration number (in real app, this would be more sophisticated)
|
||||||
|
let registration_number = format!(
|
||||||
|
"FZC-{}-{}",
|
||||||
|
Utc::now().format("%Y%m%d"),
|
||||||
|
company_name
|
||||||
|
.chars()
|
||||||
|
.take(3)
|
||||||
|
.collect::<String>()
|
||||||
|
.to_uppercase()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create company in database
|
||||||
|
match create_new_company(
|
||||||
|
company_name.clone(),
|
||||||
|
registration_number,
|
||||||
|
Utc::now().timestamp(),
|
||||||
|
business_type,
|
||||||
|
company_email,
|
||||||
|
company_phone,
|
||||||
|
company_website,
|
||||||
|
company_address,
|
||||||
|
company_industry,
|
||||||
|
company_purpose,
|
||||||
|
fiscal_year_end,
|
||||||
|
) {
|
||||||
|
Ok((company_id, _company)) => {
|
||||||
|
// TODO: Parse and create shareholders if provided
|
||||||
|
if !shareholders_str.is_empty() {
|
||||||
|
// For now, just log the shareholders - in a real app, parse and create them
|
||||||
|
log::info!(
|
||||||
|
"Shareholders for company {}: {}",
|
||||||
|
company_id,
|
||||||
|
shareholders_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save uploaded documents
|
||||||
|
if !uploaded_files.is_empty() {
|
||||||
|
log::info!(
|
||||||
|
"Processing {} uploaded files for company {}",
|
||||||
|
uploaded_files.len(),
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create uploads directory if it doesn't exist
|
||||||
|
let upload_dir = format!("/tmp/company_{}_documents", company_id);
|
||||||
|
if let Err(e) = fs::create_dir_all(&upload_dir) {
|
||||||
|
log::error!("Failed to create upload directory: {}", e);
|
||||||
|
} else {
|
||||||
|
// Save each uploaded file
|
||||||
|
for (field_name, filename, file_data) in uploaded_files {
|
||||||
|
// Determine document type based on field name
|
||||||
|
let doc_type = match field_name.as_str() {
|
||||||
|
name if name.contains("shareholder") => DocumentType::Articles,
|
||||||
|
name if name.contains("bank") => DocumentType::Financial,
|
||||||
|
name if name.contains("cooperative") => DocumentType::Articles,
|
||||||
|
name if name.contains("digital") => DocumentType::Legal,
|
||||||
|
name if name.contains("contract") => DocumentType::Contract,
|
||||||
|
_ => DocumentType::Other,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
let timestamp = Utc::now().timestamp();
|
||||||
|
let file_extension = filename.split('.').last().unwrap_or("pdf");
|
||||||
|
let unique_filename = format!(
|
||||||
|
"{}_{}.{}",
|
||||||
|
timestamp,
|
||||||
|
filename.replace(" ", "_"),
|
||||||
|
file_extension
|
||||||
|
);
|
||||||
|
let file_path = format!("{}/{}", upload_dir, unique_filename);
|
||||||
|
|
||||||
|
// Save file to disk
|
||||||
|
if let Err(e) = fs::write(&file_path, &file_data) {
|
||||||
|
log::error!("Failed to save file {}: {}", filename, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save document metadata to database
|
||||||
|
let file_size = file_data.len() as u64;
|
||||||
|
let mime_type = match file_extension {
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
"doc" | "docx" => "application/msword",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"png" => "image/png",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
match create_new_document(
|
||||||
|
filename.clone(),
|
||||||
|
file_path,
|
||||||
|
file_size,
|
||||||
|
mime_type,
|
||||||
|
company_id,
|
||||||
|
"System".to_string(), // uploaded_by
|
||||||
|
doc_type,
|
||||||
|
Some("Uploaded during company registration".to_string()),
|
||||||
|
false, // not public by default
|
||||||
|
None, // checksum
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!("Successfully saved document: {}", filename);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to save document metadata for {}: {}",
|
||||||
|
filename,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let success_message = format!(
|
||||||
|
"Successfully registered {} as a {}",
|
||||||
|
company_name, company_type_str
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!("/company?success={}", urlencoding::encode(&success_message)),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to create company: {}", e);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
"/company?error=Failed to register company",
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process company edit form
|
||||||
|
pub async fn edit(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
form: web::Form<CompanyEditForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
use actix_web::http::header;
|
||||||
|
|
||||||
|
let company_id_str = path.into_inner();
|
||||||
|
|
||||||
|
// Parse company ID
|
||||||
|
let company_id = match company_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if form.company_name.trim().is_empty() {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/edit/{}?error=Company name is required",
|
||||||
|
company_id
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse business type
|
||||||
|
let business_type = match form.company_type.as_str() {
|
||||||
|
"Startup FZC" => BusinessType::Starter,
|
||||||
|
"Growth FZC" => BusinessType::Global,
|
||||||
|
"Cooperative FZC" => BusinessType::Coop,
|
||||||
|
"Single FZC" => BusinessType::Single,
|
||||||
|
"Twin FZC" => BusinessType::Twin,
|
||||||
|
_ => BusinessType::Single, // Default
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse status
|
||||||
|
let status = match form.status.as_str() {
|
||||||
|
"Active" => CompanyStatus::Active,
|
||||||
|
"Inactive" => CompanyStatus::Inactive,
|
||||||
|
"Suspended" => CompanyStatus::Suspended,
|
||||||
|
_ => CompanyStatus::Active, // Default
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update company in database
|
||||||
|
match update_company(
|
||||||
|
company_id,
|
||||||
|
Some(form.company_name.clone()),
|
||||||
|
form.email.clone(),
|
||||||
|
form.phone.clone(),
|
||||||
|
form.website.clone(),
|
||||||
|
form.address.clone(),
|
||||||
|
form.industry.clone(),
|
||||||
|
form.description.clone(),
|
||||||
|
form.fiscal_year_end.clone(),
|
||||||
|
Some(status),
|
||||||
|
Some(business_type),
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
let success_message = format!("Successfully updated {}", form.company_name);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/view/{}?success={}",
|
||||||
|
company_id,
|
||||||
|
urlencoding::encode(&success_message)
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to update company {}: {}", company_id, e);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/edit/{}?error=Failed to update company",
|
||||||
|
company_id
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug endpoint to clean up corrupted database (emergency use only)
|
||||||
|
pub async fn cleanup_database() -> Result<HttpResponse> {
|
||||||
|
match crate::db::company::cleanup_corrupted_database() {
|
||||||
|
Ok(message) => {
|
||||||
|
log::info!("Database cleanup successful: {}", message);
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": message
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Database cleanup failed: {}", error);
|
||||||
|
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||||
|
"success": false,
|
||||||
|
"error": error
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2202
actix_mvc_app/src/controllers/contract.rs
Normal file
2202
actix_mvc_app/src/controllers/contract.rs
Normal file
File diff suppressed because it is too large
Load Diff
480
actix_mvc_app/src/controllers/defi.rs
Normal file
480
actix_mvc_app/src/controllers/defi.rs
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
use actix_web::HttpRequest;
|
||||||
|
use actix_web::{HttpResponse, Result, web};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::asset::Asset;
|
||||||
|
use crate::models::defi::{
|
||||||
|
DEFI_DB, DefiPosition, DefiPositionStatus, DefiPositionType, ProvidingPosition,
|
||||||
|
ReceivingPosition,
|
||||||
|
};
|
||||||
|
use crate::utils::render_template;
|
||||||
|
|
||||||
|
// Form structs for DeFi operations
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ProvidingForm {
|
||||||
|
pub asset_id: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub duration: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ReceivingForm {
|
||||||
|
pub collateral_asset_id: String,
|
||||||
|
pub collateral_amount: f64,
|
||||||
|
pub amount: f64,
|
||||||
|
pub duration: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct LiquidityForm {
|
||||||
|
pub first_token: String,
|
||||||
|
pub first_amount: f64,
|
||||||
|
pub second_token: String,
|
||||||
|
pub second_amount: f64,
|
||||||
|
pub pool_fee: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct StakingForm {
|
||||||
|
pub asset_id: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub staking_period: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SwapForm {
|
||||||
|
pub from_token: String,
|
||||||
|
pub from_amount: f64,
|
||||||
|
pub to_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct CollateralForm {
|
||||||
|
pub asset_id: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub purpose: String,
|
||||||
|
pub funds_amount: Option<f64>,
|
||||||
|
pub funds_term: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DefiController;
|
||||||
|
|
||||||
|
impl DefiController {
|
||||||
|
// Display the DeFi dashboard
|
||||||
|
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
println!("DEBUG: Starting DeFi dashboard rendering");
|
||||||
|
|
||||||
|
// Get mock assets for the dropdown selectors
|
||||||
|
let assets = Self::get_mock_assets();
|
||||||
|
println!("DEBUG: Generated {} mock assets", assets.len());
|
||||||
|
|
||||||
|
// Add active_page for navigation highlighting
|
||||||
|
context.insert("active_page", &"defi");
|
||||||
|
|
||||||
|
// Add DeFi stats
|
||||||
|
let defi_stats = Self::get_defi_stats();
|
||||||
|
context.insert("defi_stats", &serde_json::to_value(defi_stats).unwrap());
|
||||||
|
|
||||||
|
// Add recent assets for selection in forms
|
||||||
|
let recent_assets: Vec<serde_json::Map<String, serde_json::Value>> = assets
|
||||||
|
.iter()
|
||||||
|
.take(5)
|
||||||
|
.map(|a| Self::asset_to_json(a))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
context.insert("recent_assets", &recent_assets);
|
||||||
|
|
||||||
|
// Get user's providing positions
|
||||||
|
let db = DEFI_DB.lock().unwrap();
|
||||||
|
let providing_positions = db.get_user_providing_positions("user123");
|
||||||
|
let providing_positions_json: Vec<serde_json::Value> = providing_positions
|
||||||
|
.iter()
|
||||||
|
.map(|p| serde_json::to_value(p).unwrap())
|
||||||
|
.collect();
|
||||||
|
context.insert("providing_positions", &providing_positions_json);
|
||||||
|
|
||||||
|
// Get user's receiving positions
|
||||||
|
let receiving_positions = db.get_user_receiving_positions("user123");
|
||||||
|
let receiving_positions_json: Vec<serde_json::Value> = receiving_positions
|
||||||
|
.iter()
|
||||||
|
.map(|p| serde_json::to_value(p).unwrap())
|
||||||
|
.collect();
|
||||||
|
context.insert("receiving_positions", &receiving_positions_json);
|
||||||
|
|
||||||
|
// Add success message if present in query params
|
||||||
|
if let Some(success) = req.query_string().strip_prefix("success=") {
|
||||||
|
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
||||||
|
context.insert("success_message", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("DEBUG: Rendering DeFi dashboard template");
|
||||||
|
let response = render_template(&tmpl, "defi/index.html", &context);
|
||||||
|
println!("DEBUG: Finished rendering DeFi dashboard template");
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process providing request
|
||||||
|
pub async fn create_providing(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
form: web::Form<ProvidingForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
println!("DEBUG: Processing providing request: {:?}", form);
|
||||||
|
|
||||||
|
// Get the asset obligationails (in a real app, this would come from a database)
|
||||||
|
let assets = Self::get_mock_assets();
|
||||||
|
let asset = assets.iter().find(|a| a.id == form.asset_id);
|
||||||
|
|
||||||
|
if let Some(asset) = asset {
|
||||||
|
// Calculate profit share and return amount
|
||||||
|
let profit_share = match form.duration {
|
||||||
|
7 => 2.5,
|
||||||
|
30 => 4.2,
|
||||||
|
90 => 6.8,
|
||||||
|
180 => 8.5,
|
||||||
|
365 => 12.0,
|
||||||
|
_ => 4.2, // Default to 30 days rate
|
||||||
|
};
|
||||||
|
|
||||||
|
let return_amount = form.amount
|
||||||
|
+ (form.amount * (profit_share / 100.0) * (form.duration as f64 / 365.0));
|
||||||
|
|
||||||
|
// Create a new providing position
|
||||||
|
let providing_position = ProvidingPosition {
|
||||||
|
base: DefiPosition {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
position_type: DefiPositionType::Providing,
|
||||||
|
status: DefiPositionStatus::Active,
|
||||||
|
asset_id: form.asset_id.clone(),
|
||||||
|
asset_name: asset.name.clone(),
|
||||||
|
asset_symbol: asset.asset_type.as_str().to_string(),
|
||||||
|
amount: form.amount,
|
||||||
|
value_usd: form.amount * asset.current_valuation.unwrap_or(0.0),
|
||||||
|
expected_return: profit_share,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
expires_at: Some(Utc::now() + Duration::days(form.duration as i64)),
|
||||||
|
user_id: "user123".to_string(), // Hardcoded user ID for now
|
||||||
|
},
|
||||||
|
duration_days: form.duration,
|
||||||
|
profit_share_earned: profit_share,
|
||||||
|
return_amount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the position to the database
|
||||||
|
{
|
||||||
|
let mut db = DEFI_DB.lock().unwrap();
|
||||||
|
db.add_providing_position(providing_position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect with success message
|
||||||
|
let success_message = format!(
|
||||||
|
"Successfully provided {} {} for {} days",
|
||||||
|
form.amount, asset.name, form.duration
|
||||||
|
);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
} else {
|
||||||
|
// Asset not found, redirect with error
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/defi?error=Asset not found"))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process receiving request
|
||||||
|
pub async fn create_receiving(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
form: web::Form<ReceivingForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
println!("DEBUG: Processing receiving request: {:?}", form);
|
||||||
|
|
||||||
|
// Get the asset obligationails (in a real app, this would come from a database)
|
||||||
|
let assets = Self::get_mock_assets();
|
||||||
|
let collateral_asset = assets.iter().find(|a| a.id == form.collateral_asset_id);
|
||||||
|
|
||||||
|
if let Some(collateral_asset) = collateral_asset {
|
||||||
|
// Calculate profit share rate based on duration
|
||||||
|
let profit_share_rate = match form.duration {
|
||||||
|
7 => 3.5,
|
||||||
|
30 => 5.0,
|
||||||
|
90 => 6.5,
|
||||||
|
180 => 8.0,
|
||||||
|
365 => 10.0,
|
||||||
|
_ => 5.0, // Default to 30 days rate
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate profit share and total to repay
|
||||||
|
let profit_share =
|
||||||
|
form.amount * (profit_share_rate / 100.0) * (form.duration as f64 / 365.0);
|
||||||
|
let total_to_repay = form.amount + profit_share;
|
||||||
|
|
||||||
|
// Calculate collateral value and ratio
|
||||||
|
let collateral_value = form.collateral_amount
|
||||||
|
* collateral_asset.latest_valuation().map_or(0.5, |v| v.value);
|
||||||
|
let collateral_ratio = (collateral_value / form.amount) * 100.0;
|
||||||
|
|
||||||
|
// Create a new receiving position
|
||||||
|
let receiving_position = ReceivingPosition {
|
||||||
|
base: DefiPosition {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
position_type: DefiPositionType::Receiving,
|
||||||
|
status: DefiPositionStatus::Active,
|
||||||
|
asset_id: "ZDFZ".to_string(), // Hardcoded for now, in a real app this would be a parameter
|
||||||
|
asset_name: "Zanzibar Token".to_string(),
|
||||||
|
asset_symbol: "ZDFZ".to_string(),
|
||||||
|
amount: form.amount,
|
||||||
|
value_usd: form.amount * 0.5, // Assuming 0.5 USD per ZDFZ
|
||||||
|
expected_return: profit_share_rate,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
expires_at: Some(Utc::now() + Duration::days(form.duration as i64)),
|
||||||
|
user_id: "user123".to_string(), // Hardcoded user ID for now
|
||||||
|
},
|
||||||
|
collateral_asset_id: collateral_asset.id.clone(),
|
||||||
|
collateral_asset_name: collateral_asset.name.clone(),
|
||||||
|
collateral_asset_symbol: collateral_asset.asset_type.as_str().to_string(),
|
||||||
|
collateral_amount: form.collateral_amount,
|
||||||
|
collateral_value_usd: collateral_value,
|
||||||
|
duration_days: form.duration,
|
||||||
|
profit_share_rate,
|
||||||
|
profit_share_owed: profit_share,
|
||||||
|
total_to_repay,
|
||||||
|
collateral_ratio,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the position to the database
|
||||||
|
{
|
||||||
|
let mut db = DEFI_DB.lock().unwrap();
|
||||||
|
db.add_receiving_position(receiving_position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect with success message
|
||||||
|
let success_message = format!(
|
||||||
|
"Successfully borrowed {} ZDFZ using {} {} as collateral",
|
||||||
|
form.amount, form.collateral_amount, collateral_asset.name
|
||||||
|
);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
} else {
|
||||||
|
// Asset not found, redirect with error
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header(("Location", "/defi?error=Collateral asset not found"))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process liquidity provision
|
||||||
|
pub async fn add_liquidity(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
form: web::Form<LiquidityForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
println!("DEBUG: Processing liquidity provision: {:?}", form);
|
||||||
|
|
||||||
|
// In a real application, this would add liquidity to a pool in the database
|
||||||
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
|
let success_message = format!(
|
||||||
|
"Successfully added liquidity: {} {} and {} {}",
|
||||||
|
form.first_amount, form.first_token, form.second_amount, form.second_token
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process staking request
|
||||||
|
pub async fn create_staking(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
form: web::Form<StakingForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
println!("DEBUG: Processing staking request: {:?}", form);
|
||||||
|
|
||||||
|
// In a real application, this would create a staking position in the database
|
||||||
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
|
let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id);
|
||||||
|
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process token swap
|
||||||
|
pub async fn swap_tokens(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
form: web::Form<SwapForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
println!("DEBUG: Processing token swap: {:?}", form);
|
||||||
|
|
||||||
|
// In a real application, this would perform a token swap in the database
|
||||||
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
|
let success_message = format!(
|
||||||
|
"Successfully swapped {} {} to {}",
|
||||||
|
form.from_amount, form.from_token, form.to_token
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process collateral position creation
|
||||||
|
pub async fn create_collateral(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
form: web::Form<CollateralForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
println!("DEBUG: Processing collateral creation: {:?}", form);
|
||||||
|
|
||||||
|
// In a real application, this would create a collateral position in the database
|
||||||
|
// For now, we'll just redirect back to the DeFi dashboard with a success message
|
||||||
|
|
||||||
|
let purpose_str = match form.purpose.as_str() {
|
||||||
|
"funds" => "secure a funds",
|
||||||
|
"synthetic" => "generate synthetic assets",
|
||||||
|
"leverage" => "leverage trading",
|
||||||
|
_ => "collateralization",
|
||||||
|
};
|
||||||
|
|
||||||
|
let success_message = format!(
|
||||||
|
"Successfully collateralized {} {} for {}",
|
||||||
|
form.amount, form.asset_id, purpose_str
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!("/defi?success={}", urlencoding::encode(&success_message)),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to get DeFi statistics
|
||||||
|
fn get_defi_stats() -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let mut stats = serde_json::Map::new();
|
||||||
|
|
||||||
|
// Handle Option<Number> by unwrapping with expect
|
||||||
|
stats.insert(
|
||||||
|
"total_value_locked".to_string(),
|
||||||
|
serde_json::Value::Number(
|
||||||
|
serde_json::Number::from_f64(1250000.0).expect("Valid float"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
stats.insert(
|
||||||
|
"providing_volume".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from_f64(450000.0).expect("Valid float")),
|
||||||
|
);
|
||||||
|
stats.insert(
|
||||||
|
"receiving_volume".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from_f64(320000.0).expect("Valid float")),
|
||||||
|
);
|
||||||
|
stats.insert(
|
||||||
|
"liquidity_pools_count".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from(12)),
|
||||||
|
);
|
||||||
|
stats.insert(
|
||||||
|
"active_stakers".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from(156)),
|
||||||
|
);
|
||||||
|
stats.insert(
|
||||||
|
"total_swap_volume".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from_f64(780000.0).expect("Valid float")),
|
||||||
|
);
|
||||||
|
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to convert Asset to a JSON object for templates
|
||||||
|
fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> {
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
"id".to_string(),
|
||||||
|
serde_json::Value::String(asset.id.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
serde_json::Value::String(asset.name.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"description".to_string(),
|
||||||
|
serde_json::Value::String(asset.description.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"asset_type".to_string(),
|
||||||
|
serde_json::Value::String(asset.asset_type.as_str().to_string()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"status".to_string(),
|
||||||
|
serde_json::Value::String(asset.status.as_str().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add current valuation
|
||||||
|
if let Some(latest) = asset.latest_valuation() {
|
||||||
|
if let Some(num) = serde_json::Number::from_f64(latest.value) {
|
||||||
|
map.insert(
|
||||||
|
"current_valuation".to_string(),
|
||||||
|
serde_json::Value::Number(num),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
map.insert(
|
||||||
|
"current_valuation".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from(0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
map.insert(
|
||||||
|
"valuation_currency".to_string(),
|
||||||
|
serde_json::Value::String(latest.currency.clone()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"valuation_date".to_string(),
|
||||||
|
serde_json::Value::String(latest.date.format("%Y-%m-%d").to_string()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
map.insert(
|
||||||
|
"current_valuation".to_string(),
|
||||||
|
serde_json::Value::Number(serde_json::Number::from(0)),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"valuation_currency".to_string(),
|
||||||
|
serde_json::Value::String("USD".to_string()),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"valuation_date".to_string(),
|
||||||
|
serde_json::Value::String("N/A".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate mock assets for testing
|
||||||
|
fn get_mock_assets() -> Vec<Asset> {
|
||||||
|
// Reuse the asset controller's mock data function
|
||||||
|
crate::controllers::asset::AssetController::get_mock_assets()
|
||||||
|
}
|
||||||
|
}
|
||||||
382
actix_mvc_app/src/controllers/document.rs
Normal file
382
actix_mvc_app/src/controllers/document.rs
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
use crate::controllers::error::render_company_not_found;
|
||||||
|
use crate::db::{company::get_company_by_id, document::*};
|
||||||
|
use crate::models::document::{DocumentStatistics, DocumentType};
|
||||||
|
use crate::utils::render_template;
|
||||||
|
use actix_multipart::Multipart;
|
||||||
|
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||||
|
use futures_util::stream::StreamExt as _;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
|
// Form structs removed - not currently used in document operations
|
||||||
|
|
||||||
|
pub struct DocumentController;
|
||||||
|
|
||||||
|
impl DocumentController {
|
||||||
|
/// Display company documents management page
|
||||||
|
pub async fn index(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
req: HttpRequest,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let company_id_str = path.into_inner();
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
// Add active_page for navigation highlighting
|
||||||
|
context.insert("active_page", &"company");
|
||||||
|
|
||||||
|
// Parse query parameters for success/error messages
|
||||||
|
let query_string = req.query_string();
|
||||||
|
|
||||||
|
// Check for success message
|
||||||
|
if let Some(pos) = query_string.find("success=") {
|
||||||
|
let start = pos + 8;
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let success = &query_string[start..end];
|
||||||
|
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
|
||||||
|
context.insert("success", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error message
|
||||||
|
if let Some(pos) = query_string.find("error=") {
|
||||||
|
let start = pos + 6;
|
||||||
|
let end = query_string[start..]
|
||||||
|
.find('&')
|
||||||
|
.map_or(query_string.len(), |e| e + start);
|
||||||
|
let error = &query_string[start..end];
|
||||||
|
let decoded = urlencoding::decode(error).unwrap_or_else(|_| error.into());
|
||||||
|
context.insert("error", &decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse company ID
|
||||||
|
let company_id = match company_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => return render_company_not_found(&tmpl, Some(&company_id_str)).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch company from database
|
||||||
|
if let Ok(Some(company)) = get_company_by_id(company_id) {
|
||||||
|
context.insert("company", &company);
|
||||||
|
context.insert("company_id", &company_id);
|
||||||
|
|
||||||
|
// Get documents for this company
|
||||||
|
let documents = match get_company_documents(company_id) {
|
||||||
|
Ok(documents) => documents,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get documents for company {}: {}", company_id, e);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
let stats = DocumentStatistics::new(&documents);
|
||||||
|
context.insert("documents", &documents);
|
||||||
|
context.insert("stats", &stats);
|
||||||
|
|
||||||
|
// Add document types for dropdown (as template-friendly tuples)
|
||||||
|
let document_types: Vec<(String, String)> = DocumentType::all()
|
||||||
|
.into_iter()
|
||||||
|
.map(|dt| (format!("{:?}", dt), dt.as_str().to_string()))
|
||||||
|
.collect();
|
||||||
|
context.insert("document_types", &document_types);
|
||||||
|
|
||||||
|
render_template(&tmpl, "company/documents.html", &context)
|
||||||
|
} else {
|
||||||
|
render_company_not_found(&tmpl, Some(&company_id_str)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle document upload
|
||||||
|
pub async fn upload(path: web::Path<String>, mut payload: Multipart) -> Result<HttpResponse> {
|
||||||
|
use actix_web::http::header;
|
||||||
|
|
||||||
|
let company_id_str = path.into_inner();
|
||||||
|
log::info!("Document upload request for company: {}", company_id_str);
|
||||||
|
|
||||||
|
let company_id = match company_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?error=Invalid company ID",
|
||||||
|
company_id_str
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut form_fields: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut uploaded_files = Vec::new();
|
||||||
|
|
||||||
|
// Parse multipart form
|
||||||
|
log::info!("Starting multipart form parsing");
|
||||||
|
while let Some(Ok(mut field)) = payload.next().await {
|
||||||
|
let content_disposition = field.content_disposition();
|
||||||
|
let field_name = content_disposition
|
||||||
|
.get_name()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let filename = content_disposition.get_filename().map(|f| f.to_string());
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Processing field: {} (filename: {:?})",
|
||||||
|
field_name,
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
|
||||||
|
if field_name == "documents" {
|
||||||
|
// Handle file upload
|
||||||
|
if let Some(filename) = filename {
|
||||||
|
let mut file_data = Vec::new();
|
||||||
|
while let Some(chunk) = field.next().await {
|
||||||
|
let data = chunk.unwrap();
|
||||||
|
file_data.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file_data.is_empty() {
|
||||||
|
uploaded_files.push((filename, file_data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle form fields
|
||||||
|
let mut field_data = Vec::new();
|
||||||
|
while let Some(chunk) = field.next().await {
|
||||||
|
let data = chunk.unwrap();
|
||||||
|
field_data.extend_from_slice(&data);
|
||||||
|
}
|
||||||
|
let field_value = String::from_utf8_lossy(&field_data).to_string();
|
||||||
|
form_fields.insert(field_name, field_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Multipart parsing complete. Files: {}, Form fields: {:?}",
|
||||||
|
uploaded_files.len(),
|
||||||
|
form_fields
|
||||||
|
);
|
||||||
|
|
||||||
|
if uploaded_files.is_empty() {
|
||||||
|
log::warn!("No files uploaded");
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!("/company/documents/{}?error=No files selected", company_id),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create uploads directory if it doesn't exist
|
||||||
|
let upload_dir = format!("/tmp/company_{}_documents", company_id);
|
||||||
|
if let Err(e) = fs::create_dir_all(&upload_dir) {
|
||||||
|
log::error!("Failed to create upload directory: {}", e);
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?error=Failed to create upload directory",
|
||||||
|
company_id
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let document_type = DocumentType::from_str(
|
||||||
|
&form_fields
|
||||||
|
.get("document_type")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let description = form_fields.get("description").cloned();
|
||||||
|
let is_public = form_fields.get("is_public").map_or(false, |v| v == "on");
|
||||||
|
|
||||||
|
let mut success_count = 0;
|
||||||
|
let mut error_count = 0;
|
||||||
|
|
||||||
|
// Process each uploaded file
|
||||||
|
for (filename, file_data) in uploaded_files {
|
||||||
|
let file_path = format!("{}/{}", upload_dir, filename);
|
||||||
|
|
||||||
|
// Save file to disk
|
||||||
|
match fs::File::create(&file_path) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
if let Err(e) = file.write_all(&file_data) {
|
||||||
|
log::error!("Failed to write file {}: {}", filename, e);
|
||||||
|
error_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to create file {}: {}", filename, e);
|
||||||
|
error_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type based on file extension
|
||||||
|
let mime_type = match Path::new(&filename)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("pdf") => "application/pdf",
|
||||||
|
Some("doc") | Some("docx") => "application/msword",
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("txt") => "text/plain",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save document to database
|
||||||
|
match create_new_document(
|
||||||
|
filename.clone(),
|
||||||
|
file_path,
|
||||||
|
file_data.len() as u64,
|
||||||
|
mime_type.to_string(),
|
||||||
|
company_id,
|
||||||
|
"System".to_string(), // TODO: Use actual logged-in user
|
||||||
|
document_type.clone(),
|
||||||
|
description.clone(),
|
||||||
|
is_public,
|
||||||
|
None, // TODO: Calculate checksum
|
||||||
|
) {
|
||||||
|
Ok(_) => success_count += 1,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to save document {} to database: {}", filename, e);
|
||||||
|
error_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = if error_count == 0 {
|
||||||
|
format!("Successfully uploaded {} document(s)", success_count)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Uploaded {} document(s), {} failed",
|
||||||
|
success_count, error_count
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?success={}",
|
||||||
|
company_id,
|
||||||
|
urlencoding::encode(&message)
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a document
|
||||||
|
pub async fn delete(path: web::Path<(String, String)>) -> Result<HttpResponse> {
|
||||||
|
use actix_web::http::header;
|
||||||
|
|
||||||
|
let (company_id_str, document_id_str) = path.into_inner();
|
||||||
|
let company_id = match company_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?error=Invalid company ID",
|
||||||
|
company_id_str
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let document_id = match document_id_str.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?error=Invalid document ID",
|
||||||
|
company_id
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get document to check if it exists and belongs to the company
|
||||||
|
match get_document_by_id(document_id) {
|
||||||
|
Ok(Some(document)) => {
|
||||||
|
if document.company_id != company_id {
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!("/company/documents/{}?error=Document not found", company_id),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
if let Err(e) = fs::remove_file(&document.file_path) {
|
||||||
|
log::warn!("Failed to delete file {}: {}", document.file_path, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
match delete_document(document_id) {
|
||||||
|
Ok(_) => {
|
||||||
|
let message = format!("Successfully deleted document '{}'", document.name);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?success={}",
|
||||||
|
company_id,
|
||||||
|
urlencoding::encode(&message)
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to delete document from database: {}", e);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?error=Failed to delete document",
|
||||||
|
company_id
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!("/company/documents/{}?error=Document not found", company_id),
|
||||||
|
))
|
||||||
|
.finish()),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get document: {}", e);
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.append_header((
|
||||||
|
header::LOCATION,
|
||||||
|
format!(
|
||||||
|
"/company/documents/{}?error=Failed to access document",
|
||||||
|
company_id
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
actix_mvc_app/src/controllers/error.rs
Normal file
125
actix_mvc_app/src/controllers/error.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use actix_web::{Error, HttpResponse, web};
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
|
pub struct ErrorController;
|
||||||
|
|
||||||
|
impl ErrorController {
|
||||||
|
/// Renders a 404 Not Found page with customizable content
|
||||||
|
pub async fn not_found(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
error_title: Option<&str>,
|
||||||
|
error_message: Option<&str>,
|
||||||
|
return_url: Option<&str>,
|
||||||
|
return_text: Option<&str>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
// Set default or custom error content
|
||||||
|
context.insert("error_title", &error_title.unwrap_or("Page Not Found"));
|
||||||
|
context.insert(
|
||||||
|
"error_message",
|
||||||
|
&error_message
|
||||||
|
.unwrap_or("The page you're looking for doesn't exist or has been moved."),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional return URL and text
|
||||||
|
if let Some(url) = return_url {
|
||||||
|
context.insert("return_url", &url);
|
||||||
|
context.insert("return_text", &return_text.unwrap_or("Return"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the 404 template with 404 status
|
||||||
|
match tmpl.render("errors/404.html", &context) {
|
||||||
|
Ok(rendered) => Ok(HttpResponse::NotFound()
|
||||||
|
.content_type("text/html; charset=utf-8")
|
||||||
|
.body(rendered)),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to render 404 template: {}", e);
|
||||||
|
// Fallback to simple text response
|
||||||
|
Ok(HttpResponse::NotFound()
|
||||||
|
.content_type("text/plain")
|
||||||
|
.body("404 - Page Not Found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a 404 page for contract not found
|
||||||
|
pub async fn contract_not_found(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
contract_id: Option<&str>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let error_title = "Contract Not Found";
|
||||||
|
let error_message = if let Some(id) = contract_id {
|
||||||
|
format!(
|
||||||
|
"The contract with ID '{}' doesn't exist or has been removed.",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"The contract you're looking for doesn't exist or has been removed.".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::not_found(
|
||||||
|
tmpl,
|
||||||
|
Some(error_title),
|
||||||
|
Some(&error_message),
|
||||||
|
Some("/contracts"),
|
||||||
|
Some("Back to Contracts"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendar_event_not_found removed - not used
|
||||||
|
|
||||||
|
/// Renders a 404 page for company not found
|
||||||
|
pub async fn company_not_found(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
company_id: Option<&str>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let error_title = "Company Not Found";
|
||||||
|
let error_message = if let Some(id) = company_id {
|
||||||
|
format!(
|
||||||
|
"The company with ID '{}' doesn't exist or has been removed.",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"The company you're looking for doesn't exist or has been removed.".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::not_found(
|
||||||
|
tmpl,
|
||||||
|
Some(error_title),
|
||||||
|
Some(&error_message),
|
||||||
|
Some("/company"),
|
||||||
|
Some("Back to Companies"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a generic 404 page
|
||||||
|
pub async fn generic_not_found(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
|
||||||
|
Self::not_found(tmpl, None, None, None, None).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to quickly render a contract not found response
|
||||||
|
pub async fn render_contract_not_found(
|
||||||
|
tmpl: &web::Data<Tera>,
|
||||||
|
contract_id: Option<&str>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
ErrorController::contract_not_found(tmpl.clone(), contract_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// render_calendar_event_not_found removed - not used
|
||||||
|
|
||||||
|
/// Helper function to quickly render a company not found response
|
||||||
|
pub async fn render_company_not_found(
|
||||||
|
tmpl: &web::Data<Tera>,
|
||||||
|
company_id: Option<&str>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
ErrorController::company_not_found(tmpl.clone(), company_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to quickly render a generic not found response
|
||||||
|
pub async fn render_generic_not_found(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
|
||||||
|
ErrorController::generic_not_found(tmpl).await
|
||||||
|
}
|
||||||
636
actix_mvc_app/src/controllers/flow.rs
Normal file
636
actix_mvc_app/src/controllers/flow.rs
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
use actix_web::{web, HttpResponse, Responder, Result};
|
||||||
|
use actix_session::Session;
|
||||||
|
use chrono::{Utc, Duration};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tera::Tera;
|
||||||
|
|
||||||
|
use crate::models::flow::{Flow, FlowStatus, FlowType, FlowStatistics, FlowStep, StepStatus, FlowLog};
|
||||||
|
use crate::controllers::auth::Claims;
|
||||||
|
use crate::utils::render_template;
|
||||||
|
|
||||||
|
pub struct FlowController;
|
||||||
|
|
||||||
|
impl FlowController {
|
||||||
|
/// Renders the flows dashboard
|
||||||
|
pub async fn index(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
||||||
|
let user = Self::get_user_from_session(&session);
|
||||||
|
let flows = Self::get_mock_flows();
|
||||||
|
let stats = FlowStatistics::new(&flows);
|
||||||
|
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "flows");
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
ctx.insert("flows", &flows);
|
||||||
|
ctx.insert("stats", &stats);
|
||||||
|
ctx.insert("active_flows", &flows.iter().filter(|f| f.status == FlowStatus::InProgress).collect::<Vec<_>>());
|
||||||
|
ctx.insert("stuck_flows", &flows.iter().filter(|f| f.status == FlowStatus::Stuck).collect::<Vec<_>>());
|
||||||
|
|
||||||
|
render_template(&tmpl, "flows/index.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the flows list page
|
||||||
|
pub async fn list_flows(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
||||||
|
let user = Self::get_user_from_session(&session);
|
||||||
|
let flows = Self::get_mock_flows();
|
||||||
|
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "flows");
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
ctx.insert("flows", &flows);
|
||||||
|
|
||||||
|
render_template(&tmpl, "flows/flows.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the flow detail page
|
||||||
|
pub async fn flow_detail(
|
||||||
|
path: web::Path<String>,
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
session: Session
|
||||||
|
) -> Result<impl Responder> {
|
||||||
|
let flow_id = path.into_inner();
|
||||||
|
let user = Self::get_user_from_session(&session);
|
||||||
|
|
||||||
|
// Find the flow with the given ID
|
||||||
|
let flows = Self::get_mock_flows();
|
||||||
|
let flow = flows.iter().find(|f| f.id == flow_id);
|
||||||
|
|
||||||
|
if let Some(flow) = flow {
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "flows");
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
ctx.insert("flow", flow);
|
||||||
|
|
||||||
|
render_template(&tmpl, "flows/flow_detail.html", &ctx)
|
||||||
|
} else {
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "flows");
|
||||||
|
ctx.insert("error", "Flow not found");
|
||||||
|
|
||||||
|
// For the error page, we'll use a special case to set the status code to 404
|
||||||
|
match tmpl.render("error.html", &ctx) {
|
||||||
|
Ok(content) => Ok(HttpResponse::NotFound().content_type("text/html").body(content)),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error rendering error template: {}", e);
|
||||||
|
Err(actix_web::error::ErrorInternalServerError(format!("Error: {}", e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the create flow page
|
||||||
|
pub async fn create_flow_form(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
||||||
|
let user = Self::get_user_from_session(&session);
|
||||||
|
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "flows");
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
|
||||||
|
render_template(&tmpl, "flows/create_flow.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the create flow form submission
|
||||||
|
pub async fn create_flow(
|
||||||
|
_form: web::Form<FlowForm>,
|
||||||
|
_session: Session
|
||||||
|
) -> impl Responder {
|
||||||
|
// In a real application, we would create a new flow here
|
||||||
|
// For now, just redirect to the flows list
|
||||||
|
|
||||||
|
HttpResponse::Found()
|
||||||
|
.append_header(("Location", "/flows"))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the my flows page
|
||||||
|
pub async fn my_flows(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
||||||
|
let user = Self::get_user_from_session(&session);
|
||||||
|
|
||||||
|
if let Some(user) = &user {
|
||||||
|
let flows = Self::get_mock_flows();
|
||||||
|
let my_flows = flows.iter()
|
||||||
|
.filter(|f| f.owner_name == user.sub)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "flows");
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
ctx.insert("flows", &my_flows);
|
||||||
|
|
||||||
|
render_template(&tmpl, "flows/my_flows.html", &ctx)
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::Found()
|
||||||
|
.append_header(("Location", "/login"))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the advance flow step action
|
||||||
|
pub async fn advance_flow_step(
|
||||||
|
path: web::Path<String>,
|
||||||
|
_session: Session
|
||||||
|
) -> impl Responder {
|
||||||
|
let flow_id = path.into_inner();
|
||||||
|
|
||||||
|
// In a real application, we would advance the flow step here
|
||||||
|
// For now, just redirect to the flow detail page
|
||||||
|
|
||||||
|
HttpResponse::Found()
|
||||||
|
.append_header(("Location", format!("/flows/{}", flow_id)))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the mark flow step as stuck action
|
||||||
|
pub async fn mark_flow_step_stuck(
|
||||||
|
path: web::Path<String>,
|
||||||
|
_form: web::Form<StuckForm>,
|
||||||
|
_session: Session
|
||||||
|
) -> impl Responder {
|
||||||
|
let flow_id = path.into_inner();
|
||||||
|
|
||||||
|
// In a real application, we would mark the flow step as stuck here
|
||||||
|
// For now, just redirect to the flow detail page
|
||||||
|
|
||||||
|
HttpResponse::Found()
|
||||||
|
.append_header(("Location", format!("/flows/{}", flow_id)))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the add log to flow step action
|
||||||
|
pub async fn add_log_to_flow_step(
|
||||||
|
path: web::Path<(String, String)>,
|
||||||
|
_form: web::Form<LogForm>,
|
||||||
|
_session: Session
|
||||||
|
) -> impl Responder {
|
||||||
|
let (flow_id, _step_id) = path.into_inner();
|
||||||
|
|
||||||
|
// In a real application, we would add a log to the flow step here
|
||||||
|
// For now, just redirect to the flow detail page
|
||||||
|
|
||||||
|
HttpResponse::Found()
|
||||||
|
.append_header(("Location", format!("/flows/{}", flow_id)))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the user from the session
|
||||||
|
fn get_user_from_session(session: &Session) -> Option<Claims> {
|
||||||
|
if let Ok(Some(user)) = session.get::<Claims>("user") {
|
||||||
|
Some(user)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates mock flow data for testing
|
||||||
|
fn get_mock_flows() -> Vec<Flow> {
|
||||||
|
let mut flows = Vec::new();
|
||||||
|
|
||||||
|
// Create a few mock flows
|
||||||
|
let mut flow1 = Flow {
|
||||||
|
id: "flow-1".to_string(),
|
||||||
|
name: "ZDFZ Business Entity Registration".to_string(),
|
||||||
|
description: "Register a new business entity within the Zanzibar Digital Freezone legal framework".to_string(),
|
||||||
|
flow_type: FlowType::CompanyRegistration,
|
||||||
|
status: FlowStatus::InProgress,
|
||||||
|
owner_id: "user-1".to_string(),
|
||||||
|
owner_name: "Ibrahim Faraji".to_string(),
|
||||||
|
steps: vec![
|
||||||
|
FlowStep {
|
||||||
|
id: "step-1-1".to_string(),
|
||||||
|
name: "Document Submission".to_string(),
|
||||||
|
description: "Submit required business registration documents including business plan, ownership structure, and KYC information".to_string(),
|
||||||
|
order: 1,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(5)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(4)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-1-1-1".to_string(),
|
||||||
|
message: "Initial document package submitted".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(5),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-1-1-2".to_string(),
|
||||||
|
message: "Additional ownership verification documents requested".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(4) - Duration::hours(12),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-1-1-3".to_string(),
|
||||||
|
message: "Additional documents submitted and verified".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(4),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-1-2".to_string(),
|
||||||
|
name: "Regulatory Review".to_string(),
|
||||||
|
description: "ZDFZ Business Registry review of submitted documents and compliance with regulatory requirements".to_string(),
|
||||||
|
order: 2,
|
||||||
|
status: StepStatus::InProgress,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(3)),
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-1-2-1".to_string(),
|
||||||
|
message: "Regulatory review initiated by ZDFZ Business Registry".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(3),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-1-2-2".to_string(),
|
||||||
|
message: "Preliminary compliance assessment completed".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(2),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-1-2-3".to_string(),
|
||||||
|
message: "Awaiting final approval from regulatory committee".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-1-3".to_string(),
|
||||||
|
name: "Digital Identity Creation".to_string(),
|
||||||
|
description: "Creation of the entity's digital identity and blockchain credentials within the ZDFZ ecosystem".to_string(),
|
||||||
|
order: 3,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-1-4".to_string(),
|
||||||
|
name: "License and Certificate Issuance".to_string(),
|
||||||
|
description: "Issuance of business licenses, certificates, and digital credentials".to_string(),
|
||||||
|
order: 4,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: Utc::now() - Duration::days(5),
|
||||||
|
updated_at: Utc::now() - Duration::days(1),
|
||||||
|
completed_at: None,
|
||||||
|
progress_percentage: 40,
|
||||||
|
current_step: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the current step
|
||||||
|
flow1.current_step = flow1.steps.iter().find(|s| s.status == StepStatus::InProgress).cloned();
|
||||||
|
|
||||||
|
let mut flow2 = Flow {
|
||||||
|
id: "flow-2".to_string(),
|
||||||
|
name: "Digital Asset Tokenization Approval".to_string(),
|
||||||
|
description: "Process for approving the tokenization of a real estate asset within the ZDFZ regulatory framework".to_string(),
|
||||||
|
flow_type: FlowType::AssetTokenization,
|
||||||
|
status: FlowStatus::Completed,
|
||||||
|
owner_id: "user-2".to_string(),
|
||||||
|
owner_name: "Amina Salim".to_string(),
|
||||||
|
steps: vec![
|
||||||
|
FlowStep {
|
||||||
|
id: "step-2-1".to_string(),
|
||||||
|
name: "Asset Verification".to_string(),
|
||||||
|
description: "Verification of the underlying asset ownership and valuation".to_string(),
|
||||||
|
order: 1,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(30)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(25)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-1-1".to_string(),
|
||||||
|
message: "Asset documentation submitted for verification".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(30),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-1-2".to_string(),
|
||||||
|
message: "Independent valuation completed by ZDFZ Property Registry".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(27),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-1-3".to_string(),
|
||||||
|
message: "Asset ownership and valuation verified".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(25),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-2-2".to_string(),
|
||||||
|
name: "Tokenization Structure Review".to_string(),
|
||||||
|
description: "Review of the proposed token structure, distribution model, and compliance with ZDFZ tokenization standards".to_string(),
|
||||||
|
order: 2,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(24)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(20)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-2-1".to_string(),
|
||||||
|
message: "Tokenization proposal submitted for review".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(24),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-2-2".to_string(),
|
||||||
|
message: "Technical review completed by ZDFZ Digital Assets Committee".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(22),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-2-3".to_string(),
|
||||||
|
message: "Tokenization structure approved with minor modifications".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(20),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-2-3".to_string(),
|
||||||
|
name: "Smart Contract Deployment".to_string(),
|
||||||
|
description: "Deployment and verification of the asset tokenization smart contracts".to_string(),
|
||||||
|
order: 3,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(19)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(15)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-3-1".to_string(),
|
||||||
|
message: "Smart contract code submitted for audit".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(19),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-3-2".to_string(),
|
||||||
|
message: "Security audit completed with no critical issues".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(17),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-3-3".to_string(),
|
||||||
|
message: "Smart contracts deployed to ZDFZ-approved blockchain".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(15),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-2-4".to_string(),
|
||||||
|
name: "Final Approval and Listing".to_string(),
|
||||||
|
description: "Final regulatory approval and listing on the ZDFZ Digital Asset Exchange".to_string(),
|
||||||
|
order: 4,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(14)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(10)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-4-1".to_string(),
|
||||||
|
message: "Final documentation package submitted for approval".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(14),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-4-2".to_string(),
|
||||||
|
message: "Regulatory approval granted by ZDFZ Financial Authority".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(12),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-2-4-3".to_string(),
|
||||||
|
message: "Asset tokens listed on ZDFZ Digital Asset Exchange".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(10),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: Utc::now() - Duration::days(30),
|
||||||
|
updated_at: Utc::now() - Duration::days(10),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(10)),
|
||||||
|
progress_percentage: 100,
|
||||||
|
current_step: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
flow2.current_step = flow2.steps.last().cloned();
|
||||||
|
|
||||||
|
let mut flow3 = Flow {
|
||||||
|
id: "flow-3".to_string(),
|
||||||
|
name: "Sustainable Tourism Certification".to_string(),
|
||||||
|
description: "Application process for ZDFZ Sustainable Tourism Certification for eco-tourism businesses".to_string(),
|
||||||
|
flow_type: FlowType::Certification,
|
||||||
|
status: FlowStatus::Stuck,
|
||||||
|
owner_id: "user-3".to_string(),
|
||||||
|
owner_name: "Hassan Mwinyi".to_string(),
|
||||||
|
steps: vec![
|
||||||
|
FlowStep {
|
||||||
|
id: "step-3-1".to_string(),
|
||||||
|
name: "Initial Application".to_string(),
|
||||||
|
description: "Submission of initial application and supporting documentation".to_string(),
|
||||||
|
order: 1,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(15)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(12)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-3-1-1".to_string(),
|
||||||
|
message: "Application submitted for Coral Reef Eco Tours".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(15),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-3-1-2".to_string(),
|
||||||
|
message: "Application fee payment confirmed".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(14),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-3-1-3".to_string(),
|
||||||
|
message: "Initial documentation review completed".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(12),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-3-2".to_string(),
|
||||||
|
name: "Environmental Impact Assessment".to_string(),
|
||||||
|
description: "Assessment of the business's environmental impact and sustainability practices".to_string(),
|
||||||
|
order: 2,
|
||||||
|
status: StepStatus::Stuck,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(11)),
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-3-2-1".to_string(),
|
||||||
|
message: "Environmental assessment initiated".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(11),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-3-2-2".to_string(),
|
||||||
|
message: "Site visit scheduled with environmental officer".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(9),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-3-2-3".to_string(),
|
||||||
|
message: "STUCK: Missing required marine conservation plan documentation".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(7),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-3-3".to_string(),
|
||||||
|
name: "Community Engagement Verification".to_string(),
|
||||||
|
description: "Verification of community engagement and benefit-sharing mechanisms".to_string(),
|
||||||
|
order: 3,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-3-4".to_string(),
|
||||||
|
name: "Certification Issuance".to_string(),
|
||||||
|
description: "Final review and issuance of ZDFZ Sustainable Tourism Certification".to_string(),
|
||||||
|
order: 4,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: Utc::now() - Duration::days(15),
|
||||||
|
updated_at: Utc::now() - Duration::days(7),
|
||||||
|
completed_at: None,
|
||||||
|
progress_percentage: 35,
|
||||||
|
current_step: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
flow3.current_step = flow3.steps.iter().find(|s| s.status == StepStatus::Stuck).cloned();
|
||||||
|
|
||||||
|
let mut flow4 = Flow {
|
||||||
|
id: "flow-4".to_string(),
|
||||||
|
name: "Digital Payment Provider License".to_string(),
|
||||||
|
description: "Application for a license to operate as a digital payment provider within the ZDFZ financial system".to_string(),
|
||||||
|
flow_type: FlowType::LicenseApplication,
|
||||||
|
status: FlowStatus::InProgress,
|
||||||
|
owner_id: "user-4".to_string(),
|
||||||
|
owner_name: "Fatma Busaidy".to_string(),
|
||||||
|
steps: vec![
|
||||||
|
FlowStep {
|
||||||
|
id: "step-4-1".to_string(),
|
||||||
|
name: "Initial Application".to_string(),
|
||||||
|
description: "Submission of license application and company information".to_string(),
|
||||||
|
order: 1,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(20)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(18)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-1-1".to_string(),
|
||||||
|
message: "Application submitted for ZanziPay digital payment services".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(20),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-1-2".to_string(),
|
||||||
|
message: "Application fee payment confirmed".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(19),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-1-3".to_string(),
|
||||||
|
message: "Initial documentation review completed".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(18),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-4-2".to_string(),
|
||||||
|
name: "Technical Infrastructure Review".to_string(),
|
||||||
|
description: "Review of the technical infrastructure, security measures, and compliance with ZDFZ financial standards".to_string(),
|
||||||
|
order: 2,
|
||||||
|
status: StepStatus::Completed,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(17)),
|
||||||
|
completed_at: Some(Utc::now() - Duration::days(10)),
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-2-1".to_string(),
|
||||||
|
message: "Technical documentation submitted for review".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(17),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-2-2".to_string(),
|
||||||
|
message: "Security audit initiated by ZDFZ Financial Technology Office".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(15),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-2-3".to_string(),
|
||||||
|
message: "Technical infrastructure approved with recommendations".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(10),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-4-3".to_string(),
|
||||||
|
name: "AML/KYC Compliance Review".to_string(),
|
||||||
|
description: "Review of anti-money laundering and know-your-customer procedures".to_string(),
|
||||||
|
order: 3,
|
||||||
|
status: StepStatus::InProgress,
|
||||||
|
started_at: Some(Utc::now() - Duration::days(9)),
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-3-1".to_string(),
|
||||||
|
message: "AML/KYC documentation submitted for review".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(9),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-3-2".to_string(),
|
||||||
|
message: "Initial compliance assessment completed".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(5),
|
||||||
|
},
|
||||||
|
FlowLog {
|
||||||
|
id: "log-4-3-3".to_string(),
|
||||||
|
message: "Additional KYC procedure documentation requested".to_string(),
|
||||||
|
timestamp: Utc::now() - Duration::days(3),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
FlowStep {
|
||||||
|
id: "step-4-4".to_string(),
|
||||||
|
name: "License Issuance".to_string(),
|
||||||
|
description: "Final review and issuance of Digital Payment Provider License".to_string(),
|
||||||
|
order: 4,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
logs: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
created_at: Utc::now() - Duration::days(20),
|
||||||
|
updated_at: Utc::now() - Duration::days(3),
|
||||||
|
completed_at: None,
|
||||||
|
progress_percentage: 65,
|
||||||
|
current_step: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
flow4.current_step = flow4.steps.iter().find(|s| s.status == StepStatus::InProgress).cloned();
|
||||||
|
|
||||||
|
flows.push(flow1);
|
||||||
|
flows.push(flow2);
|
||||||
|
flows.push(flow3);
|
||||||
|
flows.push(flow4);
|
||||||
|
|
||||||
|
flows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Form for creating a new flow
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct FlowForm {
|
||||||
|
/// Flow name
|
||||||
|
pub name: String,
|
||||||
|
/// Flow description
|
||||||
|
pub description: String,
|
||||||
|
/// Flow type
|
||||||
|
pub flow_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Form for marking a step as stuck
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct StuckForm {
|
||||||
|
/// Reason for being stuck
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Form for adding a log to a step
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct LogForm {
|
||||||
|
/// Log message
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
992
actix_mvc_app/src/controllers/governance.rs
Normal file
992
actix_mvc_app/src/controllers/governance.rs
Normal file
@@ -0,0 +1,992 @@
|
|||||||
|
use crate::db::governance::{
|
||||||
|
self, create_activity, get_all_activities, get_proposal_by_id, get_proposals,
|
||||||
|
get_recent_activities,
|
||||||
|
};
|
||||||
|
// Note: Now using heromodels directly instead of local governance models
|
||||||
|
use crate::utils::render_template;
|
||||||
|
use actix_session::Session;
|
||||||
|
use actix_web::{HttpResponse, Responder, Result, web};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use heromodels::models::ActivityType;
|
||||||
|
use heromodels::models::governance::{Proposal, ProposalStatus};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tera::Tera;
|
||||||
|
|
||||||
|
use chrono::prelude::*;
|
||||||
|
|
||||||
|
/// Simple vote type for UI display
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum VoteType {
|
||||||
|
Yes,
|
||||||
|
No,
|
||||||
|
Abstain,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple vote structure for UI display
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Vote {
|
||||||
|
pub id: String,
|
||||||
|
pub proposal_id: String,
|
||||||
|
pub voter_id: i32,
|
||||||
|
pub voter_name: String,
|
||||||
|
pub vote_type: VoteType,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vote {
|
||||||
|
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::Uuid::new_v4().to_string(),
|
||||||
|
proposal_id,
|
||||||
|
voter_id,
|
||||||
|
voter_name,
|
||||||
|
vote_type,
|
||||||
|
comment,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple voting results structure for UI display
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VotingResults {
|
||||||
|
pub proposal_id: String,
|
||||||
|
pub yes_count: usize,
|
||||||
|
pub no_count: usize,
|
||||||
|
pub abstain_count: usize,
|
||||||
|
pub total_votes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VotingResults {
|
||||||
|
pub fn new(proposal_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
proposal_id,
|
||||||
|
yes_count: 0,
|
||||||
|
no_count: 0,
|
||||||
|
abstain_count: 0,
|
||||||
|
total_votes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller for handling governance-related routes
|
||||||
|
pub struct GovernanceController;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl GovernanceController {
|
||||||
|
/// Helper function to get user from session
|
||||||
|
/// For testing purposes, this will always return a mock user
|
||||||
|
fn get_user_from_session(session: &Session) -> Option<Value> {
|
||||||
|
// Try to get user from session first
|
||||||
|
let session_user = session
|
||||||
|
.get::<String>("user")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|user_json| serde_json::from_str(&user_json).ok());
|
||||||
|
|
||||||
|
// If user is not in session, return a mock user for testing
|
||||||
|
session_user.or_else(|| {
|
||||||
|
// Create a mock user
|
||||||
|
let mock_user = serde_json::json!({
|
||||||
|
"id": 1,
|
||||||
|
"username": "test_user",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"name": "Test User",
|
||||||
|
"role": "member"
|
||||||
|
});
|
||||||
|
Some(mock_user)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate statistics from the database
|
||||||
|
fn calculate_statistics_from_database(proposals: &[Proposal]) -> GovernanceStats {
|
||||||
|
let mut stats = GovernanceStats {
|
||||||
|
total_proposals: proposals.len(),
|
||||||
|
active_proposals: 0,
|
||||||
|
approved_proposals: 0,
|
||||||
|
rejected_proposals: 0,
|
||||||
|
draft_proposals: 0,
|
||||||
|
total_votes: 0,
|
||||||
|
participation_rate: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count proposals by status
|
||||||
|
for proposal in proposals {
|
||||||
|
match proposal.status {
|
||||||
|
ProposalStatus::Active => stats.active_proposals += 1,
|
||||||
|
ProposalStatus::Approved => stats.approved_proposals += 1,
|
||||||
|
ProposalStatus::Rejected => stats.rejected_proposals += 1,
|
||||||
|
ProposalStatus::Draft => stats.draft_proposals += 1,
|
||||||
|
_ => {} // Handle other statuses if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total votes
|
||||||
|
stats.total_votes += proposal.ballots.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate participation rate (if there are any proposals)
|
||||||
|
if stats.total_proposals > 0 {
|
||||||
|
// This is a simplified calculation - in a real application, you would
|
||||||
|
// calculate this based on the number of eligible voters
|
||||||
|
stats.participation_rate =
|
||||||
|
(stats.total_votes as f64 / stats.total_proposals as f64) * 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the governance dashboard page route
|
||||||
|
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();
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
|
||||||
|
// Get proposals from the database
|
||||||
|
let proposals = match crate::db::governance::get_proposals() {
|
||||||
|
Ok(props) => {
|
||||||
|
// println!(
|
||||||
|
// "📋 Proposals list page: Successfully loaded {} proposals from database",
|
||||||
|
// props.len()
|
||||||
|
// );
|
||||||
|
for (i, proposal) in props.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
" Proposal {}: ID={}, title={:?}, status={:?}",
|
||||||
|
i + 1,
|
||||||
|
proposal.base_data.id,
|
||||||
|
proposal.title,
|
||||||
|
proposal.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
props
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Proposals list page: Failed to load proposals: {}", e);
|
||||||
|
ctx.insert("error", &format!("Failed to load proposals: {}", e));
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make a copy of proposals for statistics
|
||||||
|
let proposals_for_stats = proposals.clone();
|
||||||
|
|
||||||
|
// Filter for active proposals only
|
||||||
|
let active_proposals: Vec<heromodels::models::Proposal> = proposals
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p.status == heromodels::models::ProposalStatus::Active)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort active proposals by voting end date (ascending)
|
||||||
|
let mut sorted_active_proposals = active_proposals.clone();
|
||||||
|
sorted_active_proposals.sort_by(|a, b| a.vote_start_date.cmp(&b.vote_end_date));
|
||||||
|
|
||||||
|
ctx.insert("proposals", &sorted_active_proposals);
|
||||||
|
|
||||||
|
// Get the nearest deadline proposal for the voting pane
|
||||||
|
if let Some(nearest_proposal) = sorted_active_proposals.first() {
|
||||||
|
// Calculate voting results for the nearest proposal
|
||||||
|
let results = Self::calculate_voting_results_from_proposal(nearest_proposal);
|
||||||
|
|
||||||
|
// Add both the proposal and its results to the context
|
||||||
|
ctx.insert("nearest_proposal", nearest_proposal);
|
||||||
|
ctx.insert("nearest_proposal_results", &results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate statistics from the database
|
||||||
|
let stats = Self::calculate_statistics_from_database(&proposals_for_stats);
|
||||||
|
ctx.insert("stats", &stats);
|
||||||
|
|
||||||
|
// Get recent governance activities from our tracker (limit to 4 for dashboard)
|
||||||
|
let recent_activity = match Self::get_recent_governance_activities() {
|
||||||
|
Ok(activities) => activities.into_iter().take(4).collect::<Vec<_>>(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to load recent activities: {}", e);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ctx.insert("recent_activity", &recent_activity);
|
||||||
|
|
||||||
|
render_template(&tmpl, "governance/index.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the proposal list page route
|
||||||
|
pub async fn proposals(
|
||||||
|
query: web::Query<ProposalQuery>,
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<impl Responder> {
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get proposals from the database
|
||||||
|
let mut proposals = match get_proposals() {
|
||||||
|
Ok(props) => props,
|
||||||
|
Err(e) => {
|
||||||
|
ctx.insert("error", &format!("Failed to load proposals: {}", e));
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter proposals by status if provided
|
||||||
|
if let Some(status_filter) = &query.status {
|
||||||
|
if !status_filter.is_empty() {
|
||||||
|
proposals = proposals
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| {
|
||||||
|
let proposal_status = format!("{:?}", p.status);
|
||||||
|
proposal_status == *status_filter
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search term if provided (title or description)
|
||||||
|
if let Some(search_term) = &query.search {
|
||||||
|
if !search_term.is_empty() {
|
||||||
|
let search_term = search_term.to_lowercase();
|
||||||
|
proposals = proposals
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| {
|
||||||
|
p.title.to_lowercase().contains(&search_term)
|
||||||
|
|| p.description.to_lowercase().contains(&search_term)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the filtered proposals to the context
|
||||||
|
ctx.insert("proposals", &proposals);
|
||||||
|
|
||||||
|
// Add the filter values back to the context for form persistence
|
||||||
|
ctx.insert("status_filter", &query.status);
|
||||||
|
ctx.insert("search_filter", &query.search);
|
||||||
|
|
||||||
|
render_template(&tmpl, "governance/proposals.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the proposal detail page route
|
||||||
|
pub async fn proposal_detail(
|
||||||
|
path: web::Path<String>,
|
||||||
|
req: actix_web::HttpRequest,
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<impl Responder> {
|
||||||
|
// Extract query parameters from the request
|
||||||
|
let query_str = req.query_string();
|
||||||
|
let vote_success = query_str.contains("vote_success=true");
|
||||||
|
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) {
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mock proposal detail
|
||||||
|
let proposal = get_proposal_by_id(proposal_id.parse().unwrap());
|
||||||
|
if let Ok(Some(proposal)) = proposal {
|
||||||
|
ctx.insert("proposal", &proposal);
|
||||||
|
|
||||||
|
// Extract votes directly from the proposal
|
||||||
|
let votes = Self::extract_votes_from_proposal(&proposal);
|
||||||
|
ctx.insert("votes", &votes);
|
||||||
|
|
||||||
|
// Calculate voting results directly from the proposal
|
||||||
|
let results = Self::calculate_voting_results_from_proposal(&proposal);
|
||||||
|
ctx.insert("results", &results);
|
||||||
|
|
||||||
|
// Check if vote_success parameter is present and add success message
|
||||||
|
if vote_success {
|
||||||
|
ctx.insert("success", "Your vote has been successfully recorded!");
|
||||||
|
}
|
||||||
|
|
||||||
|
render_template(&tmpl, "governance/proposal_detail.html", &ctx)
|
||||||
|
} else {
|
||||||
|
// Proposal not found
|
||||||
|
ctx.insert("error", "Proposal not found");
|
||||||
|
// For the error page, we'll use a special case to set the status code to 404
|
||||||
|
match tmpl.render("error.html", &ctx) {
|
||||||
|
Ok(content) => Ok(HttpResponse::NotFound()
|
||||||
|
.content_type("text/html")
|
||||||
|
.body(content)),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error rendering error template: {}", e);
|
||||||
|
Err(actix_web::error::ErrorInternalServerError(format!(
|
||||||
|
"Error: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the create proposal page route
|
||||||
|
pub async fn create_proposal_form(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<impl Responder> {
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
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);
|
||||||
|
|
||||||
|
render_template(&tmpl, "governance/create_proposal.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the submission of a new proposal
|
||||||
|
pub async fn submit_proposal(
|
||||||
|
_form: web::Form<ProposalForm>,
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<impl Responder> {
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "governance");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
let proposal_title = &_form.title;
|
||||||
|
let proposal_description = &_form.description;
|
||||||
|
|
||||||
|
// Use the DB-backed proposal creation
|
||||||
|
// Parse voting_start_date and voting_end_date from the form (YYYY-MM-DD expected)
|
||||||
|
let voting_start_date = _form.voting_start_date.as_ref().and_then(|s| {
|
||||||
|
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| d.and_hms_opt(0, 0, 0))
|
||||||
|
.map(|naive| chrono::Utc.from_utc_datetime(&naive))
|
||||||
|
});
|
||||||
|
let voting_end_date = _form.voting_end_date.as_ref().and_then(|s| {
|
||||||
|
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| d.and_hms_opt(23, 59, 59))
|
||||||
|
.map(|naive| chrono::Utc.from_utc_datetime(&naive))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract user id and name from serde_json::Value
|
||||||
|
let user_id = user
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(1)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let user_name = user
|
||||||
|
.get("username")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Test User")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let is_draft = _form.draft.is_some();
|
||||||
|
let status = if is_draft {
|
||||||
|
ProposalStatus::Draft
|
||||||
|
} else {
|
||||||
|
ProposalStatus::Active
|
||||||
|
};
|
||||||
|
match governance::create_new_proposal(
|
||||||
|
&user_id,
|
||||||
|
&user_name,
|
||||||
|
proposal_title,
|
||||||
|
proposal_description,
|
||||||
|
status,
|
||||||
|
voting_start_date,
|
||||||
|
voting_end_date,
|
||||||
|
) {
|
||||||
|
Ok((proposal_id, saved_proposal)) => {
|
||||||
|
println!(
|
||||||
|
"Proposal saved to DB: ID={}, title={:?}",
|
||||||
|
proposal_id, saved_proposal.title
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track the proposal creation activity
|
||||||
|
let _ = create_activity(
|
||||||
|
proposal_id,
|
||||||
|
&saved_proposal.title,
|
||||||
|
&user_name,
|
||||||
|
&ActivityType::ProposalCreated,
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.insert("success", "Proposal created successfully!");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
println!("Failed to save proposal: {err}");
|
||||||
|
ctx.insert("error", &format!("Failed to save proposal: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, we'll just redirect to the proposals page with a success message
|
||||||
|
|
||||||
|
// Get proposals from the database
|
||||||
|
let proposals = match crate::db::governance::get_proposals() {
|
||||||
|
Ok(props) => {
|
||||||
|
println!(
|
||||||
|
"✅ Successfully loaded {} proposals from database",
|
||||||
|
props.len()
|
||||||
|
);
|
||||||
|
for (i, proposal) in props.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
" Proposal {}: ID={}, title={:?}, status={:?}",
|
||||||
|
i + 1,
|
||||||
|
proposal.base_data.id,
|
||||||
|
proposal.title,
|
||||||
|
proposal.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
props
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Failed to load proposals: {}", e);
|
||||||
|
ctx.insert("error", &format!("Failed to load proposals: {}", e));
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ctx.insert("proposals", &proposals);
|
||||||
|
|
||||||
|
// Add the required context variables for the proposals template
|
||||||
|
ctx.insert("active_tab", "proposals");
|
||||||
|
ctx.insert("status_filter", &None::<String>);
|
||||||
|
ctx.insert("search_filter", &None::<String>);
|
||||||
|
|
||||||
|
// Header data (required by _header.html template)
|
||||||
|
ctx.insert("page_title", "All Proposals");
|
||||||
|
ctx.insert(
|
||||||
|
"page_description",
|
||||||
|
"Browse and filter all governance proposals",
|
||||||
|
);
|
||||||
|
ctx.insert("show_create_button", &false);
|
||||||
|
|
||||||
|
render_template(&tmpl, "governance/proposals.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the submission of a vote on a proposal
|
||||||
|
pub async fn submit_vote(
|
||||||
|
path: web::Path<String>,
|
||||||
|
form: web::Form<VoteForm>,
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<impl Responder> {
|
||||||
|
let proposal_id = path.into_inner();
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("active_page", "governance");
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
|
let user = match Self::get_user_from_session(&session) {
|
||||||
|
Some(user) => user,
|
||||||
|
None => {
|
||||||
|
return Ok(HttpResponse::Found()
|
||||||
|
.append_header(("Location", "/login"))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ctx.insert("user", &user);
|
||||||
|
|
||||||
|
// Extract user ID
|
||||||
|
let user_id = user.get("id").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
|
||||||
|
|
||||||
|
// Parse proposal ID
|
||||||
|
let proposal_id_u32 = match proposal_id.parse::<u32>() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
ctx.insert("error", "Invalid proposal ID");
|
||||||
|
return render_template(&tmpl, "error.html", &ctx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit the vote
|
||||||
|
match crate::db::governance::submit_vote_on_proposal(
|
||||||
|
proposal_id_u32,
|
||||||
|
user_id,
|
||||||
|
&form.vote_type,
|
||||||
|
1, // Default to 1 share
|
||||||
|
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 _ = create_activity(
|
||||||
|
proposal_id_u32,
|
||||||
|
&proposal.title,
|
||||||
|
user_name,
|
||||||
|
&ActivityType::VoteCast,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the proposal detail page with a success message
|
||||||
|
return Ok(HttpResponse::Found()
|
||||||
|
.append_header((
|
||||||
|
"Location",
|
||||||
|
format!("/governance/proposals/{}?vote_success=true", proposal_id),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
ctx.insert("error", &format!("Failed to submit vote: {}", e));
|
||||||
|
render_template(&tmpl, "error.html", &ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the my votes page route
|
||||||
|
pub async fn my_votes(tmpl: web::Data<Tera>, session: Session) -> Result<impl Responder> {
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Extract user ID
|
||||||
|
let user_id = user.get("id").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
|
||||||
|
|
||||||
|
// Get all proposals from the database
|
||||||
|
let proposals = match crate::db::governance::get_proposals() {
|
||||||
|
Ok(props) => props,
|
||||||
|
Err(e) => {
|
||||||
|
ctx.insert("error", &format!("Failed to load proposals: {}", e));
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract votes for this user from all proposals
|
||||||
|
let mut user_votes = Vec::new();
|
||||||
|
for proposal in &proposals {
|
||||||
|
// Extract votes from this proposal
|
||||||
|
let votes = Self::extract_votes_from_proposal(proposal);
|
||||||
|
|
||||||
|
// Filter votes for this user
|
||||||
|
for vote in votes {
|
||||||
|
if vote.voter_id == user_id {
|
||||||
|
user_votes.push((vote, proposal.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total vote counts for all proposals
|
||||||
|
let total_vote_counts = Self::calculate_total_vote_counts(&proposals);
|
||||||
|
ctx.insert("total_yes_votes", &total_vote_counts.0);
|
||||||
|
ctx.insert("total_no_votes", &total_vote_counts.1);
|
||||||
|
ctx.insert("total_abstain_votes", &total_vote_counts.2);
|
||||||
|
|
||||||
|
ctx.insert("votes", &user_votes);
|
||||||
|
|
||||||
|
render_template(&tmpl, "governance/my_votes.html", &ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = 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.creator_name,
|
||||||
|
"action": action,
|
||||||
|
"proposal_title": activity.proposal_title,
|
||||||
|
"created_at": activity.created_at.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 = 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.creator_name,
|
||||||
|
"action": action,
|
||||||
|
"proposal_title": activity.proposal_title,
|
||||||
|
"created_at": activity.created_at.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> {
|
||||||
|
let now = Utc::now();
|
||||||
|
vec![
|
||||||
|
Vote {
|
||||||
|
id: "vote-001".to_string(),
|
||||||
|
proposal_id: proposal_id.to_string(),
|
||||||
|
voter_id: 1,
|
||||||
|
voter_name: "Robert Callingham".to_string(),
|
||||||
|
vote_type: VoteType::Yes,
|
||||||
|
comment: Some("I strongly support this initiative.".to_string()),
|
||||||
|
created_at: now - Duration::days(2),
|
||||||
|
updated_at: now - Duration::days(2),
|
||||||
|
},
|
||||||
|
Vote {
|
||||||
|
id: "vote-002".to_string(),
|
||||||
|
proposal_id: proposal_id.to_string(),
|
||||||
|
voter_id: 2,
|
||||||
|
voter_name: "Jane Smith".to_string(),
|
||||||
|
vote_type: VoteType::Yes,
|
||||||
|
comment: None,
|
||||||
|
created_at: now - Duration::days(2),
|
||||||
|
updated_at: now - Duration::days(2),
|
||||||
|
},
|
||||||
|
Vote {
|
||||||
|
id: "vote-003".to_string(),
|
||||||
|
proposal_id: proposal_id.to_string(),
|
||||||
|
voter_id: 3,
|
||||||
|
voter_name: "Bob Johnson".to_string(),
|
||||||
|
vote_type: VoteType::No,
|
||||||
|
comment: Some("I have concerns about the implementation cost.".to_string()),
|
||||||
|
created_at: now - Duration::days(1),
|
||||||
|
updated_at: now - Duration::days(1),
|
||||||
|
},
|
||||||
|
Vote {
|
||||||
|
id: "vote-004".to_string(),
|
||||||
|
proposal_id: proposal_id.to_string(),
|
||||||
|
voter_id: 4,
|
||||||
|
voter_name: "Alice Williams".to_string(),
|
||||||
|
vote_type: VoteType::Abstain,
|
||||||
|
comment: Some("I need more information before making a decision.".to_string()),
|
||||||
|
created_at: now - Duration::hours(12),
|
||||||
|
updated_at: now - Duration::hours(12),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate voting results from a proposal
|
||||||
|
fn calculate_voting_results_from_proposal(proposal: &Proposal) -> VotingResults {
|
||||||
|
let mut results = VotingResults::new(proposal.base_data.id.to_string());
|
||||||
|
|
||||||
|
// Count votes for each option
|
||||||
|
for option in &proposal.options {
|
||||||
|
match option.id {
|
||||||
|
1 => results.yes_count = option.count as usize,
|
||||||
|
2 => results.no_count = option.count as usize,
|
||||||
|
3 => results.abstain_count = option.count as usize,
|
||||||
|
_ => {} // Ignore other options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total votes
|
||||||
|
results.total_votes = results.yes_count + results.no_count + results.abstain_count;
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract votes from a proposal's ballots
|
||||||
|
fn extract_votes_from_proposal(proposal: &Proposal) -> Vec<Vote> {
|
||||||
|
let mut votes = Vec::new();
|
||||||
|
|
||||||
|
// Debug: Print proposal ID and number of ballots
|
||||||
|
println!(
|
||||||
|
"Extracting votes from proposal ID: {}",
|
||||||
|
proposal.base_data.id
|
||||||
|
);
|
||||||
|
println!("Number of ballots in proposal: {}", proposal.ballots.len());
|
||||||
|
|
||||||
|
// If there are no ballots, create some mock votes for testing
|
||||||
|
if proposal.ballots.is_empty() {
|
||||||
|
println!("No ballots found in proposal, creating mock votes for testing");
|
||||||
|
|
||||||
|
// Create mock votes based on the option counts
|
||||||
|
for option in &proposal.options {
|
||||||
|
if option.count > 0 {
|
||||||
|
let vote_type = match option.id {
|
||||||
|
1 => VoteType::Yes,
|
||||||
|
2 => VoteType::No,
|
||||||
|
3 => VoteType::Abstain,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a mock vote for each count
|
||||||
|
for i in 0..option.count {
|
||||||
|
let vote = Vote::new(
|
||||||
|
proposal.base_data.id.to_string(),
|
||||||
|
i as i32 + 1,
|
||||||
|
format!("User {}", i + 1),
|
||||||
|
vote_type.clone(),
|
||||||
|
option.comment.clone(),
|
||||||
|
);
|
||||||
|
votes.push(vote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Created {} mock votes", votes.len());
|
||||||
|
return votes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each ballot to a Vote
|
||||||
|
for (i, ballot) in proposal.ballots.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
"Processing ballot {}: user_id={}, option_id={}, shares={}",
|
||||||
|
i, ballot.user_id, ballot.vote_option_id, ballot.shares_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map option_id to VoteType
|
||||||
|
let vote_type = match ballot.vote_option_id {
|
||||||
|
1 => VoteType::Yes,
|
||||||
|
2 => VoteType::No,
|
||||||
|
3 => VoteType::Abstain,
|
||||||
|
_ => {
|
||||||
|
println!(
|
||||||
|
"Unknown option_id: {}, defaulting to Abstain",
|
||||||
|
ballot.vote_option_id
|
||||||
|
);
|
||||||
|
VoteType::Abstain // Default to Abstain for unknown options
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert user_id from u32 to i32 safely
|
||||||
|
let voter_id = match i32::try_from(ballot.user_id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to convert user_id {} to i32: {}", ballot.user_id, e);
|
||||||
|
continue; // Skip this ballot if conversion fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ballot_timestamp =
|
||||||
|
match chrono::DateTime::from_timestamp(ballot.base_data.created_at, 0) {
|
||||||
|
Some(dt) => dt,
|
||||||
|
None => {
|
||||||
|
println!(
|
||||||
|
"Warning: Invalid timestamp {} for ballot, using current time",
|
||||||
|
ballot.base_data.created_at
|
||||||
|
);
|
||||||
|
Utc::now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let vote = Vote {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
proposal_id: proposal.base_data.id.to_string(),
|
||||||
|
voter_id,
|
||||||
|
voter_name: format!("User {}", voter_id),
|
||||||
|
vote_type,
|
||||||
|
comment: ballot.comment.clone(),
|
||||||
|
created_at: ballot_timestamp, // This is already local time
|
||||||
|
updated_at: ballot_timestamp, // Same as created_at for votes
|
||||||
|
};
|
||||||
|
|
||||||
|
votes.push(vote);
|
||||||
|
}
|
||||||
|
votes
|
||||||
|
}
|
||||||
|
|
||||||
|
// The calculate_statistics_from_database function is now defined at the top of the impl block
|
||||||
|
|
||||||
|
/// Calculate total vote counts across all proposals
|
||||||
|
/// Returns a tuple of (yes_count, no_count, abstain_count)
|
||||||
|
fn calculate_total_vote_counts(proposals: &[Proposal]) -> (usize, usize, usize) {
|
||||||
|
let mut yes_count = 0;
|
||||||
|
let mut no_count = 0;
|
||||||
|
let mut abstain_count = 0;
|
||||||
|
|
||||||
|
for proposal in proposals {
|
||||||
|
// Extract votes from this proposal
|
||||||
|
let votes = Self::extract_votes_from_proposal(proposal);
|
||||||
|
|
||||||
|
// Count votes by type
|
||||||
|
for vote in votes {
|
||||||
|
match vote.vote_type {
|
||||||
|
VoteType::Yes => yes_count += 1,
|
||||||
|
VoteType::No => no_count += 1,
|
||||||
|
VoteType::Abstain => abstain_count += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(yes_count, no_count, abstain_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the data submitted in the proposal form
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ProposalForm {
|
||||||
|
/// Title of the proposal
|
||||||
|
pub title: String,
|
||||||
|
/// Description of the proposal
|
||||||
|
pub description: String,
|
||||||
|
/// Status of the proposal
|
||||||
|
pub draft: Option<bool>,
|
||||||
|
/// Start date for voting
|
||||||
|
pub voting_start_date: Option<String>,
|
||||||
|
/// End date for voting
|
||||||
|
pub voting_end_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the data submitted in the vote form
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct VoteForm {
|
||||||
|
/// Type of vote (yes, no, abstain)
|
||||||
|
pub vote_type: String,
|
||||||
|
/// Optional comment explaining the vote
|
||||||
|
pub comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for filtering proposals
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ProposalQuery {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents statistics for the governance dashboard
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GovernanceStats {
|
||||||
|
/// Total number of proposals
|
||||||
|
pub total_proposals: usize,
|
||||||
|
/// Number of active proposals
|
||||||
|
pub active_proposals: usize,
|
||||||
|
/// Number of approved proposals
|
||||||
|
pub approved_proposals: usize,
|
||||||
|
/// Number of rejected proposals
|
||||||
|
pub rejected_proposals: usize,
|
||||||
|
/// Number of draft proposals
|
||||||
|
pub draft_proposals: usize,
|
||||||
|
/// Total number of votes cast
|
||||||
|
pub total_votes: usize,
|
||||||
|
/// Participation rate (percentage)
|
||||||
|
pub participation_rate: f64,
|
||||||
|
}
|
||||||
418
actix_mvc_app/src/controllers/health.rs
Normal file
418
actix_mvc_app/src/controllers/health.rs
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
use actix_web::{HttpResponse, Result, web};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HealthStatus {
|
||||||
|
pub status: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub version: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub checks: Vec<HealthCheck>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HealthCheck {
|
||||||
|
pub name: String,
|
||||||
|
pub status: String,
|
||||||
|
pub response_time_ms: u64,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub details: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthStatus {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
status: "unknown".to_string(),
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
uptime_seconds: 0,
|
||||||
|
checks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_uptime(&mut self, uptime: Duration) {
|
||||||
|
self.uptime_seconds = uptime.as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_check(&mut self, check: HealthCheck) {
|
||||||
|
self.checks.push(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calculate_overall_status(&mut self) {
|
||||||
|
let all_healthy = self.checks.iter().all(|check| check.status == "healthy");
|
||||||
|
let any_degraded = self.checks.iter().any(|check| check.status == "degraded");
|
||||||
|
|
||||||
|
self.status = if all_healthy {
|
||||||
|
"healthy".to_string()
|
||||||
|
} else if any_degraded {
|
||||||
|
"degraded".to_string()
|
||||||
|
} else {
|
||||||
|
"unhealthy".to_string()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthCheck {
|
||||||
|
pub fn new(name: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
status: "unknown".to_string(),
|
||||||
|
response_time_ms: 0,
|
||||||
|
message: None,
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn healthy(name: &str, response_time_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
status: "healthy".to_string(),
|
||||||
|
response_time_ms,
|
||||||
|
message: Some("OK".to_string()),
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn degraded(name: &str, response_time_ms: u64, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
status: "degraded".to_string(),
|
||||||
|
response_time_ms,
|
||||||
|
message: Some(message.to_string()),
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unhealthy(name: &str, response_time_ms: u64, error: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
status: "unhealthy".to_string(),
|
||||||
|
response_time_ms,
|
||||||
|
message: Some(error.to_string()),
|
||||||
|
details: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
||||||
|
self.details = Some(details);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check endpoint
|
||||||
|
pub async fn health_check() -> Result<HttpResponse> {
|
||||||
|
let start_time = Instant::now();
|
||||||
|
let mut status = HealthStatus::new();
|
||||||
|
|
||||||
|
// Set uptime (in a real app, you'd track this from startup)
|
||||||
|
status.set_uptime(Duration::from_secs(3600)); // Placeholder
|
||||||
|
|
||||||
|
// Check database connectivity
|
||||||
|
let db_check = check_database_health().await;
|
||||||
|
status.add_check(db_check);
|
||||||
|
|
||||||
|
// Check Redis connectivity
|
||||||
|
let redis_check = check_redis_health().await;
|
||||||
|
status.add_check(redis_check);
|
||||||
|
|
||||||
|
// Check Stripe connectivity
|
||||||
|
let stripe_check = check_stripe_health().await;
|
||||||
|
status.add_check(stripe_check);
|
||||||
|
|
||||||
|
// Check file system
|
||||||
|
let fs_check = check_filesystem_health().await;
|
||||||
|
status.add_check(fs_check);
|
||||||
|
|
||||||
|
// Check memory usage
|
||||||
|
let memory_check = check_memory_health().await;
|
||||||
|
status.add_check(memory_check);
|
||||||
|
|
||||||
|
// Calculate overall status
|
||||||
|
status.calculate_overall_status();
|
||||||
|
|
||||||
|
let response_code = match status.status.as_str() {
|
||||||
|
"healthy" => 200,
|
||||||
|
"degraded" => 200, // Still operational
|
||||||
|
_ => 503, // Service unavailable
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Health check completed in {}ms - Status: {}",
|
||||||
|
start_time.elapsed().as_millis(),
|
||||||
|
status.status
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
HttpResponse::build(actix_web::http::StatusCode::from_u16(response_code).unwrap())
|
||||||
|
.json(status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detailed health check endpoint for monitoring systems
|
||||||
|
pub async fn health_check_detailed() -> Result<HttpResponse> {
|
||||||
|
let start_time = Instant::now();
|
||||||
|
let mut status = HealthStatus::new();
|
||||||
|
|
||||||
|
// Set uptime
|
||||||
|
status.set_uptime(Duration::from_secs(3600)); // Placeholder
|
||||||
|
|
||||||
|
// Detailed database check
|
||||||
|
let db_check = check_database_health_detailed().await;
|
||||||
|
status.add_check(db_check);
|
||||||
|
|
||||||
|
// Detailed Redis check
|
||||||
|
let redis_check = check_redis_health_detailed().await;
|
||||||
|
status.add_check(redis_check);
|
||||||
|
|
||||||
|
// Detailed Stripe check
|
||||||
|
let stripe_check = check_stripe_health_detailed().await;
|
||||||
|
status.add_check(stripe_check);
|
||||||
|
|
||||||
|
// Check external dependencies
|
||||||
|
let external_check = check_external_dependencies().await;
|
||||||
|
status.add_check(external_check);
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
let perf_check = check_performance_metrics().await;
|
||||||
|
status.add_check(perf_check);
|
||||||
|
|
||||||
|
status.calculate_overall_status();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Detailed health check completed in {}ms - Status: {}",
|
||||||
|
start_time.elapsed().as_millis(),
|
||||||
|
status.status
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple readiness check for load balancers
|
||||||
|
pub async fn readiness_check() -> Result<HttpResponse> {
|
||||||
|
// Quick checks for essential services
|
||||||
|
let db_ok = check_database_connectivity().await;
|
||||||
|
let redis_ok = check_redis_connectivity().await;
|
||||||
|
|
||||||
|
if db_ok && redis_ok {
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"status": "ready",
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||||
|
})))
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::ServiceUnavailable().json(serde_json::json!({
|
||||||
|
"status": "not_ready",
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple liveness check
|
||||||
|
pub async fn liveness_check() -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"status": "alive",
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"version": env!("CARGO_PKG_VERSION")
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check implementations
|
||||||
|
|
||||||
|
async fn check_database_health() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
match crate::db::db::get_db() {
|
||||||
|
Ok(_) => HealthCheck::healthy("database", start.elapsed().as_millis() as u64),
|
||||||
|
Err(e) => HealthCheck::unhealthy(
|
||||||
|
"database",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
&format!("Database connection failed: {}", e),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_database_health_detailed() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
match crate::db::db::get_db() {
|
||||||
|
Ok(db) => {
|
||||||
|
// Try to perform a simple operation
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"connection_pool_size": "N/A", // Would need to expose from heromodels
|
||||||
|
"active_connections": "N/A",
|
||||||
|
"database_size": "N/A"
|
||||||
|
});
|
||||||
|
|
||||||
|
HealthCheck::healthy("database", start.elapsed().as_millis() as u64)
|
||||||
|
.with_details(details)
|
||||||
|
}
|
||||||
|
Err(e) => HealthCheck::unhealthy(
|
||||||
|
"database",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
&format!("Database connection failed: {}", e),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_redis_health() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Try to connect to Redis
|
||||||
|
match crate::utils::redis_service::get_connection() {
|
||||||
|
Ok(_) => HealthCheck::healthy("redis", start.elapsed().as_millis() as u64),
|
||||||
|
Err(e) => HealthCheck::unhealthy(
|
||||||
|
"redis",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
&format!("Redis connection failed: {}", e),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_redis_health_detailed() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
match crate::utils::redis_service::get_connection() {
|
||||||
|
Ok(_) => {
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"connection_status": "connected",
|
||||||
|
"memory_usage": "N/A",
|
||||||
|
"connected_clients": "N/A"
|
||||||
|
});
|
||||||
|
|
||||||
|
HealthCheck::healthy("redis", start.elapsed().as_millis() as u64).with_details(details)
|
||||||
|
}
|
||||||
|
Err(e) => HealthCheck::unhealthy(
|
||||||
|
"redis",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
&format!("Redis connection failed: {}", e),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_stripe_health() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Check if Stripe configuration is available
|
||||||
|
let config = crate::config::get_config();
|
||||||
|
if !config.stripe.secret_key.is_empty() {
|
||||||
|
HealthCheck::healthy("stripe", start.elapsed().as_millis() as u64)
|
||||||
|
} else {
|
||||||
|
HealthCheck::degraded(
|
||||||
|
"stripe",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
"Stripe secret key not configured",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_stripe_health_detailed() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let config = crate::config::get_config();
|
||||||
|
let has_secret = !config.stripe.secret_key.is_empty();
|
||||||
|
let has_webhook_secret = config.stripe.webhook_secret.is_some();
|
||||||
|
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"secret_key_configured": has_secret,
|
||||||
|
"webhook_secret_configured": has_webhook_secret,
|
||||||
|
"api_version": "2023-10-16" // Current Stripe API version
|
||||||
|
});
|
||||||
|
|
||||||
|
if has_secret && has_webhook_secret {
|
||||||
|
HealthCheck::healthy("stripe", start.elapsed().as_millis() as u64).with_details(details)
|
||||||
|
} else {
|
||||||
|
HealthCheck::degraded(
|
||||||
|
"stripe",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
"Stripe configuration incomplete",
|
||||||
|
)
|
||||||
|
.with_details(details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_filesystem_health() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Check if we can write to the data directory
|
||||||
|
match std::fs::create_dir_all("data") {
|
||||||
|
Ok(_) => {
|
||||||
|
// Try to write a test file
|
||||||
|
match std::fs::write("data/.health_check", "test") {
|
||||||
|
Ok(_) => {
|
||||||
|
// Clean up
|
||||||
|
let _ = std::fs::remove_file("data/.health_check");
|
||||||
|
HealthCheck::healthy("filesystem", start.elapsed().as_millis() as u64)
|
||||||
|
}
|
||||||
|
Err(e) => HealthCheck::unhealthy(
|
||||||
|
"filesystem",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
&format!("Cannot write to data directory: {}", e),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => HealthCheck::unhealthy(
|
||||||
|
"filesystem",
|
||||||
|
start.elapsed().as_millis() as u64,
|
||||||
|
&format!("Cannot create data directory: {}", e),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_memory_health() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Basic memory check (in a real app, you'd use system metrics)
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"status": "basic_check_only",
|
||||||
|
"note": "Detailed memory metrics require system integration"
|
||||||
|
});
|
||||||
|
|
||||||
|
HealthCheck::healthy("memory", start.elapsed().as_millis() as u64).with_details(details)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_external_dependencies() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Check external services (placeholder)
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"external_apis": "not_implemented",
|
||||||
|
"third_party_services": "not_implemented"
|
||||||
|
});
|
||||||
|
|
||||||
|
HealthCheck::healthy("external_dependencies", start.elapsed().as_millis() as u64)
|
||||||
|
.with_details(details)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_performance_metrics() -> HealthCheck {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let details = serde_json::json!({
|
||||||
|
"avg_response_time_ms": "N/A",
|
||||||
|
"requests_per_second": "N/A",
|
||||||
|
"error_rate": "N/A",
|
||||||
|
"cpu_usage": "N/A"
|
||||||
|
});
|
||||||
|
|
||||||
|
HealthCheck::healthy("performance", start.elapsed().as_millis() as u64).with_details(details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick connectivity checks for readiness
|
||||||
|
|
||||||
|
async fn check_database_connectivity() -> bool {
|
||||||
|
crate::db::db::get_db().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_redis_connectivity() -> bool {
|
||||||
|
crate::utils::redis_service::get_connection().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure health check routes
|
||||||
|
pub fn configure_health_routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/health")
|
||||||
|
.route("", web::get().to(health_check))
|
||||||
|
.route("/detailed", web::get().to(health_check_detailed))
|
||||||
|
.route("/ready", web::get().to(readiness_check))
|
||||||
|
.route("/live", web::get().to(liveness_check)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
use actix_web::{web, HttpResponse, Responder, Result};
|
use actix_web::{web, Responder, Result};
|
||||||
use actix_session::Session;
|
use actix_session::Session;
|
||||||
use tera::Tera;
|
use tera::Tera;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::utils::render_template;
|
||||||
|
|
||||||
/// Controller for handling home-related routes
|
/// Controller for handling home-related routes
|
||||||
pub struct HomeController;
|
pub struct HomeController;
|
||||||
|
|
||||||
@@ -24,13 +26,7 @@ impl HomeController {
|
|||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("editor.html", &ctx)
|
render_template(&tmpl, "editor.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the home page route
|
/// Handles the home page route
|
||||||
@@ -43,13 +39,7 @@ impl HomeController {
|
|||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("index.html", &ctx)
|
render_template(&tmpl, "index.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the about page route
|
/// Handles the about page route
|
||||||
@@ -62,13 +52,7 @@ impl HomeController {
|
|||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("about.html", &ctx)
|
render_template(&tmpl, "about.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the contact page route
|
/// Handles the contact page route
|
||||||
@@ -81,13 +65,7 @@ impl HomeController {
|
|||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("contact.html", &ctx)
|
render_template(&tmpl, "contact.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles form submissions from the contact page
|
/// Handles form submissions from the contact page
|
||||||
@@ -112,18 +90,13 @@ impl HomeController {
|
|||||||
ctx.insert("user", &user);
|
ctx.insert("user", &user);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = tmpl.render("contact.html", &ctx)
|
render_template(&tmpl, "contact.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the data submitted in the contact form
|
/// Represents the data submitted in the contact form
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct ContactForm {
|
pub struct ContactForm {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
|||||||
611
actix_mvc_app/src/controllers/marketplace.rs
Normal file
611
actix_mvc_app/src/controllers/marketplace.rs
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
use actix_web::{HttpResponse, Result, http, web};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
|
use crate::controllers::asset::AssetController;
|
||||||
|
use crate::models::asset::{Asset, AssetStatus, AssetType};
|
||||||
|
use crate::models::marketplace::{Listing, ListingStatus, ListingType, MarketplaceStatistics};
|
||||||
|
use crate::utils::render_template;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListingForm {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub asset_id: String,
|
||||||
|
pub price: f64,
|
||||||
|
pub currency: String,
|
||||||
|
pub listing_type: String,
|
||||||
|
pub duration_days: Option<u32>,
|
||||||
|
pub tags: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct BidForm {
|
||||||
|
pub amount: f64,
|
||||||
|
pub currency: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PurchaseForm {
|
||||||
|
pub agree_to_terms: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MarketplaceController;
|
||||||
|
|
||||||
|
impl MarketplaceController {
|
||||||
|
// Display the marketplace dashboard
|
||||||
|
pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
let listings = Self::get_mock_listings();
|
||||||
|
let stats = MarketplaceStatistics::new(&listings);
|
||||||
|
|
||||||
|
// Get featured listings (up to 4)
|
||||||
|
let featured_listings: Vec<&Listing> = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.featured && l.status == ListingStatus::Active)
|
||||||
|
.take(4)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Get recent listings (up to 8)
|
||||||
|
let mut recent_listings: Vec<&Listing> = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.status == ListingStatus::Active)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by created_at (newest first)
|
||||||
|
recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Get recent sales (up to 5)
|
||||||
|
let mut recent_sales: Vec<&Listing> = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.status == ListingStatus::Sold)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by sold_at (newest first)
|
||||||
|
recent_sales.sort_by(|a, b| {
|
||||||
|
let a_sold = a.sold_at.unwrap_or(a.created_at);
|
||||||
|
let b_sold = b.sold_at.unwrap_or(b.created_at);
|
||||||
|
b_sold.cmp(&a_sold)
|
||||||
|
});
|
||||||
|
let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Add data to context
|
||||||
|
context.insert("active_page", &"marketplace");
|
||||||
|
context.insert("stats", &stats);
|
||||||
|
context.insert("featured_listings", &featured_listings);
|
||||||
|
context.insert("recent_listings", &recent_listings);
|
||||||
|
context.insert("recent_sales", &recent_sales);
|
||||||
|
|
||||||
|
render_template(&tmpl, "marketplace/index.html", &context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display all marketplace listings
|
||||||
|
pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
let listings = Self::get_mock_listings();
|
||||||
|
|
||||||
|
// Filter active listings
|
||||||
|
let active_listings: Vec<&Listing> = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.status == ListingStatus::Active)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
context.insert("active_page", &"marketplace");
|
||||||
|
context.insert("listings", &active_listings);
|
||||||
|
context.insert(
|
||||||
|
"listing_types",
|
||||||
|
&[
|
||||||
|
ListingType::FixedPrice.as_str(),
|
||||||
|
ListingType::Auction.as_str(),
|
||||||
|
ListingType::Exchange.as_str(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
context.insert(
|
||||||
|
"asset_types",
|
||||||
|
&[
|
||||||
|
AssetType::Token.as_str(),
|
||||||
|
AssetType::Artwork.as_str(),
|
||||||
|
AssetType::RealEstate.as_str(),
|
||||||
|
AssetType::IntellectualProperty.as_str(),
|
||||||
|
AssetType::Commodity.as_str(),
|
||||||
|
AssetType::Share.as_str(),
|
||||||
|
AssetType::Bond.as_str(),
|
||||||
|
AssetType::Other.as_str(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
render_template(&tmpl, "marketplace/listings.html", &context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display my listings
|
||||||
|
pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
let listings = Self::get_mock_listings();
|
||||||
|
|
||||||
|
// Filter by current user (mock user ID)
|
||||||
|
let user_id = "user-123";
|
||||||
|
let my_listings: Vec<&Listing> =
|
||||||
|
listings.iter().filter(|l| l.seller_id == user_id).collect();
|
||||||
|
|
||||||
|
context.insert("active_page", &"marketplace");
|
||||||
|
context.insert("listings", &my_listings);
|
||||||
|
|
||||||
|
render_template(&tmpl, "marketplace/my_listings.html", &context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display listing details
|
||||||
|
pub async fn listing_detail(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let listing_id = path.into_inner();
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
let listings = Self::get_mock_listings();
|
||||||
|
|
||||||
|
// Find the listing
|
||||||
|
let listing = listings.iter().find(|l| l.id == listing_id);
|
||||||
|
|
||||||
|
if let Some(listing) = listing {
|
||||||
|
// Get similar listings (same asset type, active)
|
||||||
|
let similar_listings: Vec<&Listing> = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|l| {
|
||||||
|
l.asset_type == listing.asset_type
|
||||||
|
&& l.status == ListingStatus::Active
|
||||||
|
&& l.id != listing.id
|
||||||
|
})
|
||||||
|
.take(4)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Get highest bid amount and minimum bid for auction listings
|
||||||
|
let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction
|
||||||
|
{
|
||||||
|
if let Some(bid) = listing.highest_bid() {
|
||||||
|
(Some(bid.amount), bid.amount + 1.0)
|
||||||
|
} else {
|
||||||
|
(None, listing.price + 1.0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
context.insert("active_page", &"marketplace");
|
||||||
|
context.insert("listing", listing);
|
||||||
|
context.insert("similar_listings", &similar_listings);
|
||||||
|
context.insert("highest_bid_amount", &highest_bid_amount);
|
||||||
|
context.insert("minimum_bid", &minimum_bid);
|
||||||
|
|
||||||
|
// Add current user info for bid/purchase forms
|
||||||
|
let user_id = "user-123";
|
||||||
|
let user_name = "Alice Hostly";
|
||||||
|
context.insert("user_id", &user_id);
|
||||||
|
context.insert("user_name", &user_name);
|
||||||
|
|
||||||
|
render_template(&tmpl, "marketplace/listing_detail.html", &context)
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::NotFound().finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display create listing form
|
||||||
|
pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
|
||||||
|
let mut context = Context::new();
|
||||||
|
|
||||||
|
// Get user's assets for selection
|
||||||
|
let assets = AssetController::get_mock_assets();
|
||||||
|
let user_id = "user-123"; // Mock user ID
|
||||||
|
|
||||||
|
let user_assets: Vec<&Asset> = assets
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
context.insert("active_page", &"marketplace");
|
||||||
|
context.insert("assets", &user_assets);
|
||||||
|
context.insert(
|
||||||
|
"listing_types",
|
||||||
|
&[
|
||||||
|
ListingType::FixedPrice.as_str(),
|
||||||
|
ListingType::Auction.as_str(),
|
||||||
|
ListingType::Exchange.as_str(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new listing
|
||||||
|
pub async fn create_listing(
|
||||||
|
tmpl: web::Data<Tera>,
|
||||||
|
form: web::Form<ListingForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let form = form.into_inner();
|
||||||
|
|
||||||
|
// Get the asset details
|
||||||
|
let assets = AssetController::get_mock_assets();
|
||||||
|
let asset = assets.iter().find(|a| a.id == form.asset_id);
|
||||||
|
|
||||||
|
if let Some(asset) = asset {
|
||||||
|
// Process tags
|
||||||
|
let tags = match form.tags {
|
||||||
|
Some(tags_str) => tags_str
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate expiration date if provided
|
||||||
|
let expires_at = form
|
||||||
|
.duration_days
|
||||||
|
.map(|days| Utc::now() + Duration::days(days as i64));
|
||||||
|
|
||||||
|
// Parse listing type
|
||||||
|
let listing_type = match form.listing_type.as_str() {
|
||||||
|
"Fixed Price" => ListingType::FixedPrice,
|
||||||
|
"Auction" => ListingType::Auction,
|
||||||
|
"Exchange" => ListingType::Exchange,
|
||||||
|
_ => ListingType::FixedPrice,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock user data
|
||||||
|
let user_id = "user-123";
|
||||||
|
let user_name = "Alice Hostly";
|
||||||
|
|
||||||
|
// Create the listing
|
||||||
|
let _listing = Listing::new(
|
||||||
|
form.title,
|
||||||
|
form.description,
|
||||||
|
asset.id.clone(),
|
||||||
|
asset.name.clone(),
|
||||||
|
asset.asset_type.clone(),
|
||||||
|
user_id.to_string(),
|
||||||
|
user_name.to_string(),
|
||||||
|
form.price,
|
||||||
|
form.currency,
|
||||||
|
listing_type,
|
||||||
|
expires_at,
|
||||||
|
tags,
|
||||||
|
asset.image_url.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// In a real application, we would save the listing to a database here
|
||||||
|
|
||||||
|
// Redirect to the marketplace
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.insert_header((http::header::LOCATION, "/marketplace"))
|
||||||
|
.finish())
|
||||||
|
} else {
|
||||||
|
// Asset not found
|
||||||
|
let mut context = Context::new();
|
||||||
|
context.insert("active_page", &"marketplace");
|
||||||
|
context.insert("error", &"Asset not found");
|
||||||
|
|
||||||
|
render_template(&tmpl, "marketplace/create_listing.html", &context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit a bid on an auction listing
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn submit_bid(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
_form: web::Form<BidForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let listing_id = path.into_inner();
|
||||||
|
let _form = _form.into_inner();
|
||||||
|
|
||||||
|
// In a real application, we would:
|
||||||
|
// 1. Find the listing in the database
|
||||||
|
// 2. Validate the bid
|
||||||
|
// 3. Create the bid
|
||||||
|
// 4. Save it to the database
|
||||||
|
|
||||||
|
// For now, we'll just redirect back to the listing
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.insert_header((
|
||||||
|
http::header::LOCATION,
|
||||||
|
format!("/marketplace/{}", listing_id),
|
||||||
|
))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purchase a fixed-price listing
|
||||||
|
pub async fn purchase_listing(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
form: web::Form<PurchaseForm>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let listing_id = path.into_inner();
|
||||||
|
let form = form.into_inner();
|
||||||
|
|
||||||
|
if !form.agree_to_terms {
|
||||||
|
// User must agree to terms
|
||||||
|
return Ok(HttpResponse::SeeOther()
|
||||||
|
.insert_header((
|
||||||
|
http::header::LOCATION,
|
||||||
|
format!("/marketplace/{}", listing_id),
|
||||||
|
))
|
||||||
|
.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real application, we would:
|
||||||
|
// 1. Find the listing in the database
|
||||||
|
// 2. Validate the purchase
|
||||||
|
// 3. Process the transaction
|
||||||
|
// 4. Update the listing status
|
||||||
|
// 5. Transfer the asset
|
||||||
|
|
||||||
|
// For now, we'll just redirect to the marketplace
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.insert_header((http::header::LOCATION, "/marketplace"))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel a listing
|
||||||
|
pub async fn cancel_listing(
|
||||||
|
_tmpl: web::Data<Tera>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let _listing_id = path.into_inner();
|
||||||
|
|
||||||
|
// In a real application, we would:
|
||||||
|
// 1. Find the listing in the database
|
||||||
|
// 2. Validate that the current user is the seller
|
||||||
|
// 3. Update the listing status
|
||||||
|
|
||||||
|
// For now, we'll just redirect to my listings
|
||||||
|
Ok(HttpResponse::SeeOther()
|
||||||
|
.insert_header((http::header::LOCATION, "/marketplace/my"))
|
||||||
|
.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate mock listings for development
|
||||||
|
pub fn get_mock_listings() -> Vec<Listing> {
|
||||||
|
let assets = AssetController::get_mock_assets();
|
||||||
|
let mut listings = Vec::new();
|
||||||
|
|
||||||
|
// Mock user data
|
||||||
|
let user_ids = vec!["user-123", "user-456", "user-789"];
|
||||||
|
let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"];
|
||||||
|
|
||||||
|
// Create some fixed price listings
|
||||||
|
for i in 0..6 {
|
||||||
|
let asset_index = i % assets.len();
|
||||||
|
let asset = &assets[asset_index];
|
||||||
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
|
let price = match asset.asset_type {
|
||||||
|
AssetType::Token => 50.0 + (i as f64 * 10.0),
|
||||||
|
AssetType::Artwork => 500.0 + (i as f64 * 100.0),
|
||||||
|
AssetType::RealEstate => 50000.0 + (i as f64 * 10000.0),
|
||||||
|
AssetType::IntellectualProperty => 2000.0 + (i as f64 * 500.0),
|
||||||
|
AssetType::Commodity => 1000.0 + (i as f64 * 200.0),
|
||||||
|
AssetType::Share => 300.0 + (i as f64 * 50.0),
|
||||||
|
AssetType::Bond => 1500.0 + (i as f64 * 300.0),
|
||||||
|
AssetType::Other => 800.0 + (i as f64 * 150.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut listing = Listing::new(
|
||||||
|
format!("{} for Sale", asset.name),
|
||||||
|
format!(
|
||||||
|
"This is a great opportunity to own {}. {}",
|
||||||
|
asset.name, asset.description
|
||||||
|
),
|
||||||
|
asset.id.clone(),
|
||||||
|
asset.name.clone(),
|
||||||
|
asset.asset_type.clone(),
|
||||||
|
user_ids[user_index].to_string(),
|
||||||
|
user_names[user_index].to_string(),
|
||||||
|
price,
|
||||||
|
"USD".to_string(),
|
||||||
|
ListingType::FixedPrice,
|
||||||
|
Some(Utc::now() + Duration::days(30)),
|
||||||
|
vec!["digital".to_string(), "asset".to_string()],
|
||||||
|
asset.image_url.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make some listings featured
|
||||||
|
if i % 5 == 0 {
|
||||||
|
listing.set_featured(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
listings.push(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create some auction listings
|
||||||
|
for i in 0..4 {
|
||||||
|
let asset_index = (i + 6) % assets.len();
|
||||||
|
let asset = &assets[asset_index];
|
||||||
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
|
let starting_price = match asset.asset_type {
|
||||||
|
AssetType::Token => 40.0 + (i as f64 * 5.0),
|
||||||
|
AssetType::Artwork => 400.0 + (i as f64 * 50.0),
|
||||||
|
AssetType::RealEstate => 40000.0 + (i as f64 * 5000.0),
|
||||||
|
AssetType::IntellectualProperty => 1500.0 + (i as f64 * 300.0),
|
||||||
|
AssetType::Commodity => 800.0 + (i as f64 * 100.0),
|
||||||
|
AssetType::Share => 250.0 + (i as f64 * 40.0),
|
||||||
|
AssetType::Bond => 1200.0 + (i as f64 * 250.0),
|
||||||
|
AssetType::Other => 600.0 + (i as f64 * 120.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut listing = Listing::new(
|
||||||
|
format!("Auction: {}", asset.name),
|
||||||
|
format!("Bid on this amazing {}. {}", asset.name, asset.description),
|
||||||
|
asset.id.clone(),
|
||||||
|
asset.name.clone(),
|
||||||
|
asset.asset_type.clone(),
|
||||||
|
user_ids[user_index].to_string(),
|
||||||
|
user_names[user_index].to_string(),
|
||||||
|
starting_price,
|
||||||
|
"USD".to_string(),
|
||||||
|
ListingType::Auction,
|
||||||
|
Some(Utc::now() + Duration::days(7)),
|
||||||
|
vec!["auction".to_string(), "bidding".to_string()],
|
||||||
|
asset.image_url.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add some bids to the auctions
|
||||||
|
let num_bids = 2 + (i % 3);
|
||||||
|
for j in 0..num_bids {
|
||||||
|
let bidder_index = (j + 1) % user_ids.len();
|
||||||
|
if bidder_index != user_index {
|
||||||
|
// Ensure seller isn't bidding
|
||||||
|
let bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64));
|
||||||
|
let _ = listing.add_bid(
|
||||||
|
user_ids[bidder_index].to_string(),
|
||||||
|
user_names[bidder_index].to_string(),
|
||||||
|
bid_amount,
|
||||||
|
"USD".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make some listings featured
|
||||||
|
if i % 3 == 0 {
|
||||||
|
listing.set_featured(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
listings.push(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create some exchange listings
|
||||||
|
for i in 0..3 {
|
||||||
|
let asset_index = (i + 10) % assets.len();
|
||||||
|
let asset = &assets[asset_index];
|
||||||
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
|
let value = match asset.asset_type {
|
||||||
|
AssetType::Token => 60.0 + (i as f64 * 15.0),
|
||||||
|
AssetType::Artwork => 600.0 + (i as f64 * 150.0),
|
||||||
|
AssetType::RealEstate => 60000.0 + (i as f64 * 15000.0),
|
||||||
|
AssetType::IntellectualProperty => 2500.0 + (i as f64 * 600.0),
|
||||||
|
AssetType::Commodity => 1200.0 + (i as f64 * 300.0),
|
||||||
|
AssetType::Share => 350.0 + (i as f64 * 70.0),
|
||||||
|
AssetType::Bond => 1800.0 + (i as f64 * 350.0),
|
||||||
|
AssetType::Other => 1000.0 + (i as f64 * 200.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let listing = Listing::new(
|
||||||
|
format!("Trade: {}", asset.name),
|
||||||
|
format!(
|
||||||
|
"Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.",
|
||||||
|
asset.name
|
||||||
|
),
|
||||||
|
asset.id.clone(),
|
||||||
|
asset.name.clone(),
|
||||||
|
asset.asset_type.clone(),
|
||||||
|
user_ids[user_index].to_string(),
|
||||||
|
user_names[user_index].to_string(),
|
||||||
|
value, // Estimated value for exchange
|
||||||
|
"USD".to_string(),
|
||||||
|
ListingType::Exchange,
|
||||||
|
Some(Utc::now() + Duration::days(60)),
|
||||||
|
vec!["exchange".to_string(), "trade".to_string()],
|
||||||
|
asset.image_url.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
listings.push(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create some sold listings
|
||||||
|
for i in 0..5 {
|
||||||
|
let asset_index = (i + 13) % assets.len();
|
||||||
|
let asset = &assets[asset_index];
|
||||||
|
let seller_index = i % user_ids.len();
|
||||||
|
let buyer_index = (i + 1) % user_ids.len();
|
||||||
|
|
||||||
|
let price = match asset.asset_type {
|
||||||
|
AssetType::Token => 55.0 + (i as f64 * 12.0),
|
||||||
|
AssetType::Artwork => 550.0 + (i as f64 * 120.0),
|
||||||
|
AssetType::RealEstate => 55000.0 + (i as f64 * 12000.0),
|
||||||
|
AssetType::IntellectualProperty => 2200.0 + (i as f64 * 550.0),
|
||||||
|
AssetType::Commodity => 1100.0 + (i as f64 * 220.0),
|
||||||
|
AssetType::Share => 320.0 + (i as f64 * 60.0),
|
||||||
|
AssetType::Bond => 1650.0 + (i as f64 * 330.0),
|
||||||
|
AssetType::Other => 900.0 + (i as f64 * 180.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sale_price = price * 0.95; // Slight discount on sale
|
||||||
|
|
||||||
|
let mut listing = Listing::new(
|
||||||
|
format!("{} - SOLD", asset.name),
|
||||||
|
format!("This {} was sold recently.", asset.name),
|
||||||
|
asset.id.clone(),
|
||||||
|
asset.name.clone(),
|
||||||
|
asset.asset_type.clone(),
|
||||||
|
user_ids[seller_index].to_string(),
|
||||||
|
user_names[seller_index].to_string(),
|
||||||
|
price,
|
||||||
|
"USD".to_string(),
|
||||||
|
ListingType::FixedPrice,
|
||||||
|
None,
|
||||||
|
vec!["sold".to_string()],
|
||||||
|
asset.image_url.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark as sold
|
||||||
|
let _ = listing.mark_as_sold(
|
||||||
|
user_ids[buyer_index].to_string(),
|
||||||
|
user_names[buyer_index].to_string(),
|
||||||
|
sale_price,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set sold date to be sometime in the past
|
||||||
|
let days_ago = i as i64 + 1;
|
||||||
|
listing.sold_at = Some(Utc::now() - Duration::days(days_ago));
|
||||||
|
|
||||||
|
listings.push(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a few cancelled listings
|
||||||
|
for i in 0..2 {
|
||||||
|
let asset_index = (i + 18) % assets.len();
|
||||||
|
let asset = &assets[asset_index];
|
||||||
|
let user_index = i % user_ids.len();
|
||||||
|
|
||||||
|
let price = match asset.asset_type {
|
||||||
|
AssetType::Token => 45.0 + (i as f64 * 8.0),
|
||||||
|
AssetType::Artwork => 450.0 + (i as f64 * 80.0),
|
||||||
|
AssetType::RealEstate => 45000.0 + (i as f64 * 8000.0),
|
||||||
|
AssetType::IntellectualProperty => 1800.0 + (i as f64 * 400.0),
|
||||||
|
AssetType::Commodity => 900.0 + (i as f64 * 180.0),
|
||||||
|
AssetType::Share => 280.0 + (i as f64 * 45.0),
|
||||||
|
AssetType::Bond => 1350.0 + (i as f64 * 270.0),
|
||||||
|
AssetType::Other => 750.0 + (i as f64 * 150.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut listing = Listing::new(
|
||||||
|
format!("{} - Cancelled", asset.name),
|
||||||
|
format!("This listing for {} was cancelled.", asset.name),
|
||||||
|
asset.id.clone(),
|
||||||
|
asset.name.clone(),
|
||||||
|
asset.asset_type.clone(),
|
||||||
|
user_ids[user_index].to_string(),
|
||||||
|
user_names[user_index].to_string(),
|
||||||
|
price,
|
||||||
|
"USD".to_string(),
|
||||||
|
ListingType::FixedPrice,
|
||||||
|
None,
|
||||||
|
vec!["cancelled".to_string()],
|
||||||
|
asset.image_url.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel the listing
|
||||||
|
let _ = listing.cancel();
|
||||||
|
|
||||||
|
listings.push(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
listings
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
// Export controllers
|
// Export controllers
|
||||||
pub mod home;
|
pub mod asset;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod ticket;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
|
pub mod company;
|
||||||
|
pub mod contract;
|
||||||
|
pub mod defi;
|
||||||
|
pub mod document;
|
||||||
|
pub mod error;
|
||||||
|
pub mod flow;
|
||||||
|
pub mod governance;
|
||||||
|
pub mod health;
|
||||||
|
pub mod home;
|
||||||
|
pub mod marketplace;
|
||||||
|
pub mod payment;
|
||||||
|
pub mod ticket;
|
||||||
|
|
||||||
|
// Re-export controllers for easier imports
|
||||||
|
|||||||
1152
actix_mvc_app/src/controllers/payment.rs
Normal file
1152
actix_mvc_app/src/controllers/payment.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ use tera::Tera;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use crate::models::{User, Ticket, TicketComment, TicketStatus, TicketPriority};
|
use crate::models::{User, Ticket, TicketComment, TicketStatus, TicketPriority};
|
||||||
|
use crate::utils::render_template;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -131,13 +132,7 @@ impl TicketController {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Render the template
|
// Render the template
|
||||||
let rendered = tmpl.render("tickets/list.html", &ctx)
|
render_template(&tmpl, "tickets/list.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the form for creating a new ticket
|
/// Shows the form for creating a new ticket
|
||||||
@@ -172,13 +167,7 @@ impl TicketController {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Render the template
|
// Render the template
|
||||||
let rendered = tmpl.render("tickets/new.html", &ctx)
|
render_template(&tmpl, "tickets/new.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new ticket
|
/// Creates a new ticket
|
||||||
@@ -285,13 +274,7 @@ impl TicketController {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Render the template
|
// Render the template
|
||||||
let rendered = tmpl.render("tickets/show.html", &ctx)
|
render_template(&tmpl, "tickets/show.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a comment to a ticket
|
/// Adds a comment to a ticket
|
||||||
@@ -443,12 +426,6 @@ impl TicketController {
|
|||||||
ctx.insert("my_tickets", &true);
|
ctx.insert("my_tickets", &true);
|
||||||
|
|
||||||
// Render the template
|
// Render the template
|
||||||
let rendered = tmpl.render("tickets/list.html", &ctx)
|
render_template(&tmpl, "tickets/list.html", &ctx)
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("Template rendering error: {}", e);
|
|
||||||
actix_web::error::ErrorInternalServerError("Template rendering error")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
362
actix_mvc_app/src/db/calendar.rs
Normal file
362
actix_mvc_app/src/db/calendar.rs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
#![allow(dead_code)] // Database utility functions may not all be used yet
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use heromodels::{
|
||||||
|
db::{Collection, Db},
|
||||||
|
models::calendar::{AttendanceStatus, Attendee, Calendar, Event},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::db::get_db;
|
||||||
|
|
||||||
|
/// Creates a new calendar and saves it to the database. Returns the saved calendar and its ID.
|
||||||
|
pub fn create_new_calendar(
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
owner_id: Option<u32>,
|
||||||
|
is_public: bool,
|
||||||
|
color: Option<&str>,
|
||||||
|
) -> Result<(u32, Calendar), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create a new calendar (with auto-generated ID)
|
||||||
|
let mut calendar = Calendar::new(None, name);
|
||||||
|
|
||||||
|
if let Some(desc) = description {
|
||||||
|
calendar = calendar.description(desc);
|
||||||
|
}
|
||||||
|
if let Some(owner) = owner_id {
|
||||||
|
calendar = calendar.owner_id(owner);
|
||||||
|
}
|
||||||
|
if let Some(col) = color {
|
||||||
|
calendar = calendar.color(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar = calendar.is_public(is_public);
|
||||||
|
|
||||||
|
// Save the calendar to the database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Calendar>()
|
||||||
|
.expect("can open calendar collection");
|
||||||
|
let (calendar_id, saved_calendar) = collection.set(&calendar).expect("can save calendar");
|
||||||
|
|
||||||
|
Ok((calendar_id, saved_calendar))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new event and saves it to the database. Returns the saved event and its ID.
|
||||||
|
pub fn create_new_event(
|
||||||
|
title: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
start_time: DateTime<Utc>,
|
||||||
|
end_time: DateTime<Utc>,
|
||||||
|
location: Option<&str>,
|
||||||
|
color: Option<&str>,
|
||||||
|
all_day: bool,
|
||||||
|
created_by: Option<u32>,
|
||||||
|
category: Option<&str>,
|
||||||
|
reminder_minutes: Option<i32>,
|
||||||
|
) -> Result<(u32, Event), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create a new event (with auto-generated ID)
|
||||||
|
let mut event = Event::new(title, start_time, end_time);
|
||||||
|
|
||||||
|
if let Some(desc) = description {
|
||||||
|
event = event.description(desc);
|
||||||
|
}
|
||||||
|
if let Some(loc) = location {
|
||||||
|
event = event.location(loc);
|
||||||
|
}
|
||||||
|
if let Some(col) = color {
|
||||||
|
event = event.color(col);
|
||||||
|
}
|
||||||
|
if let Some(user_id) = created_by {
|
||||||
|
event = event.created_by(user_id);
|
||||||
|
}
|
||||||
|
if let Some(cat) = category {
|
||||||
|
event = event.category(cat);
|
||||||
|
}
|
||||||
|
if let Some(reminder) = reminder_minutes {
|
||||||
|
event = event.reminder_minutes(reminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
event = event.all_day(all_day);
|
||||||
|
|
||||||
|
// Save the event to the database
|
||||||
|
let collection = db.collection::<Event>().expect("can open event collection");
|
||||||
|
let (event_id, saved_event) = collection.set(&event).expect("can save event");
|
||||||
|
|
||||||
|
Ok((event_id, saved_event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all calendars from the database and returns them as a Vec<Calendar>.
|
||||||
|
pub fn get_calendars() -> Result<Vec<Calendar>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Calendar>()
|
||||||
|
.expect("can open calendar collection");
|
||||||
|
|
||||||
|
// Try to load all calendars, but handle deserialization errors gracefully
|
||||||
|
let calendars = match collection.get_all() {
|
||||||
|
Ok(calendars) => calendars,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error loading calendars: {:?}", e);
|
||||||
|
vec![] // Return an empty vector if there's an error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(calendars)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all events from the database and returns them as a Vec<Event>.
|
||||||
|
pub fn get_events() -> Result<Vec<Event>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db.collection::<Event>().expect("can open event collection");
|
||||||
|
|
||||||
|
// Try to load all events, but handle deserialization errors gracefully
|
||||||
|
let events = match collection.get_all() {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error loading events: {:?}", e);
|
||||||
|
vec![] // Return an empty vector if there's an error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single calendar by its ID from the database.
|
||||||
|
pub fn get_calendar_by_id(calendar_id: u32) -> Result<Option<Calendar>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Calendar>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
match collection.get_by_id(calendar_id) {
|
||||||
|
Ok(calendar) => Ok(calendar),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error fetching calendar by id {}: {:?}", calendar_id, e);
|
||||||
|
Err(format!("Failed to fetch calendar: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single event by its ID from the database.
|
||||||
|
pub fn get_event_by_id(event_id: u32) -> Result<Option<Event>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Event>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
match collection.get_by_id(event_id) {
|
||||||
|
Ok(event) => Ok(event),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error fetching event by id {}: {:?}", event_id, e);
|
||||||
|
Err(format!("Failed to fetch event: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new attendee and saves it to the database. Returns the saved attendee and its ID.
|
||||||
|
pub fn create_new_attendee(
|
||||||
|
contact_id: u32,
|
||||||
|
status: AttendanceStatus,
|
||||||
|
) -> Result<(u32, Attendee), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create a new attendee (with auto-generated ID)
|
||||||
|
let attendee = Attendee::new(contact_id).status(status);
|
||||||
|
|
||||||
|
// Save the attendee to the database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Attendee>()
|
||||||
|
.expect("can open attendee collection");
|
||||||
|
let (attendee_id, saved_attendee) = collection.set(&attendee).expect("can save attendee");
|
||||||
|
|
||||||
|
Ok((attendee_id, saved_attendee))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single attendee by its ID from the database.
|
||||||
|
pub fn get_attendee_by_id(attendee_id: u32) -> Result<Option<Attendee>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Attendee>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
match collection.get_by_id(attendee_id) {
|
||||||
|
Ok(attendee) => Ok(attendee),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error fetching attendee by id {}: {:?}", attendee_id, e);
|
||||||
|
Err(format!("Failed to fetch attendee: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates attendee status in the database and returns the updated attendee.
|
||||||
|
pub fn update_attendee_status(
|
||||||
|
attendee_id: u32,
|
||||||
|
status: AttendanceStatus,
|
||||||
|
) -> Result<Attendee, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Attendee>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut attendee) = collection
|
||||||
|
.get_by_id(attendee_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch attendee: {:?}", e))?
|
||||||
|
{
|
||||||
|
attendee = attendee.status(status);
|
||||||
|
let (_, updated_attendee) = collection
|
||||||
|
.set(&attendee)
|
||||||
|
.map_err(|e| format!("Failed to update attendee: {:?}", e))?;
|
||||||
|
Ok(updated_attendee)
|
||||||
|
} else {
|
||||||
|
Err("Attendee not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add attendee to event
|
||||||
|
pub fn add_attendee_to_event(event_id: u32, attendee_id: u32) -> Result<Event, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Event>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut event) = collection
|
||||||
|
.get_by_id(event_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch event: {:?}", e))?
|
||||||
|
{
|
||||||
|
event = event.add_attendee(attendee_id);
|
||||||
|
let (_, updated_event) = collection
|
||||||
|
.set(&event)
|
||||||
|
.map_err(|e| format!("Failed to update event: {:?}", e))?;
|
||||||
|
Ok(updated_event)
|
||||||
|
} else {
|
||||||
|
Err("Event not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove attendee from event
|
||||||
|
pub fn remove_attendee_from_event(event_id: u32, attendee_id: u32) -> Result<Event, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Event>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut event) = collection
|
||||||
|
.get_by_id(event_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch event: {:?}", e))?
|
||||||
|
{
|
||||||
|
event = event.remove_attendee(attendee_id);
|
||||||
|
let (_, updated_event) = collection
|
||||||
|
.set(&event)
|
||||||
|
.map_err(|e| format!("Failed to update event: {:?}", e))?;
|
||||||
|
Ok(updated_event)
|
||||||
|
} else {
|
||||||
|
Err("Event not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add event to calendar
|
||||||
|
pub fn add_event_to_calendar(calendar_id: u32, event_id: u32) -> Result<Calendar, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Calendar>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut calendar) = collection
|
||||||
|
.get_by_id(calendar_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch calendar: {:?}", e))?
|
||||||
|
{
|
||||||
|
calendar = calendar.add_event(event_id as i64);
|
||||||
|
let (_, updated_calendar) = collection
|
||||||
|
.set(&calendar)
|
||||||
|
.map_err(|e| format!("Failed to update calendar: {:?}", e))?;
|
||||||
|
Ok(updated_calendar)
|
||||||
|
} else {
|
||||||
|
Err("Calendar not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove event from calendar
|
||||||
|
pub fn remove_event_from_calendar(calendar_id: u32, event_id: u32) -> Result<Calendar, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Calendar>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut calendar) = collection
|
||||||
|
.get_by_id(calendar_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch calendar: {:?}", e))?
|
||||||
|
{
|
||||||
|
calendar = calendar.remove_event(event_id as i64);
|
||||||
|
let (_, updated_calendar) = collection
|
||||||
|
.set(&calendar)
|
||||||
|
.map_err(|e| format!("Failed to update calendar: {:?}", e))?;
|
||||||
|
Ok(updated_calendar)
|
||||||
|
} else {
|
||||||
|
Err("Calendar not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a calendar from the database.
|
||||||
|
pub fn delete_calendar(calendar_id: u32) -> Result<(), String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Calendar>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
collection
|
||||||
|
.delete_by_id(calendar_id)
|
||||||
|
.map_err(|e| format!("Failed to delete calendar: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes an event from the database.
|
||||||
|
pub fn delete_event(event_id: u32) -> Result<(), String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Event>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
collection
|
||||||
|
.delete_by_id(event_id)
|
||||||
|
.map_err(|e| format!("Failed to delete event: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets or creates a calendar for a user. If the user already has a calendar, returns it.
|
||||||
|
/// If not, creates a new calendar for the user and returns it.
|
||||||
|
pub fn get_or_create_user_calendar(user_id: u32, user_name: &str) -> Result<Calendar, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Calendar>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
// Try to find existing calendar for this user
|
||||||
|
let calendars = match collection.get_all() {
|
||||||
|
Ok(calendars) => calendars,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error loading calendars: {:?}", e);
|
||||||
|
vec![] // Return an empty vector if there's an error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look for a calendar owned by this user
|
||||||
|
for calendar in calendars {
|
||||||
|
if let Some(owner_id) = calendar.owner_id {
|
||||||
|
if owner_id == user_id {
|
||||||
|
return Ok(calendar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No calendar found for this user, create a new one
|
||||||
|
let calendar_name = format!("{}'s Calendar", user_name);
|
||||||
|
let (_, new_calendar) = create_new_calendar(
|
||||||
|
&calendar_name,
|
||||||
|
Some("Personal calendar"),
|
||||||
|
Some(user_id),
|
||||||
|
false, // Private calendar
|
||||||
|
Some("#4285F4"), // Default blue color
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(new_calendar)
|
||||||
|
}
|
||||||
500
actix_mvc_app/src/db/company.rs
Normal file
500
actix_mvc_app/src/db/company.rs
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
#![allow(dead_code)] // Database utility functions may not all be used yet
|
||||||
|
|
||||||
|
use super::db::get_db;
|
||||||
|
use heromodels::{
|
||||||
|
db::{Collection, Db},
|
||||||
|
models::biz::{BusinessType, Company, CompanyStatus, Shareholder, ShareholderType},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Creates a new company and saves it to the database
|
||||||
|
pub fn create_new_company(
|
||||||
|
name: String,
|
||||||
|
registration_number: String,
|
||||||
|
incorporation_date: i64,
|
||||||
|
business_type: BusinessType,
|
||||||
|
email: String,
|
||||||
|
phone: String,
|
||||||
|
website: String,
|
||||||
|
address: String,
|
||||||
|
industry: String,
|
||||||
|
description: String,
|
||||||
|
fiscal_year_end: String,
|
||||||
|
) -> Result<(u32, Company), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create using heromodels constructor
|
||||||
|
let company = Company::new(name, registration_number, incorporation_date)
|
||||||
|
.business_type(business_type)
|
||||||
|
.email(email)
|
||||||
|
.phone(phone)
|
||||||
|
.website(website)
|
||||||
|
.address(address)
|
||||||
|
.industry(industry)
|
||||||
|
.description(description)
|
||||||
|
.fiscal_year_end(fiscal_year_end)
|
||||||
|
.status(CompanyStatus::PendingPayment);
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.expect("can open company collection");
|
||||||
|
let (id, saved_company) = collection.set(&company).expect("can save company");
|
||||||
|
|
||||||
|
Ok((id, saved_company))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new company with a specific status and saves it to the database
|
||||||
|
pub fn create_new_company_with_status(
|
||||||
|
name: String,
|
||||||
|
registration_number: String,
|
||||||
|
incorporation_date: i64,
|
||||||
|
business_type: BusinessType,
|
||||||
|
email: String,
|
||||||
|
phone: String,
|
||||||
|
website: String,
|
||||||
|
address: String,
|
||||||
|
industry: String,
|
||||||
|
description: String,
|
||||||
|
fiscal_year_end: String,
|
||||||
|
status: CompanyStatus,
|
||||||
|
) -> Result<(u32, Company), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create using heromodels constructor with specified status
|
||||||
|
let company = Company::new(name, registration_number, incorporation_date)
|
||||||
|
.business_type(business_type)
|
||||||
|
.email(email)
|
||||||
|
.phone(phone)
|
||||||
|
.website(website)
|
||||||
|
.address(address)
|
||||||
|
.industry(industry)
|
||||||
|
.description(description)
|
||||||
|
.fiscal_year_end(fiscal_year_end)
|
||||||
|
.status(status);
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.expect("can open company collection");
|
||||||
|
let (id, saved_company) = collection.set(&company).expect("can save company");
|
||||||
|
|
||||||
|
Ok((id, saved_company))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all companies from the database
|
||||||
|
pub fn get_companies() -> Result<Vec<Company>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.expect("can open company collection");
|
||||||
|
|
||||||
|
let companies = match collection.get_all() {
|
||||||
|
Ok(companies) => {
|
||||||
|
log::info!(
|
||||||
|
"Successfully loaded {} companies from database",
|
||||||
|
companies.len()
|
||||||
|
);
|
||||||
|
companies
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load companies from database: {:?}", e);
|
||||||
|
// Return the error instead of empty vec to properly handle corruption
|
||||||
|
return Err(format!("Failed to get companies: {:?}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(companies)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update company status (e.g., from PendingPayment to Active)
|
||||||
|
pub fn update_company_status(
|
||||||
|
company_id: u32,
|
||||||
|
new_status: CompanyStatus,
|
||||||
|
) -> Result<Option<Company>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.expect("can open company collection");
|
||||||
|
|
||||||
|
// Try to get all companies, with corruption recovery
|
||||||
|
let all_companies = match collection.get_all() {
|
||||||
|
Ok(companies) => companies,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get companies for status update: {:?}", e);
|
||||||
|
|
||||||
|
// If we have a decode error, try to recover by clearing corrupted data
|
||||||
|
if format!("{:?}", e).contains("Decode") {
|
||||||
|
log::warn!("Database corruption detected, attempting recovery...");
|
||||||
|
|
||||||
|
// Try to recover by clearing the collection and recreating
|
||||||
|
match recover_from_database_corruption() {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!(
|
||||||
|
"Database recovery successful, but company {} may be lost",
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
return Err(format!(
|
||||||
|
"Database was corrupted and recovered, but company {} was not found. Please re-register.",
|
||||||
|
company_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(recovery_err) => {
|
||||||
|
log::error!("Database recovery failed: {}", recovery_err);
|
||||||
|
return Err(format!(
|
||||||
|
"Database corruption detected and recovery failed: {}",
|
||||||
|
recovery_err
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(format!("Failed to get companies: {:?}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the company by ID
|
||||||
|
for (_index, company) in all_companies.iter().enumerate() {
|
||||||
|
if company.base_data.id == company_id {
|
||||||
|
// Create updated company with new status
|
||||||
|
let mut updated_company = company.clone();
|
||||||
|
updated_company.status = new_status.clone();
|
||||||
|
|
||||||
|
// Update in database
|
||||||
|
let (_, saved_company) = collection.set(&updated_company).map_err(|e| {
|
||||||
|
log::error!("Failed to update company status: {:?}", e);
|
||||||
|
format!("Failed to update company: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!("Updated company {} status to {:?}", company_id, new_status);
|
||||||
|
|
||||||
|
return Ok(Some(saved_company));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"Company not found with ID: {} (cannot update status)",
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single company by its ID
|
||||||
|
pub fn get_company_by_id(company_id: u32) -> Result<Option<Company>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
match collection.get_by_id(company_id) {
|
||||||
|
Ok(company) => Ok(company),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error fetching company by id {}: {:?}", company_id, e);
|
||||||
|
Err(format!("Failed to fetch company: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates company in the database
|
||||||
|
pub fn update_company(
|
||||||
|
company_id: u32,
|
||||||
|
name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
phone: Option<String>,
|
||||||
|
website: Option<String>,
|
||||||
|
address: Option<String>,
|
||||||
|
industry: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
fiscal_year_end: Option<String>,
|
||||||
|
status: Option<CompanyStatus>,
|
||||||
|
business_type: Option<BusinessType>,
|
||||||
|
) -> Result<Company, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut company) = collection
|
||||||
|
.get_by_id(company_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch company: {:?}", e))?
|
||||||
|
{
|
||||||
|
// Update using builder pattern
|
||||||
|
if let Some(name) = name {
|
||||||
|
company.name = name;
|
||||||
|
}
|
||||||
|
if let Some(email) = email {
|
||||||
|
company = company.email(email);
|
||||||
|
}
|
||||||
|
if let Some(phone) = phone {
|
||||||
|
company = company.phone(phone);
|
||||||
|
}
|
||||||
|
if let Some(website) = website {
|
||||||
|
company = company.website(website);
|
||||||
|
}
|
||||||
|
if let Some(address) = address {
|
||||||
|
company = company.address(address);
|
||||||
|
}
|
||||||
|
if let Some(industry) = industry {
|
||||||
|
company = company.industry(industry);
|
||||||
|
}
|
||||||
|
if let Some(description) = description {
|
||||||
|
company = company.description(description);
|
||||||
|
}
|
||||||
|
if let Some(fiscal_year_end) = fiscal_year_end {
|
||||||
|
company = company.fiscal_year_end(fiscal_year_end);
|
||||||
|
}
|
||||||
|
if let Some(status) = status {
|
||||||
|
company = company.status(status);
|
||||||
|
}
|
||||||
|
if let Some(business_type) = business_type {
|
||||||
|
company = company.business_type(business_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, updated_company) = collection
|
||||||
|
.set(&company)
|
||||||
|
.map_err(|e| format!("Failed to update company: {:?}", e))?;
|
||||||
|
Ok(updated_company)
|
||||||
|
} else {
|
||||||
|
Err("Company not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes company from the database
|
||||||
|
pub fn delete_company(company_id: u32) -> Result<(), String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
collection
|
||||||
|
.delete_by_id(company_id)
|
||||||
|
.map_err(|e| format!("Failed to delete company: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a company by name (useful for cleaning up test data)
|
||||||
|
pub fn delete_company_by_name(company_name: &str) -> Result<(), String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
// Get all companies and find the one with matching name
|
||||||
|
let companies = collection
|
||||||
|
.get_all()
|
||||||
|
.map_err(|e| format!("Failed to get companies: {:?}", e))?;
|
||||||
|
|
||||||
|
let company_to_delete = companies
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.name.trim().to_lowercase() == company_name.trim().to_lowercase());
|
||||||
|
|
||||||
|
if let Some(company) = company_to_delete {
|
||||||
|
collection
|
||||||
|
.delete_by_id(company.base_data.id)
|
||||||
|
.map_err(|e| format!("Failed to delete company: {:?}", e))?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Successfully deleted company '{}' with ID {}",
|
||||||
|
company.name,
|
||||||
|
company.base_data.id
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Company '{}' not found", company_name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists all company names in the database (useful for debugging duplicates)
|
||||||
|
pub fn list_company_names() -> Result<Vec<String>, String> {
|
||||||
|
let companies = get_companies()?;
|
||||||
|
let names: Vec<String> = companies.iter().map(|c| c.name.clone()).collect();
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover from database corruption by clearing corrupted data
|
||||||
|
fn recover_from_database_corruption() -> Result<(), String> {
|
||||||
|
log::warn!("Attempting to recover from database corruption...");
|
||||||
|
|
||||||
|
// Since there's no clear method available, we'll provide instructions for manual recovery
|
||||||
|
log::warn!("Database corruption detected - manual intervention required");
|
||||||
|
log::warn!("To fix: Stop the application, delete the database files, and restart");
|
||||||
|
|
||||||
|
Err(
|
||||||
|
"Database corruption detected. Please restart the application to reset the database."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manual function to clean up corrupted database (for emergency use)
|
||||||
|
pub fn cleanup_corrupted_database() -> Result<String, String> {
|
||||||
|
log::warn!("Manual database cleanup initiated...");
|
||||||
|
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Company>()
|
||||||
|
.expect("can open company collection");
|
||||||
|
|
||||||
|
// Try to get companies to check for corruption
|
||||||
|
match collection.get_all() {
|
||||||
|
Ok(companies) => {
|
||||||
|
log::info!("Database is healthy with {} companies", companies.len());
|
||||||
|
Ok(format!(
|
||||||
|
"Database is healthy with {} companies",
|
||||||
|
companies.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Database corruption detected: {:?}", e);
|
||||||
|
|
||||||
|
// Since we can't clear the collection programmatically, provide instructions
|
||||||
|
log::error!("Database corruption detected but cannot be fixed automatically");
|
||||||
|
Err("Database corruption detected. Please stop the application, delete the database files in the 'data' directory, and restart the application.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Shareholder Management Functions ===
|
||||||
|
|
||||||
|
/// Creates a new shareholder and saves it to the database
|
||||||
|
pub fn create_new_shareholder(
|
||||||
|
company_id: u32,
|
||||||
|
user_id: u32,
|
||||||
|
name: String,
|
||||||
|
shares: f64,
|
||||||
|
percentage: f64,
|
||||||
|
shareholder_type: ShareholderType,
|
||||||
|
since: i64,
|
||||||
|
) -> Result<(u32, Shareholder), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create a new shareholder
|
||||||
|
let shareholder = Shareholder::new()
|
||||||
|
.company_id(company_id)
|
||||||
|
.user_id(user_id)
|
||||||
|
.name(name)
|
||||||
|
.shares(shares)
|
||||||
|
.percentage(percentage)
|
||||||
|
.type_(shareholder_type)
|
||||||
|
.since(since);
|
||||||
|
|
||||||
|
// Save the shareholder to the database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Shareholder>()
|
||||||
|
.expect("can open shareholder collection");
|
||||||
|
let (shareholder_id, saved_shareholder) =
|
||||||
|
collection.set(&shareholder).expect("can save shareholder");
|
||||||
|
|
||||||
|
Ok((shareholder_id, saved_shareholder))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all shareholders for a specific company
|
||||||
|
pub fn get_company_shareholders(company_id: u32) -> Result<Vec<Shareholder>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Shareholder>()
|
||||||
|
.expect("can open shareholder collection");
|
||||||
|
|
||||||
|
let all_shareholders = match collection.get_all() {
|
||||||
|
Ok(shareholders) => shareholders,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load shareholders from database: {:?}", e);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter shareholders by company_id
|
||||||
|
let company_shareholders = all_shareholders
|
||||||
|
.into_iter()
|
||||||
|
.filter(|shareholder| shareholder.company_id == company_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(company_shareholders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all shareholders from the database
|
||||||
|
pub fn get_all_shareholders() -> Result<Vec<Shareholder>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Shareholder>()
|
||||||
|
.expect("can open shareholder collection");
|
||||||
|
|
||||||
|
let shareholders = match collection.get_all() {
|
||||||
|
Ok(shareholders) => shareholders,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load shareholders from database: {:?}", e);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(shareholders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single shareholder by its ID
|
||||||
|
pub fn get_shareholder_by_id(shareholder_id: u32) -> Result<Option<Shareholder>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Shareholder>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
match collection.get_by_id(shareholder_id) {
|
||||||
|
Ok(shareholder) => Ok(shareholder),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Error fetching shareholder by id {}: {:?}",
|
||||||
|
shareholder_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Err(format!("Failed to fetch shareholder: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates shareholder in the database
|
||||||
|
pub fn update_shareholder(
|
||||||
|
shareholder_id: u32,
|
||||||
|
name: Option<String>,
|
||||||
|
shares: Option<f64>,
|
||||||
|
percentage: Option<f64>,
|
||||||
|
shareholder_type: Option<ShareholderType>,
|
||||||
|
) -> Result<Shareholder, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Shareholder>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut shareholder) = collection
|
||||||
|
.get_by_id(shareholder_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch shareholder: {:?}", e))?
|
||||||
|
{
|
||||||
|
// Update using builder pattern
|
||||||
|
if let Some(name) = name {
|
||||||
|
shareholder = shareholder.name(name);
|
||||||
|
}
|
||||||
|
if let Some(shares) = shares {
|
||||||
|
shareholder = shareholder.shares(shares);
|
||||||
|
}
|
||||||
|
if let Some(percentage) = percentage {
|
||||||
|
shareholder = shareholder.percentage(percentage);
|
||||||
|
}
|
||||||
|
if let Some(shareholder_type) = shareholder_type {
|
||||||
|
shareholder = shareholder.type_(shareholder_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, updated_shareholder) = collection
|
||||||
|
.set(&shareholder)
|
||||||
|
.map_err(|e| format!("Failed to update shareholder: {:?}", e))?;
|
||||||
|
Ok(updated_shareholder)
|
||||||
|
} else {
|
||||||
|
Err("Shareholder not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes shareholder from the database
|
||||||
|
pub fn delete_shareholder(shareholder_id: u32) -> Result<(), String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Shareholder>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
collection
|
||||||
|
.delete_by_id(shareholder_id)
|
||||||
|
.map_err(|e| format!("Failed to delete shareholder: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
460
actix_mvc_app/src/db/contracts.rs
Normal file
460
actix_mvc_app/src/db/contracts.rs
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
#![allow(dead_code)] // Database utility functions may not all be used yet
|
||||||
|
|
||||||
|
use heromodels::{
|
||||||
|
db::{Collection, Db},
|
||||||
|
models::legal::{Contract, ContractRevision, ContractSigner, ContractStatus, SignerStatus},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::db::get_db;
|
||||||
|
|
||||||
|
/// Creates a new contract and saves it to the database. Returns the saved contract and its ID.
|
||||||
|
pub fn create_new_contract(
|
||||||
|
base_id: u32,
|
||||||
|
contract_id: &str,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
contract_type: &str,
|
||||||
|
status: ContractStatus,
|
||||||
|
created_by: &str,
|
||||||
|
terms_and_conditions: Option<&str>,
|
||||||
|
start_date: Option<u64>,
|
||||||
|
end_date: Option<u64>,
|
||||||
|
renewal_period_days: Option<u32>,
|
||||||
|
) -> Result<(u32, Contract), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create a new contract using the heromodels Contract::new constructor
|
||||||
|
let mut contract = Contract::new(base_id, contract_id.to_string())
|
||||||
|
.title(title)
|
||||||
|
.description(description)
|
||||||
|
.contract_type(contract_type.to_string())
|
||||||
|
.status(status)
|
||||||
|
.created_by(created_by.to_string());
|
||||||
|
|
||||||
|
if let Some(terms) = terms_and_conditions {
|
||||||
|
contract = contract.terms_and_conditions(terms);
|
||||||
|
}
|
||||||
|
if let Some(start) = start_date {
|
||||||
|
contract = contract.start_date(start);
|
||||||
|
}
|
||||||
|
if let Some(end) = end_date {
|
||||||
|
contract = contract.end_date(end);
|
||||||
|
}
|
||||||
|
if let Some(renewal) = renewal_period_days {
|
||||||
|
contract = contract.renewal_period_days(renewal as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the contract to the database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.expect("can open contract collection");
|
||||||
|
let (contract_id, saved_contract) = collection.set(&contract).expect("can save contract");
|
||||||
|
|
||||||
|
Ok((contract_id, saved_contract))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all contracts from the database and returns them as a Vec<Contract>.
|
||||||
|
pub fn get_contracts() -> Result<Vec<Contract>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.expect("can open contract collection");
|
||||||
|
|
||||||
|
// Try to load all contracts, but handle deserialization errors gracefully
|
||||||
|
let contracts = match collection.get_all() {
|
||||||
|
Ok(contracts) => contracts,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load contracts from database: {:?}", e);
|
||||||
|
vec![] // Return empty vector if there's an error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single contract by its ID from the database.
|
||||||
|
pub fn get_contract_by_id(contract_id: u32) -> Result<Option<Contract>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
match collection.get_by_id(contract_id) {
|
||||||
|
Ok(contract) => Ok(contract),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error fetching contract by id {}: {:?}", contract_id, e);
|
||||||
|
Err(format!("Failed to fetch contract: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a contract's basic information in the database and returns the updated contract.
|
||||||
|
pub fn update_contract(
|
||||||
|
contract_id: u32,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
contract_type: &str,
|
||||||
|
terms_and_conditions: Option<&str>,
|
||||||
|
start_date: Option<u64>,
|
||||||
|
end_date: Option<u64>,
|
||||||
|
) -> Result<Contract, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut contract) = collection
|
||||||
|
.get_by_id(contract_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch contract: {:?}", e))?
|
||||||
|
{
|
||||||
|
// Update the contract fields
|
||||||
|
contract = contract
|
||||||
|
.title(title)
|
||||||
|
.description(description)
|
||||||
|
.contract_type(contract_type.to_string());
|
||||||
|
|
||||||
|
if let Some(terms) = terms_and_conditions {
|
||||||
|
contract = contract.terms_and_conditions(terms);
|
||||||
|
}
|
||||||
|
if let Some(start) = start_date {
|
||||||
|
contract = contract.start_date(start);
|
||||||
|
}
|
||||||
|
if let Some(end) = end_date {
|
||||||
|
contract = contract.end_date(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, updated_contract) = collection
|
||||||
|
.set(&contract)
|
||||||
|
.map_err(|e| format!("Failed to update contract: {:?}", e))?;
|
||||||
|
Ok(updated_contract)
|
||||||
|
} else {
|
||||||
|
Err("Contract not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a contract's status in the database and returns the updated contract.
|
||||||
|
pub fn update_contract_status(
|
||||||
|
contract_id: u32,
|
||||||
|
status: ContractStatus,
|
||||||
|
) -> Result<Contract, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut contract) = collection
|
||||||
|
.get_by_id(contract_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch contract: {:?}", e))?
|
||||||
|
{
|
||||||
|
contract = contract.status(status);
|
||||||
|
let (_, updated_contract) = collection
|
||||||
|
.set(&contract)
|
||||||
|
.map_err(|e| format!("Failed to update contract: {:?}", e))?;
|
||||||
|
Ok(updated_contract)
|
||||||
|
} else {
|
||||||
|
Err("Contract not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a signer to a contract and returns the updated contract.
|
||||||
|
pub fn add_signer_to_contract(
|
||||||
|
contract_id: u32,
|
||||||
|
signer_id: &str,
|
||||||
|
name: &str,
|
||||||
|
email: &str,
|
||||||
|
) -> Result<Contract, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut contract) = collection
|
||||||
|
.get_by_id(contract_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch contract: {:?}", e))?
|
||||||
|
{
|
||||||
|
let signer =
|
||||||
|
ContractSigner::new(signer_id.to_string(), name.to_string(), email.to_string());
|
||||||
|
contract = contract.add_signer(signer);
|
||||||
|
let (_, updated_contract) = collection
|
||||||
|
.set(&contract)
|
||||||
|
.map_err(|e| format!("Failed to update contract: {:?}", e))?;
|
||||||
|
Ok(updated_contract)
|
||||||
|
} else {
|
||||||
|
Err("Contract not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a revision to a contract and returns the updated contract.
|
||||||
|
pub fn add_revision_to_contract(
|
||||||
|
contract_id: u32,
|
||||||
|
version: u32,
|
||||||
|
content: &str,
|
||||||
|
created_by: &str,
|
||||||
|
comments: Option<&str>,
|
||||||
|
) -> Result<Contract, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut contract) = collection
|
||||||
|
.get_by_id(contract_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch contract: {:?}", e))?
|
||||||
|
{
|
||||||
|
let revision = ContractRevision::new(
|
||||||
|
version,
|
||||||
|
content.to_string(),
|
||||||
|
current_timestamp_secs(),
|
||||||
|
created_by.to_string(),
|
||||||
|
)
|
||||||
|
.comments(comments.unwrap_or("").to_string());
|
||||||
|
|
||||||
|
contract = contract.add_revision(revision);
|
||||||
|
let (_, updated_contract) = collection
|
||||||
|
.set(&contract)
|
||||||
|
.map_err(|e| format!("Failed to update contract: {:?}", e))?;
|
||||||
|
Ok(updated_contract)
|
||||||
|
} else {
|
||||||
|
Err("Contract not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a signer's status for a contract and returns the updated contract.
|
||||||
|
pub fn update_signer_status(
|
||||||
|
contract_id: u32,
|
||||||
|
signer_id: &str,
|
||||||
|
status: SignerStatus,
|
||||||
|
comments: Option<&str>,
|
||||||
|
) -> Result<Contract, String> {
|
||||||
|
update_signer_status_with_signature(contract_id, signer_id, status, comments, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a signer's status with signature data for a contract and returns the updated contract.
|
||||||
|
pub fn update_signer_status_with_signature(
|
||||||
|
contract_id: u32,
|
||||||
|
signer_id: &str,
|
||||||
|
status: SignerStatus,
|
||||||
|
comments: Option<&str>,
|
||||||
|
signature_data: Option<&str>,
|
||||||
|
) -> Result<Contract, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
if let Some(mut contract) = collection
|
||||||
|
.get_by_id(contract_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch contract: {:?}", e))?
|
||||||
|
{
|
||||||
|
// Find and update the signer
|
||||||
|
let mut signer_found = false;
|
||||||
|
for signer in &mut contract.signers {
|
||||||
|
if signer.id == signer_id {
|
||||||
|
signer.status = status.clone();
|
||||||
|
if status == SignerStatus::Signed {
|
||||||
|
signer.signed_at = Some(current_timestamp_secs());
|
||||||
|
}
|
||||||
|
if let Some(comment) = comments {
|
||||||
|
signer.comments = Some(comment.to_string());
|
||||||
|
}
|
||||||
|
if let Some(sig_data) = signature_data {
|
||||||
|
signer.signature_data = Some(sig_data.to_string());
|
||||||
|
}
|
||||||
|
signer_found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !signer_found {
|
||||||
|
return Err(format!("Signer with ID {} not found", signer_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, updated_contract) = collection
|
||||||
|
.set(&contract)
|
||||||
|
.map_err(|e| format!("Failed to update contract: {:?}", e))?;
|
||||||
|
Ok(updated_contract)
|
||||||
|
} else {
|
||||||
|
Err("Contract not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a contract from the database.
|
||||||
|
pub fn delete_contract(contract_id: u32) -> Result<(), String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
collection
|
||||||
|
.delete_by_id(contract_id)
|
||||||
|
.map_err(|e| format!("Failed to delete contract: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets contracts by status
|
||||||
|
pub fn get_contracts_by_status(status: ContractStatus) -> Result<Vec<Contract>, String> {
|
||||||
|
let contracts = get_contracts()?;
|
||||||
|
let filtered_contracts = contracts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|contract| contract.status == status)
|
||||||
|
.collect();
|
||||||
|
Ok(filtered_contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets contracts by creator
|
||||||
|
pub fn get_contracts_by_creator(created_by: &str) -> Result<Vec<Contract>, String> {
|
||||||
|
let contracts = get_contracts()?;
|
||||||
|
let filtered_contracts = contracts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|contract| contract.created_by == created_by)
|
||||||
|
.collect();
|
||||||
|
Ok(filtered_contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets contracts that need renewal (approaching end date)
|
||||||
|
pub fn get_contracts_needing_renewal(days_ahead: u64) -> Result<Vec<Contract>, String> {
|
||||||
|
let contracts = get_contracts()?;
|
||||||
|
let threshold_timestamp = current_timestamp_secs() + (days_ahead * 24 * 60 * 60);
|
||||||
|
|
||||||
|
let filtered_contracts = contracts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|contract| {
|
||||||
|
if let Some(end_date) = contract.end_date {
|
||||||
|
end_date <= threshold_timestamp && contract.status == ContractStatus::Active
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(filtered_contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets expired contracts
|
||||||
|
pub fn get_expired_contracts() -> Result<Vec<Contract>, String> {
|
||||||
|
let contracts = get_contracts()?;
|
||||||
|
let current_time = current_timestamp_secs();
|
||||||
|
|
||||||
|
let filtered_contracts = contracts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|contract| {
|
||||||
|
if let Some(end_date) = contract.end_date {
|
||||||
|
end_date < current_time && contract.status != ContractStatus::Expired
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(filtered_contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates multiple contracts to expired status
|
||||||
|
pub fn mark_contracts_as_expired(contract_ids: Vec<u32>) -> Result<Vec<Contract>, String> {
|
||||||
|
let mut updated_contracts = Vec::new();
|
||||||
|
|
||||||
|
for contract_id in contract_ids {
|
||||||
|
match update_contract_status(contract_id, ContractStatus::Expired) {
|
||||||
|
Ok(contract) => updated_contracts.push(contract),
|
||||||
|
Err(e) => log::error!("Failed to update contract {}: {}", contract_id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(updated_contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a signer's reminder timestamp for a contract and returns the updated contract.
|
||||||
|
pub fn update_signer_reminder_timestamp(
|
||||||
|
contract_id: u32,
|
||||||
|
signer_id: &str,
|
||||||
|
_timestamp: u64,
|
||||||
|
) -> Result<Contract, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Contract>()
|
||||||
|
.expect("can open contract collection");
|
||||||
|
|
||||||
|
if let Some(mut contract) = collection
|
||||||
|
.get_by_id(contract_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch contract: {:?}", e))?
|
||||||
|
{
|
||||||
|
let mut signer_found = false;
|
||||||
|
for signer in &mut contract.signers {
|
||||||
|
if signer.id == signer_id {
|
||||||
|
// TODO: Update reminder timestamp when field is available in heromodels
|
||||||
|
// signer.last_reminder_mail_sent_at = Some(timestamp);
|
||||||
|
signer_found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !signer_found {
|
||||||
|
return Err(format!("Signer with ID {} not found", signer_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, updated_contract) = collection
|
||||||
|
.set(&contract)
|
||||||
|
.map_err(|e| format!("Failed to update contract: {:?}", e))?;
|
||||||
|
Ok(updated_contract)
|
||||||
|
} else {
|
||||||
|
Err("Contract not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets contract statistics
|
||||||
|
pub fn get_contract_statistics() -> Result<ContractStatistics, String> {
|
||||||
|
let contracts = get_contracts()?;
|
||||||
|
|
||||||
|
let total = contracts.len();
|
||||||
|
let draft = contracts
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == ContractStatus::Draft)
|
||||||
|
.count();
|
||||||
|
let pending = contracts
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == ContractStatus::PendingSignatures)
|
||||||
|
.count();
|
||||||
|
let signed = contracts
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == ContractStatus::Signed)
|
||||||
|
.count();
|
||||||
|
let active = contracts
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == ContractStatus::Active)
|
||||||
|
.count();
|
||||||
|
let expired = contracts
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == ContractStatus::Expired)
|
||||||
|
.count();
|
||||||
|
let cancelled = contracts
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.status == ContractStatus::Cancelled)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
Ok(ContractStatistics {
|
||||||
|
total_contracts: total,
|
||||||
|
draft_contracts: draft,
|
||||||
|
pending_signature_contracts: pending,
|
||||||
|
signed_contracts: signed,
|
||||||
|
active_contracts: active,
|
||||||
|
expired_contracts: expired,
|
||||||
|
cancelled_contracts: cancelled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A helper for current timestamp (seconds since epoch)
|
||||||
|
fn current_timestamp_secs() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract statistics structure
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ContractStatistics {
|
||||||
|
pub total_contracts: usize,
|
||||||
|
pub draft_contracts: usize,
|
||||||
|
pub pending_signature_contracts: usize,
|
||||||
|
pub signed_contracts: usize,
|
||||||
|
pub active_contracts: usize,
|
||||||
|
pub expired_contracts: usize,
|
||||||
|
pub cancelled_contracts: usize,
|
||||||
|
}
|
||||||
17
actix_mvc_app/src/db/db.rs
Normal file
17
actix_mvc_app/src/db/db.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use heromodels::db::hero::OurDB;
|
||||||
|
|
||||||
|
/// The path to the database file. Change this as needed for your environment.
|
||||||
|
pub const DB_PATH: &str = "/tmp/freezone_db";
|
||||||
|
|
||||||
|
/// Returns a shared OurDB instance for the given path. You can wrap this in Arc/Mutex for concurrent access if needed.
|
||||||
|
pub fn get_db() -> Result<OurDB, String> {
|
||||||
|
let db_path = PathBuf::from(DB_PATH);
|
||||||
|
if let Some(parent) = db_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
// Temporarily reset the database to fix the serialization issue
|
||||||
|
let db = heromodels::db::hero::OurDB::new(db_path, false).expect("Can create DB");
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
199
actix_mvc_app/src/db/document.rs
Normal file
199
actix_mvc_app/src/db/document.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#![allow(dead_code)] // Database utility functions may not all be used yet
|
||||||
|
|
||||||
|
use crate::models::document::{Document, DocumentType};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const DOCUMENTS_FILE: &str = "/tmp/freezone_documents.json";
|
||||||
|
|
||||||
|
/// Helper function to load documents from JSON file
|
||||||
|
fn load_documents() -> Result<Vec<Document>, String> {
|
||||||
|
if !Path::new(DOCUMENTS_FILE).exists() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(DOCUMENTS_FILE)
|
||||||
|
.map_err(|e| format!("Failed to read documents file: {}", e))?;
|
||||||
|
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&content).map_err(|e| format!("Failed to parse documents JSON: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to save documents to JSON file
|
||||||
|
fn save_documents(documents: &[Document]) -> Result<(), String> {
|
||||||
|
let content = serde_json::to_string_pretty(documents)
|
||||||
|
.map_err(|e| format!("Failed to serialize documents: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(DOCUMENTS_FILE, content).map_err(|e| format!("Failed to write documents file: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new document and saves it to the database
|
||||||
|
pub fn create_new_document(
|
||||||
|
name: String,
|
||||||
|
file_path: String,
|
||||||
|
file_size: u64,
|
||||||
|
mime_type: String,
|
||||||
|
company_id: u32,
|
||||||
|
uploaded_by: String,
|
||||||
|
document_type: DocumentType,
|
||||||
|
description: Option<String>,
|
||||||
|
is_public: bool,
|
||||||
|
checksum: Option<String>,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
let mut documents = load_documents()?;
|
||||||
|
|
||||||
|
// Create new document
|
||||||
|
let mut document = Document::new(
|
||||||
|
name,
|
||||||
|
file_path,
|
||||||
|
file_size,
|
||||||
|
mime_type,
|
||||||
|
company_id,
|
||||||
|
uploaded_by,
|
||||||
|
)
|
||||||
|
.document_type(document_type)
|
||||||
|
.is_public(is_public);
|
||||||
|
|
||||||
|
if let Some(desc) = description {
|
||||||
|
document = document.description(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(checksum) = checksum {
|
||||||
|
document = document.checksum(checksum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate next ID (simple incremental)
|
||||||
|
let next_id = documents.iter().map(|d| d.id).max().unwrap_or(0) + 1;
|
||||||
|
document.id = next_id;
|
||||||
|
|
||||||
|
documents.push(document);
|
||||||
|
save_documents(&documents)?;
|
||||||
|
|
||||||
|
Ok(next_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all documents from the database
|
||||||
|
pub fn get_documents() -> Result<Vec<Document>, String> {
|
||||||
|
load_documents()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all documents for a specific company
|
||||||
|
pub fn get_company_documents(company_id: u32) -> Result<Vec<Document>, String> {
|
||||||
|
let all_documents = load_documents()?;
|
||||||
|
|
||||||
|
// Filter documents by company_id
|
||||||
|
let company_documents = all_documents
|
||||||
|
.into_iter()
|
||||||
|
.filter(|document| document.company_id == company_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(company_documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single document by its ID
|
||||||
|
pub fn get_document_by_id(document_id: u32) -> Result<Option<Document>, String> {
|
||||||
|
let documents = load_documents()?;
|
||||||
|
|
||||||
|
let document = documents.into_iter().find(|doc| doc.id == document_id);
|
||||||
|
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates document in the database
|
||||||
|
pub fn update_document(
|
||||||
|
document_id: u32,
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
document_type: Option<DocumentType>,
|
||||||
|
is_public: Option<bool>,
|
||||||
|
) -> Result<Document, String> {
|
||||||
|
let mut documents = load_documents()?;
|
||||||
|
|
||||||
|
if let Some(document) = documents.iter_mut().find(|doc| doc.id == document_id) {
|
||||||
|
// Update fields
|
||||||
|
if let Some(name) = name {
|
||||||
|
document.name = name;
|
||||||
|
}
|
||||||
|
if let Some(description) = description {
|
||||||
|
document.description = Some(description);
|
||||||
|
}
|
||||||
|
if let Some(document_type) = document_type {
|
||||||
|
document.document_type = document_type;
|
||||||
|
}
|
||||||
|
if let Some(is_public) = is_public {
|
||||||
|
document.is_public = is_public;
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated_document = document.clone();
|
||||||
|
save_documents(&documents)?;
|
||||||
|
Ok(updated_document)
|
||||||
|
} else {
|
||||||
|
Err("Document not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes document from the database
|
||||||
|
pub fn delete_document(document_id: u32) -> Result<(), String> {
|
||||||
|
let mut documents = load_documents()?;
|
||||||
|
|
||||||
|
let initial_len = documents.len();
|
||||||
|
documents.retain(|doc| doc.id != document_id);
|
||||||
|
|
||||||
|
if documents.len() == initial_len {
|
||||||
|
return Err("Document not found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
save_documents(&documents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets documents by type for a company
|
||||||
|
pub fn get_company_documents_by_type(
|
||||||
|
company_id: u32,
|
||||||
|
document_type: DocumentType,
|
||||||
|
) -> Result<Vec<Document>, String> {
|
||||||
|
let company_documents = get_company_documents(company_id)?;
|
||||||
|
|
||||||
|
let filtered_documents = company_documents
|
||||||
|
.into_iter()
|
||||||
|
.filter(|doc| doc.document_type == document_type)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(filtered_documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets public documents for a company
|
||||||
|
pub fn get_public_company_documents(company_id: u32) -> Result<Vec<Document>, String> {
|
||||||
|
let company_documents = get_company_documents(company_id)?;
|
||||||
|
|
||||||
|
let public_documents = company_documents
|
||||||
|
.into_iter()
|
||||||
|
.filter(|doc| doc.is_public)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(public_documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches documents by name for a company
|
||||||
|
pub fn search_company_documents(
|
||||||
|
company_id: u32,
|
||||||
|
search_term: &str,
|
||||||
|
) -> Result<Vec<Document>, String> {
|
||||||
|
let company_documents = get_company_documents(company_id)?;
|
||||||
|
|
||||||
|
let search_term_lower = search_term.to_lowercase();
|
||||||
|
let matching_documents = company_documents
|
||||||
|
.into_iter()
|
||||||
|
.filter(|doc| {
|
||||||
|
doc.name.to_lowercase().contains(&search_term_lower)
|
||||||
|
|| doc.description.as_ref().map_or(false, |desc| {
|
||||||
|
desc.to_lowercase().contains(&search_term_lower)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(matching_documents)
|
||||||
|
}
|
||||||
257
actix_mvc_app/src/db/governance.rs
Normal file
257
actix_mvc_app/src/db/governance.rs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use heromodels::{
|
||||||
|
db::{Collection, Db},
|
||||||
|
models::governance::{Activity, ActivityType, Proposal, ProposalStatus},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::db::get_db;
|
||||||
|
|
||||||
|
/// Creates a new proposal and saves it to the database. Returns the saved proposal and its ID.
|
||||||
|
pub fn create_new_proposal(
|
||||||
|
creator_id: &str,
|
||||||
|
creator_name: &str,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
status: ProposalStatus,
|
||||||
|
voting_start_date: Option<chrono::DateTime<Utc>>,
|
||||||
|
voting_end_date: Option<chrono::DateTime<Utc>>,
|
||||||
|
) -> Result<(u32, Proposal), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
let created_at = Utc::now();
|
||||||
|
let updated_at = created_at;
|
||||||
|
|
||||||
|
// Create a new proposal (with auto-generated ID)
|
||||||
|
let proposal = Proposal::new(
|
||||||
|
None,
|
||||||
|
creator_id,
|
||||||
|
creator_name,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
voting_start_date.unwrap_or_else(Utc::now),
|
||||||
|
voting_end_date.unwrap_or_else(|| Utc::now() + Duration::days(7)),
|
||||||
|
);
|
||||||
|
// Save the proposal to the database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Proposal>()
|
||||||
|
.expect("can open proposal collection");
|
||||||
|
let (proposal_id, saved_proposal) = collection.set(&proposal).expect("can save proposal");
|
||||||
|
|
||||||
|
Ok((proposal_id, saved_proposal))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all proposals from the database and returns them as a Vec<Proposal>.
|
||||||
|
pub fn get_proposals() -> Result<Vec<Proposal>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Proposal>()
|
||||||
|
.expect("can open proposal collection");
|
||||||
|
|
||||||
|
// Try to load all proposals, but handle deserialization errors gracefully
|
||||||
|
let proposals = match collection.get_all() {
|
||||||
|
Ok(props) => props,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error loading proposals: {:?}", e);
|
||||||
|
vec![] // Return an empty vector if there's an error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(proposals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a single proposal by its ID from the database.
|
||||||
|
pub fn get_proposal_by_id(proposal_id: u32) -> Result<Option<Proposal>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Proposal>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
match collection.get_by_id(proposal_id) {
|
||||||
|
Ok(proposal) => Ok(Some(proposal.expect("proposal not found"))),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error fetching proposal by id {}: {:?}", proposal_id, e);
|
||||||
|
Err(format!("Failed to fetch proposal: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submits a vote on a proposal and returns the updated proposal
|
||||||
|
pub fn submit_vote_on_proposal(
|
||||||
|
proposal_id: u32,
|
||||||
|
user_id: i32,
|
||||||
|
vote_type: &str,
|
||||||
|
shares_count: u32, // Default to 1 if not specified
|
||||||
|
comment: Option<String>,
|
||||||
|
) -> Result<Proposal, String> {
|
||||||
|
// Get the proposal from the database
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Proposal>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
// Get the proposal
|
||||||
|
let mut proposal = collection
|
||||||
|
.get_by_id(proposal_id)
|
||||||
|
.map_err(|e| format!("Failed to fetch proposal: {:?}", e))?
|
||||||
|
.ok_or_else(|| format!("Proposal not found with ID: {}", proposal_id))?;
|
||||||
|
|
||||||
|
// Ensure the proposal has vote options
|
||||||
|
// Check if the proposal already has options
|
||||||
|
if proposal.options.is_empty() {
|
||||||
|
// Add standard vote options if they don't exist
|
||||||
|
proposal = proposal.add_option(1, "Approve", Some("Approve the proposal"));
|
||||||
|
proposal = proposal.add_option(2, "Reject", Some("Reject the proposal"));
|
||||||
|
proposal = proposal.add_option(3, "Abstain", Some("Abstain from voting"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map vote_type to option_id
|
||||||
|
let option_id = match vote_type {
|
||||||
|
"Yes" => 1, // Approve
|
||||||
|
"No" => 2, // Reject
|
||||||
|
"Abstain" => 3, // Abstain
|
||||||
|
_ => return Err(format!("Invalid vote type: {}", vote_type)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Since we're having issues with the cast_vote method, let's implement a workaround
|
||||||
|
// that directly updates the vote count for the selected option
|
||||||
|
|
||||||
|
// Check if the proposal is active
|
||||||
|
if proposal.status != ProposalStatus::Active {
|
||||||
|
return Err(format!(
|
||||||
|
"Cannot vote on a proposal with status: {:?}",
|
||||||
|
proposal.status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if voting period is valid
|
||||||
|
let now = Utc::now();
|
||||||
|
if now > proposal.vote_end_date {
|
||||||
|
return Err("Voting period has ended".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if now < proposal.vote_start_date {
|
||||||
|
return Err("Voting period has not started yet".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the option and increment its count
|
||||||
|
let mut option_found = false;
|
||||||
|
for option in &mut proposal.options {
|
||||||
|
if option.id == option_id {
|
||||||
|
option.count += shares_count as i64;
|
||||||
|
option_found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !option_found {
|
||||||
|
return Err(format!("Option with ID {} not found", option_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the vote in the proposal's ballots
|
||||||
|
// We'll create a simple ballot with an auto-generated ID
|
||||||
|
let ballot_id = proposal.ballots.len() as u32 + 1;
|
||||||
|
|
||||||
|
// Create a new ballot and add it to the proposal's ballots
|
||||||
|
use heromodels::models::governance::Ballot;
|
||||||
|
|
||||||
|
// Use the Ballot::new constructor which handles the BaseModelData creation
|
||||||
|
let mut ballot = Ballot::new(
|
||||||
|
Some(ballot_id),
|
||||||
|
user_id as u32,
|
||||||
|
option_id,
|
||||||
|
shares_count as i64,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the comment if provided
|
||||||
|
ballot.comment = comment;
|
||||||
|
|
||||||
|
// Store the local time (EEST = UTC+3) as the vote timestamp
|
||||||
|
// This ensures the displayed time matches the user's local time
|
||||||
|
let utc_now = Utc::now();
|
||||||
|
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
|
||||||
|
let local_time = utc_now.with_timezone(&local_offset);
|
||||||
|
|
||||||
|
// Store the local time as a timestamp (this is what will be displayed)
|
||||||
|
ballot.base_data.created_at = local_time.timestamp();
|
||||||
|
|
||||||
|
// Add the ballot to the proposal's ballots
|
||||||
|
proposal.ballots.push(ballot);
|
||||||
|
|
||||||
|
// Update the proposal's updated_at timestamp
|
||||||
|
proposal.updated_at = Utc::now();
|
||||||
|
|
||||||
|
// Save the updated proposal
|
||||||
|
let (_, updated_proposal) = collection
|
||||||
|
.set(&proposal)
|
||||||
|
.map_err(|e| format!("Failed to save vote: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(updated_proposal)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused_assignments)]
|
||||||
|
/// Creates a new governance activity and saves it to the database using OurDB
|
||||||
|
pub fn create_activity(
|
||||||
|
proposal_id: u32,
|
||||||
|
proposal_title: &str,
|
||||||
|
creator_name: &str,
|
||||||
|
activity_type: &ActivityType,
|
||||||
|
) -> Result<(u32, Activity), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
let mut activity = Activity::default();
|
||||||
|
|
||||||
|
match activity_type {
|
||||||
|
ActivityType::ProposalCreated => {
|
||||||
|
activity = Activity::proposal_created(proposal_id, proposal_title, creator_name);
|
||||||
|
}
|
||||||
|
ActivityType::VoteCast => {
|
||||||
|
activity = Activity::vote_cast(proposal_id, proposal_title, creator_name);
|
||||||
|
}
|
||||||
|
ActivityType::VotingStarted => {
|
||||||
|
activity = Activity::voting_started(proposal_id, proposal_title);
|
||||||
|
}
|
||||||
|
ActivityType::VotingEnded => {
|
||||||
|
activity = Activity::voting_ended(proposal_id, proposal_title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the proposal to the database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Activity>()
|
||||||
|
.expect("can open activity collection");
|
||||||
|
|
||||||
|
let (proposal_id, saved_proposal) = collection.set(&activity).expect("can save proposal");
|
||||||
|
Ok((proposal_id, saved_proposal))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recent_activities() -> Result<Vec<Activity>, String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
let collection = db
|
||||||
|
.collection::<Activity>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
let mut db_activities = collection
|
||||||
|
.get_all()
|
||||||
|
.map_err(|e| format!("DB fetch error: {:?}", e))?;
|
||||||
|
|
||||||
|
// Sort by created_at descending
|
||||||
|
db_activities.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
|
||||||
|
// Take the top 10 most recent
|
||||||
|
let recent_activities = db_activities.into_iter().take(10).collect();
|
||||||
|
|
||||||
|
Ok(recent_activities)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_activities() -> Result<Vec<Activity>, String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
let collection = db
|
||||||
|
.collection::<Activity>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
let db_activities = collection
|
||||||
|
.get_all()
|
||||||
|
.map_err(|e| format!("DB fetch error: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(db_activities)
|
||||||
|
}
|
||||||
8
actix_mvc_app/src/db/mod.rs
Normal file
8
actix_mvc_app/src/db/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod calendar;
|
||||||
|
pub mod company;
|
||||||
|
pub mod contracts;
|
||||||
|
pub mod db;
|
||||||
|
pub mod document;
|
||||||
|
pub mod governance;
|
||||||
|
pub mod payment;
|
||||||
|
pub mod registration;
|
||||||
355
actix_mvc_app/src/db/payment.rs
Normal file
355
actix_mvc_app/src/db/payment.rs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
#![allow(dead_code)] // Database utility functions may not all be used yet
|
||||||
|
|
||||||
|
use super::db::get_db;
|
||||||
|
use heromodels::{
|
||||||
|
db::{Collection, Db},
|
||||||
|
models::{Payment, PaymentStatus},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Creates a new payment and saves it to the database
|
||||||
|
pub fn create_new_payment(
|
||||||
|
payment_intent_id: String,
|
||||||
|
company_id: u32,
|
||||||
|
payment_plan: String,
|
||||||
|
setup_fee: f64,
|
||||||
|
monthly_fee: f64,
|
||||||
|
total_amount: f64,
|
||||||
|
) -> Result<(u32, Payment), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
|
||||||
|
// Create using heromodels constructor
|
||||||
|
let payment = Payment::new(
|
||||||
|
payment_intent_id.clone(),
|
||||||
|
company_id,
|
||||||
|
payment_plan,
|
||||||
|
setup_fee,
|
||||||
|
monthly_fee,
|
||||||
|
total_amount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
let (id, saved_payment) = collection.set(&payment).expect("can save payment");
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Created payment with ID {} for company {} (Intent: {})",
|
||||||
|
id,
|
||||||
|
company_id,
|
||||||
|
payment_intent_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((id, saved_payment))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all payments from the database
|
||||||
|
pub fn get_payments() -> Result<Vec<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
let payments = match collection.get_all() {
|
||||||
|
Ok(payments) => payments,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load payments from database: {:?}", e);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(payments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a payment by its database ID
|
||||||
|
pub fn get_payment_by_id(payment_id: u32) -> Result<Option<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
match collection.get_by_id(payment_id) {
|
||||||
|
Ok(payment) => Ok(payment),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get payment by ID {}: {:?}", payment_id, e);
|
||||||
|
Err(format!("Failed to get payment: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a payment by Stripe payment intent ID
|
||||||
|
pub fn get_payment_by_intent_id(payment_intent_id: &str) -> Result<Option<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
// Get all payments and find by payment_intent_id
|
||||||
|
// TODO: Use indexed query when available in heromodels
|
||||||
|
let payments = collection.get_all().map_err(|e| {
|
||||||
|
log::error!("Failed to get payments: {:?}", e);
|
||||||
|
format!("Failed to get payments: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let payment = payments
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.payment_intent_id == payment_intent_id);
|
||||||
|
|
||||||
|
Ok(payment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all payments for a specific company
|
||||||
|
pub fn get_company_payments(company_id: u32) -> Result<Vec<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
// Get all payments and filter by company_id
|
||||||
|
// TODO: Use indexed query when available in heromodels
|
||||||
|
let all_payments = collection.get_all().map_err(|e| {
|
||||||
|
log::error!("Failed to get payments: {:?}", e);
|
||||||
|
format!("Failed to get payments: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let company_payments = all_payments
|
||||||
|
.into_iter()
|
||||||
|
.filter(|payment| payment.company_id == company_id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(company_payments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a payment in the database
|
||||||
|
pub fn update_payment(payment: Payment) -> Result<(u32, Payment), String> {
|
||||||
|
let db = get_db().expect("Can get DB");
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
let (id, updated_payment) = collection.set(&payment).expect("can update payment");
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Updated payment with ID {} (Intent: {}, Status: {:?})",
|
||||||
|
id,
|
||||||
|
payment.payment_intent_id,
|
||||||
|
payment.status
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((id, updated_payment))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update payment with company ID after company creation
|
||||||
|
pub fn update_payment_company_id(
|
||||||
|
payment_intent_id: &str,
|
||||||
|
company_id: u32,
|
||||||
|
) -> Result<Option<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
// Get all payments and find the one to update
|
||||||
|
let all_payments = collection.get_all().map_err(|e| {
|
||||||
|
log::error!("Failed to get payments for company ID update: {:?}", e);
|
||||||
|
format!("Failed to get payments: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Find the payment by payment_intent_id
|
||||||
|
for (_index, payment) in all_payments.iter().enumerate() {
|
||||||
|
if payment.payment_intent_id == payment_intent_id {
|
||||||
|
// Create updated payment with company_id
|
||||||
|
let mut updated_payment = payment.clone();
|
||||||
|
updated_payment.company_id = company_id;
|
||||||
|
|
||||||
|
// Update in database (this is a limitation of current DB interface)
|
||||||
|
let (_, saved_payment) = collection.set(&updated_payment).map_err(|e| {
|
||||||
|
log::error!("Failed to update payment company ID: {:?}", e);
|
||||||
|
format!("Failed to update payment: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Updated payment {} with company ID {}",
|
||||||
|
payment_intent_id,
|
||||||
|
company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(Some(saved_payment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"Payment not found for intent ID: {} (cannot update company ID)",
|
||||||
|
payment_intent_id
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update payment status
|
||||||
|
pub fn update_payment_status(
|
||||||
|
payment_intent_id: &str,
|
||||||
|
status: heromodels::models::biz::PaymentStatus,
|
||||||
|
) -> Result<Option<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
// Get all payments and find the one to update
|
||||||
|
let all_payments = collection.get_all().map_err(|e| {
|
||||||
|
log::error!("Failed to get payments for status update: {:?}", e);
|
||||||
|
format!("Failed to get payments: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Find the payment by payment_intent_id
|
||||||
|
for (_index, payment) in all_payments.iter().enumerate() {
|
||||||
|
if payment.payment_intent_id == payment_intent_id {
|
||||||
|
// Create updated payment with new status
|
||||||
|
let mut updated_payment = payment.clone();
|
||||||
|
updated_payment.status = status.clone();
|
||||||
|
|
||||||
|
// Update in database
|
||||||
|
let (_, saved_payment) = collection.set(&updated_payment).map_err(|e| {
|
||||||
|
log::error!("Failed to update payment status: {:?}", e);
|
||||||
|
format!("Failed to update payment: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Updated payment {} status to {:?}",
|
||||||
|
payment_intent_id,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(Some(saved_payment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"Payment not found for intent ID: {} (cannot update status)",
|
||||||
|
payment_intent_id
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all pending payments (for monitoring/retry)
|
||||||
|
pub fn get_pending_payments() -> Result<Vec<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
let all_payments = collection.get_all().map_err(|e| {
|
||||||
|
log::error!("Failed to get payments: {:?}", e);
|
||||||
|
format!("Failed to get payments: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Filter for pending payments
|
||||||
|
let pending_payments = all_payments
|
||||||
|
.into_iter()
|
||||||
|
.filter(|payment| payment.status == heromodels::models::biz::PaymentStatus::Pending)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(pending_payments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get failed payments (for retry/investigation)
|
||||||
|
pub fn get_failed_payments() -> Result<Vec<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
let all_payments = collection.get_all().map_err(|e| {
|
||||||
|
log::error!("Failed to get payments: {:?}", e);
|
||||||
|
format!("Failed to get payments: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Filter for failed payments
|
||||||
|
let failed_payments = all_payments
|
||||||
|
.into_iter()
|
||||||
|
.filter(|payment| payment.status == heromodels::models::biz::PaymentStatus::Failed)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(failed_payments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes a payment (marks as completed with Stripe customer ID)
|
||||||
|
pub fn complete_payment(
|
||||||
|
payment_intent_id: &str,
|
||||||
|
stripe_customer_id: Option<String>,
|
||||||
|
) -> Result<Option<Payment>, String> {
|
||||||
|
if let Some(payment) = get_payment_by_intent_id(payment_intent_id)? {
|
||||||
|
let completed_payment = payment.complete_payment(stripe_customer_id);
|
||||||
|
let (_, updated_payment) = update_payment(completed_payment)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Completed payment {} for company {}",
|
||||||
|
payment_intent_id,
|
||||||
|
updated_payment.company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Some(updated_payment))
|
||||||
|
} else {
|
||||||
|
log::warn!("Payment not found for intent ID: {}", payment_intent_id);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a payment as failed
|
||||||
|
pub fn fail_payment(payment_intent_id: &str) -> Result<Option<Payment>, String> {
|
||||||
|
if let Some(payment) = get_payment_by_intent_id(payment_intent_id)? {
|
||||||
|
let failed_payment = payment.fail_payment();
|
||||||
|
let (_, updated_payment) = update_payment(failed_payment)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Failed payment {} for company {}",
|
||||||
|
payment_intent_id,
|
||||||
|
updated_payment.company_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Some(updated_payment))
|
||||||
|
} else {
|
||||||
|
log::warn!("Payment not found for intent ID: {}", payment_intent_id);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets payments by status
|
||||||
|
pub fn get_payments_by_status(status: PaymentStatus) -> Result<Vec<Payment>, String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.expect("can open payment collection");
|
||||||
|
|
||||||
|
// Get all payments and filter by status
|
||||||
|
// TODO: Use indexed query when available in heromodels
|
||||||
|
let all_payments = collection.get_all().map_err(|e| {
|
||||||
|
log::error!("Failed to get payments: {:?}", e);
|
||||||
|
format!("Failed to get payments: {:?}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let filtered_payments = all_payments
|
||||||
|
.into_iter()
|
||||||
|
.filter(|payment| payment.status == status)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(filtered_payments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a payment from the database
|
||||||
|
pub fn delete_payment(payment_id: u32) -> Result<(), String> {
|
||||||
|
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
let collection = db
|
||||||
|
.collection::<Payment>()
|
||||||
|
.map_err(|e| format!("Collection error: {:?}", e))?;
|
||||||
|
|
||||||
|
match collection.delete_by_id(payment_id) {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!("Successfully deleted payment with ID {}", payment_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to delete payment {}: {:?}", payment_id, e);
|
||||||
|
Err(format!("Failed to delete payment: {:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
272
actix_mvc_app/src/db/registration.rs
Normal file
272
actix_mvc_app/src/db/registration.rs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
#![allow(dead_code)] // Database utility functions may not all be used yet
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Stored registration data linked to payment intent
|
||||||
|
/// This preserves all user form data until company creation after payment success
|
||||||
|
/// NOTE: This uses file-based storage until we can add the model to heromodels
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StoredRegistrationData {
|
||||||
|
pub payment_intent_id: String,
|
||||||
|
pub company_name: String,
|
||||||
|
pub company_type: 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>,
|
||||||
|
pub shareholders: String, // JSON string of shareholders array
|
||||||
|
pub payment_plan: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File path for storing registration data
|
||||||
|
const REGISTRATION_DATA_FILE: &str = "data/registration_data.json";
|
||||||
|
|
||||||
|
/// Ensure data directory exists
|
||||||
|
fn ensure_data_directory() -> Result<(), String> {
|
||||||
|
let data_dir = Path::new("data");
|
||||||
|
if !data_dir.exists() {
|
||||||
|
fs::create_dir_all(data_dir)
|
||||||
|
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all registration data from file
|
||||||
|
fn load_registration_data() -> Result<HashMap<String, StoredRegistrationData>, String> {
|
||||||
|
if !Path::new(REGISTRATION_DATA_FILE).exists() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(REGISTRATION_DATA_FILE)
|
||||||
|
.map_err(|e| format!("Failed to read registration data file: {}", e))?;
|
||||||
|
|
||||||
|
let data: HashMap<String, StoredRegistrationData> = serde_json::from_str(&content)
|
||||||
|
.map_err(|e| format!("Failed to parse registration data: {}", e))?;
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save all registration data to file
|
||||||
|
fn save_registration_data(data: &HashMap<String, StoredRegistrationData>) -> Result<(), String> {
|
||||||
|
ensure_data_directory()?;
|
||||||
|
|
||||||
|
let content = serde_json::to_string_pretty(data)
|
||||||
|
.map_err(|e| format!("Failed to serialize registration data: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(REGISTRATION_DATA_FILE, content)
|
||||||
|
.map_err(|e| format!("Failed to write registration data file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredRegistrationData {
|
||||||
|
/// Create new stored registration data
|
||||||
|
pub fn new(
|
||||||
|
payment_intent_id: String,
|
||||||
|
company_name: String,
|
||||||
|
company_type: String,
|
||||||
|
company_email: String,
|
||||||
|
company_phone: String,
|
||||||
|
company_website: Option<String>,
|
||||||
|
company_address: String,
|
||||||
|
company_industry: Option<String>,
|
||||||
|
company_purpose: Option<String>,
|
||||||
|
fiscal_year_end: Option<String>,
|
||||||
|
shareholders: String,
|
||||||
|
payment_plan: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
payment_intent_id,
|
||||||
|
company_name,
|
||||||
|
company_type,
|
||||||
|
company_email,
|
||||||
|
company_phone,
|
||||||
|
company_website,
|
||||||
|
company_address,
|
||||||
|
company_industry,
|
||||||
|
company_purpose,
|
||||||
|
fiscal_year_end,
|
||||||
|
shareholders,
|
||||||
|
payment_plan,
|
||||||
|
created_at: chrono::Utc::now().timestamp(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store registration data linked to payment intent
|
||||||
|
pub fn store_registration_data(
|
||||||
|
payment_intent_id: String,
|
||||||
|
data: crate::controllers::payment::CompanyRegistrationData,
|
||||||
|
) -> Result<(u32, StoredRegistrationData), String> {
|
||||||
|
// Create stored registration data
|
||||||
|
let stored_data = StoredRegistrationData::new(
|
||||||
|
payment_intent_id.clone(),
|
||||||
|
data.company_name,
|
||||||
|
data.company_type,
|
||||||
|
data.company_email
|
||||||
|
.unwrap_or_else(|| "noemail@example.com".to_string()),
|
||||||
|
data.company_phone
|
||||||
|
.unwrap_or_else(|| "+1234567890".to_string()),
|
||||||
|
data.company_website,
|
||||||
|
data.company_address
|
||||||
|
.unwrap_or_else(|| "No address provided".to_string()),
|
||||||
|
data.company_industry,
|
||||||
|
data.company_purpose,
|
||||||
|
data.fiscal_year_end,
|
||||||
|
data.shareholders,
|
||||||
|
data.payment_plan,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load existing data
|
||||||
|
let mut all_data = load_registration_data()?;
|
||||||
|
|
||||||
|
// Add new data
|
||||||
|
all_data.insert(payment_intent_id.clone(), stored_data.clone());
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
save_registration_data(&all_data)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Stored registration data for payment intent {}",
|
||||||
|
payment_intent_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return with a generated ID (timestamp-based)
|
||||||
|
let id = chrono::Utc::now().timestamp() as u32;
|
||||||
|
Ok((id, stored_data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve registration data by payment intent ID
|
||||||
|
pub fn get_registration_data(
|
||||||
|
payment_intent_id: &str,
|
||||||
|
) -> Result<Option<StoredRegistrationData>, String> {
|
||||||
|
let all_data = load_registration_data()?;
|
||||||
|
Ok(all_data.get(payment_intent_id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all stored registration data
|
||||||
|
pub fn get_all_registration_data() -> Result<Vec<StoredRegistrationData>, String> {
|
||||||
|
let all_data = load_registration_data()?;
|
||||||
|
Ok(all_data.into_values().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete registration data by payment intent ID
|
||||||
|
pub fn delete_registration_data(payment_intent_id: &str) -> Result<bool, String> {
|
||||||
|
let mut all_data = load_registration_data()?;
|
||||||
|
|
||||||
|
if all_data.remove(payment_intent_id).is_some() {
|
||||||
|
save_registration_data(&all_data)?;
|
||||||
|
log::info!(
|
||||||
|
"Deleted registration data for payment intent: {}",
|
||||||
|
payment_intent_id
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Registration data not found for payment intent: {}",
|
||||||
|
payment_intent_id
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update registration data
|
||||||
|
pub fn update_registration_data(
|
||||||
|
payment_intent_id: &str,
|
||||||
|
updated_data: StoredRegistrationData,
|
||||||
|
) -> Result<Option<StoredRegistrationData>, String> {
|
||||||
|
let mut all_data = load_registration_data()?;
|
||||||
|
|
||||||
|
all_data.insert(payment_intent_id.to_string(), updated_data.clone());
|
||||||
|
save_registration_data(&all_data)?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Updated registration data for payment intent: {}",
|
||||||
|
payment_intent_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Some(updated_data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert StoredRegistrationData back to CompanyRegistrationData for processing
|
||||||
|
pub fn stored_to_registration_data(
|
||||||
|
stored: &StoredRegistrationData,
|
||||||
|
) -> crate::controllers::payment::CompanyRegistrationData {
|
||||||
|
crate::controllers::payment::CompanyRegistrationData {
|
||||||
|
company_name: stored.company_name.clone(),
|
||||||
|
company_type: stored.company_type.clone(),
|
||||||
|
company_email: Some(stored.company_email.clone()),
|
||||||
|
company_phone: Some(stored.company_phone.clone()),
|
||||||
|
company_website: stored.company_website.clone(),
|
||||||
|
company_address: Some(stored.company_address.clone()),
|
||||||
|
company_industry: stored.company_industry.clone(),
|
||||||
|
company_purpose: stored.company_purpose.clone(),
|
||||||
|
fiscal_year_end: stored.fiscal_year_end.clone(),
|
||||||
|
shareholders: stored.shareholders.clone(),
|
||||||
|
payment_plan: stored.payment_plan.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stored_registration_data_creation() {
|
||||||
|
let data = StoredRegistrationData::new(
|
||||||
|
"pi_test123".to_string(),
|
||||||
|
"Test Company".to_string(),
|
||||||
|
"Single FZC".to_string(),
|
||||||
|
"test@example.com".to_string(),
|
||||||
|
"+1234567890".to_string(),
|
||||||
|
Some("https://example.com".to_string()),
|
||||||
|
"123 Test St".to_string(),
|
||||||
|
Some("Technology".to_string()),
|
||||||
|
Some("Software development".to_string()),
|
||||||
|
Some("December".to_string()),
|
||||||
|
"[]".to_string(),
|
||||||
|
"monthly".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(data.payment_intent_id, "pi_test123");
|
||||||
|
assert_eq!(data.company_name, "Test Company");
|
||||||
|
assert_eq!(data.company_type, "Single FZC");
|
||||||
|
assert_eq!(data.company_email, "test@example.com");
|
||||||
|
assert!(data.created_at > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stored_to_registration_data_conversion() {
|
||||||
|
let stored = StoredRegistrationData::new(
|
||||||
|
"pi_test123".to_string(),
|
||||||
|
"Test Company".to_string(),
|
||||||
|
"Single FZC".to_string(),
|
||||||
|
"test@example.com".to_string(),
|
||||||
|
"+1234567890".to_string(),
|
||||||
|
Some("https://example.com".to_string()),
|
||||||
|
"123 Test St".to_string(),
|
||||||
|
Some("Technology".to_string()),
|
||||||
|
Some("Software development".to_string()),
|
||||||
|
Some("December".to_string()),
|
||||||
|
"[]".to_string(),
|
||||||
|
"monthly".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let registration_data = stored_to_registration_data(&stored);
|
||||||
|
|
||||||
|
assert_eq!(registration_data.company_name, "Test Company");
|
||||||
|
assert_eq!(registration_data.company_type, "Single FZC");
|
||||||
|
assert_eq!(
|
||||||
|
registration_data.company_email,
|
||||||
|
Some("test@example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(registration_data.payment_plan, "monthly");
|
||||||
|
}
|
||||||
|
}
|
||||||
37
actix_mvc_app/src/lib.rs
Normal file
37
actix_mvc_app/src/lib.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Library exports for testing and external use
|
||||||
|
|
||||||
|
use actix_web::cookie::Key;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod controllers;
|
||||||
|
pub mod db;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod models;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod utils;
|
||||||
|
pub mod validators;
|
||||||
|
|
||||||
|
// Session key needed by routes
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref SESSION_KEY: Key = {
|
||||||
|
// In production, this should be a proper secret key from environment variables
|
||||||
|
let secret = std::env::var("SESSION_SECRET").unwrap_or_else(|_| {
|
||||||
|
// Create a key that's at least 64 bytes long
|
||||||
|
"my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the key is at least 64 bytes
|
||||||
|
let mut key_bytes = secret.into_bytes();
|
||||||
|
while key_bytes.len() < 64 {
|
||||||
|
key_bytes.extend_from_slice(b"padding");
|
||||||
|
}
|
||||||
|
key_bytes.truncate(64);
|
||||||
|
|
||||||
|
Key::from(&key_bytes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export commonly used types for easier testing
|
||||||
|
pub use controllers::payment::CompanyRegistrationData;
|
||||||
|
pub use validators::{CompanyRegistrationValidator, ValidationError, ValidationResult};
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
use actix_files as fs;
|
use actix_files as fs;
|
||||||
use actix_web::{App, HttpServer, web};
|
|
||||||
use actix_web::middleware::Logger;
|
use actix_web::middleware::Logger;
|
||||||
use tera::Tera;
|
use actix_web::{App, HttpServer, web};
|
||||||
use std::io;
|
|
||||||
use std::env;
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
use std::env;
|
||||||
|
use std::io;
|
||||||
|
use tera::Tera;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod controllers;
|
mod controllers;
|
||||||
|
mod db;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
mod validators;
|
||||||
|
|
||||||
// Import middleware components
|
// Import middleware components
|
||||||
use middleware::{RequestTimer, SecurityHeaders, JwtAuth};
|
use middleware::{JwtAuth, RequestTimer, SecurityHeaders};
|
||||||
|
use models::initialize_mock_data;
|
||||||
use utils::redis_service;
|
use utils::redis_service;
|
||||||
|
|
||||||
// Initialize lazy_static for in-memory storage
|
// Initialize lazy_static for in-memory storage
|
||||||
@@ -48,10 +51,18 @@ async fn main() -> io::Result<()> {
|
|||||||
// Load configuration
|
// Load configuration
|
||||||
let config = config::get_config();
|
let config = config::get_config();
|
||||||
|
|
||||||
// Check for port override from command line arguments
|
// Check for port override from environment variable or command line arguments
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
let mut port = config.server.port;
|
let mut port = config.server.port;
|
||||||
|
|
||||||
|
// First check environment variable
|
||||||
|
if let Ok(env_port) = env::var("PORT") {
|
||||||
|
if let Ok(p) = env_port.parse::<u16>() {
|
||||||
|
port = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check command line arguments (takes precedence over env var)
|
||||||
for i in 1..args.len() {
|
for i in 1..args.len() {
|
||||||
if args[i] == "--port" && i + 1 < args.len() {
|
if args[i] == "--port" && i + 1 < args.len() {
|
||||||
if let Ok(p) = args[i + 1].parse::<u16>() {
|
if let Ok(p) = args[i + 1].parse::<u16>() {
|
||||||
@@ -64,7 +75,8 @@ async fn main() -> io::Result<()> {
|
|||||||
let bind_address = format!("{}:{}", config.server.host, port);
|
let bind_address = format!("{}:{}", config.server.host, port);
|
||||||
|
|
||||||
// Initialize Redis client
|
// 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) {
|
if let Err(e) = redis_service::init_redis_client(&redis_url) {
|
||||||
log::error!("Failed to initialize Redis client: {}", e);
|
log::error!("Failed to initialize Redis client: {}", e);
|
||||||
log::warn!("Calendar functionality will not work properly without Redis");
|
log::warn!("Calendar functionality will not work properly without Redis");
|
||||||
@@ -72,6 +84,13 @@ async fn main() -> io::Result<()> {
|
|||||||
log::info!("Redis client initialized successfully");
|
log::info!("Redis client initialized successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize mock data for DeFi operations
|
||||||
|
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);
|
log::info!("Starting server at http://{}", bind_address);
|
||||||
|
|
||||||
// Create and configure the HTTP server
|
// Create and configure the HTTP server
|
||||||
@@ -101,6 +120,8 @@ async fn main() -> io::Result<()> {
|
|||||||
.app_data(web::Data::new(tera))
|
.app_data(web::Data::new(tera))
|
||||||
// Configure routes
|
// Configure routes
|
||||||
.configure(routes::configure_routes)
|
.configure(routes::configure_routes)
|
||||||
|
// Add default handler for 404 errors
|
||||||
|
.default_service(web::route().to(controllers::error::render_generic_not_found))
|
||||||
})
|
})
|
||||||
.bind(bind_address)?
|
.bind(bind_address)?
|
||||||
.workers(num_cpus::get())
|
.workers(num_cpus::get())
|
||||||
|
|||||||
283
actix_mvc_app/src/models/asset.rs
Normal file
283
actix_mvc_app/src/models/asset.rs
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Asset types representing different categories of digital assets
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AssetType {
|
||||||
|
Artwork,
|
||||||
|
Token,
|
||||||
|
RealEstate,
|
||||||
|
Commodity,
|
||||||
|
Share,
|
||||||
|
Bond,
|
||||||
|
IntellectualProperty,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetType {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AssetType::Artwork => "Artwork",
|
||||||
|
AssetType::Token => "Token",
|
||||||
|
AssetType::RealEstate => "Real Estate",
|
||||||
|
AssetType::Commodity => "Commodity",
|
||||||
|
AssetType::Share => "Share",
|
||||||
|
AssetType::Bond => "Bond",
|
||||||
|
AssetType::IntellectualProperty => "Intellectual Property",
|
||||||
|
AssetType::Other => "Other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of an asset
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AssetStatus {
|
||||||
|
Active,
|
||||||
|
Locked,
|
||||||
|
ForSale,
|
||||||
|
Transferred,
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetStatus {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AssetStatus::Active => "Active",
|
||||||
|
AssetStatus::Locked => "Locked",
|
||||||
|
AssetStatus::ForSale => "For Sale",
|
||||||
|
AssetStatus::Transferred => "Transferred",
|
||||||
|
AssetStatus::Archived => "Archived",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blockchain information for an asset
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BlockchainInfo {
|
||||||
|
pub blockchain: String,
|
||||||
|
pub token_id: String,
|
||||||
|
pub contract_address: String,
|
||||||
|
pub owner_address: String,
|
||||||
|
pub transaction_hash: Option<String>,
|
||||||
|
pub block_number: Option<u64>,
|
||||||
|
pub timestamp: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valuation history point for an asset
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ValuationPoint {
|
||||||
|
pub id: String,
|
||||||
|
pub date: DateTime<Utc>,
|
||||||
|
pub value: f64,
|
||||||
|
pub currency: String,
|
||||||
|
pub source: String,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction history for an asset
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AssetTransaction {
|
||||||
|
pub id: String,
|
||||||
|
pub transaction_type: String,
|
||||||
|
pub date: DateTime<Utc>,
|
||||||
|
pub from_address: Option<String>,
|
||||||
|
pub to_address: Option<String>,
|
||||||
|
pub amount: Option<f64>,
|
||||||
|
pub currency: Option<String>,
|
||||||
|
pub transaction_hash: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main Asset model
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Asset {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub asset_type: AssetType,
|
||||||
|
pub status: AssetStatus,
|
||||||
|
pub owner_id: String,
|
||||||
|
pub owner_name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub blockchain_info: Option<BlockchainInfo>,
|
||||||
|
pub current_valuation: Option<f64>,
|
||||||
|
pub valuation_currency: Option<String>,
|
||||||
|
pub valuation_date: Option<DateTime<Utc>>,
|
||||||
|
pub valuation_history: Vec<ValuationPoint>,
|
||||||
|
pub transaction_history: Vec<AssetTransaction>,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub external_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl Asset {
|
||||||
|
/// Creates a new asset
|
||||||
|
pub fn new(
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
asset_type: AssetType,
|
||||||
|
owner_id: &str,
|
||||||
|
owner_name: &str,
|
||||||
|
) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: format!("asset-{}", Uuid::new_v4().to_string()[..8].to_string()),
|
||||||
|
name: name.to_string(),
|
||||||
|
description: description.to_string(),
|
||||||
|
asset_type,
|
||||||
|
status: AssetStatus::Active,
|
||||||
|
owner_id: owner_id.to_string(),
|
||||||
|
owner_name: owner_name.to_string(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
blockchain_info: None,
|
||||||
|
current_valuation: None,
|
||||||
|
valuation_currency: None,
|
||||||
|
valuation_date: None,
|
||||||
|
valuation_history: Vec::new(),
|
||||||
|
transaction_history: Vec::new(),
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
image_url: None,
|
||||||
|
external_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds blockchain information to the asset
|
||||||
|
pub fn add_blockchain_info(&mut self, blockchain_info: BlockchainInfo) {
|
||||||
|
self.blockchain_info = Some(blockchain_info);
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a valuation point to the asset's history
|
||||||
|
pub fn add_valuation(&mut self, value: f64, currency: &str, source: &str, notes: Option<String>) {
|
||||||
|
let valuation = ValuationPoint {
|
||||||
|
id: format!("val-{}", Uuid::new_v4().to_string()[..8].to_string()),
|
||||||
|
date: Utc::now(),
|
||||||
|
value,
|
||||||
|
currency: currency.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.current_valuation = Some(value);
|
||||||
|
self.valuation_currency = Some(currency.to_string());
|
||||||
|
self.valuation_date = Some(valuation.date);
|
||||||
|
self.valuation_history.push(valuation);
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a transaction to the asset's history
|
||||||
|
pub fn add_transaction(
|
||||||
|
&mut self,
|
||||||
|
transaction_type: &str,
|
||||||
|
from_address: Option<String>,
|
||||||
|
to_address: Option<String>,
|
||||||
|
amount: Option<f64>,
|
||||||
|
currency: Option<String>,
|
||||||
|
transaction_hash: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
) {
|
||||||
|
let transaction = AssetTransaction {
|
||||||
|
id: format!("tx-{}", Uuid::new_v4().to_string()[..8].to_string()),
|
||||||
|
transaction_type: transaction_type.to_string(),
|
||||||
|
date: Utc::now(),
|
||||||
|
from_address,
|
||||||
|
to_address,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
transaction_hash,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.transaction_history.push(transaction);
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the status of the asset
|
||||||
|
pub fn update_status(&mut self, status: AssetStatus) {
|
||||||
|
self.status = status;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the latest valuation point
|
||||||
|
pub fn latest_valuation(&self) -> Option<&ValuationPoint> {
|
||||||
|
self.valuation_history.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the latest transaction
|
||||||
|
pub fn latest_transaction(&self) -> Option<&AssetTransaction> {
|
||||||
|
self.transaction_history.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the valuation history sorted by date
|
||||||
|
pub fn sorted_valuation_history(&self) -> Vec<&ValuationPoint> {
|
||||||
|
let mut history = self.valuation_history.iter().collect::<Vec<_>>();
|
||||||
|
history.sort_by(|a, b| a.date.cmp(&b.date));
|
||||||
|
history
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the transaction history sorted by date
|
||||||
|
pub fn sorted_transaction_history(&self) -> Vec<&AssetTransaction> {
|
||||||
|
let mut history = self.transaction_history.iter().collect::<Vec<_>>();
|
||||||
|
history.sort_by(|a, b| a.date.cmp(&b.date));
|
||||||
|
history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter for assets
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AssetFilter {
|
||||||
|
pub asset_type: Option<AssetType>,
|
||||||
|
pub status: Option<AssetStatus>,
|
||||||
|
pub owner_id: Option<String>,
|
||||||
|
pub min_valuation: Option<f64>,
|
||||||
|
pub max_valuation: Option<f64>,
|
||||||
|
pub valuation_currency: Option<String>,
|
||||||
|
pub search_query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics for assets
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AssetStatistics {
|
||||||
|
pub total_assets: usize,
|
||||||
|
pub total_value: f64,
|
||||||
|
pub value_by_type: std::collections::HashMap<String, f64>,
|
||||||
|
pub assets_by_type: std::collections::HashMap<String, usize>,
|
||||||
|
pub assets_by_status: std::collections::HashMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetStatistics {
|
||||||
|
pub fn new(assets: &[Asset]) -> Self {
|
||||||
|
let mut total_value = 0.0;
|
||||||
|
let mut value_by_type = std::collections::HashMap::new();
|
||||||
|
let mut assets_by_type = std::collections::HashMap::new();
|
||||||
|
let mut assets_by_status = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for asset in assets {
|
||||||
|
if let Some(valuation) = asset.current_valuation {
|
||||||
|
total_value += valuation;
|
||||||
|
|
||||||
|
let asset_type = asset.asset_type.as_str().to_string();
|
||||||
|
*value_by_type.entry(asset_type.clone()).or_insert(0.0) += valuation;
|
||||||
|
*assets_by_type.entry(asset_type).or_insert(0) += 1;
|
||||||
|
} else {
|
||||||
|
let asset_type = asset.asset_type.as_str().to_string();
|
||||||
|
*assets_by_type.entry(asset_type).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = asset.status.as_str().to_string();
|
||||||
|
*assets_by_status.entry(status).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
total_assets: assets.len(),
|
||||||
|
total_value,
|
||||||
|
value_by_type,
|
||||||
|
assets_by_type,
|
||||||
|
assets_by_status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +1,4 @@
|
|||||||
use chrono::{DateTime, Utc};
|
// No imports needed for this module currently
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Represents a calendar event
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CalendarEvent {
|
|
||||||
/// Unique identifier for the event
|
|
||||||
pub id: String,
|
|
||||||
/// Title of the event
|
|
||||||
pub title: String,
|
|
||||||
/// Description of the event
|
|
||||||
pub description: String,
|
|
||||||
/// Start time of the event
|
|
||||||
pub start_time: DateTime<Utc>,
|
|
||||||
/// End time of the event
|
|
||||||
pub end_time: DateTime<Utc>,
|
|
||||||
/// Color of the event (hex code)
|
|
||||||
pub color: String,
|
|
||||||
/// Whether the event is an all-day event
|
|
||||||
pub all_day: bool,
|
|
||||||
/// User ID of the event creator
|
|
||||||
pub user_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalendarEvent {
|
|
||||||
/// Creates a new calendar event
|
|
||||||
pub fn new(
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
start_time: DateTime<Utc>,
|
|
||||||
end_time: DateTime<Utc>,
|
|
||||||
color: Option<String>,
|
|
||||||
all_day: bool,
|
|
||||||
user_id: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
color: color.unwrap_or_else(|| "#4285F4".to_string()), // Google Calendar blue
|
|
||||||
all_day,
|
|
||||||
user_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts the event to a JSON string
|
|
||||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
|
||||||
serde_json::to_string(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an event from a JSON string
|
|
||||||
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
|
||||||
serde_json::from_str(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a view mode for the calendar
|
/// Represents a view mode for the calendar
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
446
actix_mvc_app/src/models/contract.rs
Normal file
446
actix_mvc_app/src/models/contract.rs
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
#![allow(dead_code)] // Model utility functions may not all be used yet
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Contract activity types
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ContractActivityType {
|
||||||
|
Created,
|
||||||
|
SignerAdded,
|
||||||
|
SignerRemoved,
|
||||||
|
SentForSignatures,
|
||||||
|
Signed,
|
||||||
|
Rejected,
|
||||||
|
StatusChanged,
|
||||||
|
Revised,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContractActivityType {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ContractActivityType::Created => "Contract Created",
|
||||||
|
ContractActivityType::SignerAdded => "Signer Added",
|
||||||
|
ContractActivityType::SignerRemoved => "Signer Removed",
|
||||||
|
ContractActivityType::SentForSignatures => "Sent for Signatures",
|
||||||
|
ContractActivityType::Signed => "Contract Signed",
|
||||||
|
ContractActivityType::Rejected => "Contract Rejected",
|
||||||
|
ContractActivityType::StatusChanged => "Status Changed",
|
||||||
|
ContractActivityType::Revised => "Contract Revised",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract activity model
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ContractActivity {
|
||||||
|
pub id: String,
|
||||||
|
pub contract_id: u32,
|
||||||
|
pub activity_type: ContractActivityType,
|
||||||
|
pub description: String,
|
||||||
|
pub user_name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContractActivity {
|
||||||
|
/// Creates a new contract activity
|
||||||
|
pub fn new(
|
||||||
|
contract_id: u32,
|
||||||
|
activity_type: ContractActivityType,
|
||||||
|
description: String,
|
||||||
|
user_name: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
contract_id,
|
||||||
|
activity_type,
|
||||||
|
description,
|
||||||
|
user_name,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
metadata: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a contract creation activity
|
||||||
|
pub fn contract_created(contract_id: u32, contract_title: &str, user_name: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
contract_id,
|
||||||
|
ContractActivityType::Created,
|
||||||
|
format!("Created contract '{}'", contract_title),
|
||||||
|
user_name.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a signer added activity
|
||||||
|
pub fn signer_added(contract_id: u32, signer_name: &str, user_name: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
contract_id,
|
||||||
|
ContractActivityType::SignerAdded,
|
||||||
|
format!("Added signer: {}", signer_name),
|
||||||
|
user_name.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a sent for signatures activity
|
||||||
|
pub fn sent_for_signatures(contract_id: u32, signer_count: usize, user_name: &str) -> Self {
|
||||||
|
Self::new(
|
||||||
|
contract_id,
|
||||||
|
ContractActivityType::SentForSignatures,
|
||||||
|
format!("Sent contract for signatures to {} signer(s)", signer_count),
|
||||||
|
user_name.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract status enum
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ContractStatus {
|
||||||
|
Draft,
|
||||||
|
PendingSignatures,
|
||||||
|
Signed,
|
||||||
|
Active,
|
||||||
|
Expired,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContractStatus {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ContractStatus::Draft => "Draft",
|
||||||
|
ContractStatus::PendingSignatures => "Pending Signatures",
|
||||||
|
ContractStatus::Signed => "Signed",
|
||||||
|
ContractStatus::Active => "Active",
|
||||||
|
ContractStatus::Expired => "Expired",
|
||||||
|
ContractStatus::Cancelled => "Cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract type enum
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ContractType {
|
||||||
|
Service,
|
||||||
|
Employment,
|
||||||
|
NDA,
|
||||||
|
SLA,
|
||||||
|
Partnership,
|
||||||
|
Distribution,
|
||||||
|
License,
|
||||||
|
Membership,
|
||||||
|
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::Partnership => "Partnership Agreement",
|
||||||
|
ContractType::Distribution => "Distribution Agreement",
|
||||||
|
ContractType::License => "License Agreement",
|
||||||
|
ContractType::Membership => "Membership 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<DateTime<Utc>>,
|
||||||
|
pub comments: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
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<String>) {
|
||||||
|
self.status = SignerStatus::Signed;
|
||||||
|
self.signed_at = Some(Utc::now());
|
||||||
|
self.comments = comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejects the contract
|
||||||
|
pub fn reject(&mut self, comments: Option<String>) {
|
||||||
|
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<Utc>,
|
||||||
|
pub created_by: String,
|
||||||
|
pub comments: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl ContractRevision {
|
||||||
|
/// Creates a new contract revision
|
||||||
|
pub fn new(
|
||||||
|
version: u32,
|
||||||
|
content: String,
|
||||||
|
created_by: String,
|
||||||
|
comments: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
version,
|
||||||
|
content,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
created_by,
|
||||||
|
comments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Table of Contents item for multi-page contracts
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TocItem {
|
||||||
|
pub title: String,
|
||||||
|
pub file: String,
|
||||||
|
pub children: Vec<TocItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub created_by: String,
|
||||||
|
pub effective_date: Option<DateTime<Utc>>,
|
||||||
|
pub expiration_date: Option<DateTime<Utc>>,
|
||||||
|
pub signers: Vec<ContractSigner>,
|
||||||
|
pub revisions: Vec<ContractRevision>,
|
||||||
|
pub current_version: u32,
|
||||||
|
pub organization_id: Option<String>,
|
||||||
|
// Multi-page markdown support
|
||||||
|
pub content_dir: Option<String>,
|
||||||
|
pub toc: Option<Vec<TocItem>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl Contract {
|
||||||
|
/// Creates a new contract
|
||||||
|
pub fn new(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
contract_type: ContractType,
|
||||||
|
created_by: String,
|
||||||
|
organization_id: Option<String>,
|
||||||
|
) -> 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: 1,
|
||||||
|
organization_id,
|
||||||
|
content_dir: None,
|
||||||
|
toc: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>) {
|
||||||
|
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<ContractStatus>,
|
||||||
|
pub contract_type: Option<ContractType>,
|
||||||
|
pub created_by: Option<String>,
|
||||||
|
pub organization_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
actix_mvc_app/src/models/defi.rs
Normal file
209
actix_mvc_app/src/models/defi.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// DeFi position status
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum DefiPositionStatus {
|
||||||
|
Active,
|
||||||
|
Completed,
|
||||||
|
Liquidated,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl DefiPositionStatus {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
DefiPositionStatus::Active => "Active",
|
||||||
|
DefiPositionStatus::Completed => "Completed",
|
||||||
|
DefiPositionStatus::Liquidated => "Liquidated",
|
||||||
|
DefiPositionStatus::Cancelled => "Cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeFi position type
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum DefiPositionType {
|
||||||
|
Providing,
|
||||||
|
Receiving,
|
||||||
|
Liquidity,
|
||||||
|
Staking,
|
||||||
|
Collateral,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl DefiPositionType {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
DefiPositionType::Providing => "Providing",
|
||||||
|
DefiPositionType::Receiving => "Receiving",
|
||||||
|
DefiPositionType::Liquidity => "Liquidity",
|
||||||
|
DefiPositionType::Staking => "Staking",
|
||||||
|
DefiPositionType::Collateral => "Collateral",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base DeFi position
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DefiPosition {
|
||||||
|
pub id: String,
|
||||||
|
pub position_type: DefiPositionType,
|
||||||
|
pub status: DefiPositionStatus,
|
||||||
|
pub asset_id: String,
|
||||||
|
pub asset_name: String,
|
||||||
|
pub asset_symbol: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub value_usd: f64,
|
||||||
|
pub expected_return: f64,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providing position
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProvidingPosition {
|
||||||
|
pub base: DefiPosition,
|
||||||
|
pub duration_days: i32,
|
||||||
|
pub profit_share_earned: f64,
|
||||||
|
pub return_amount: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receiving position
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReceivingPosition {
|
||||||
|
pub base: DefiPosition,
|
||||||
|
pub collateral_asset_id: String,
|
||||||
|
pub collateral_asset_name: String,
|
||||||
|
pub collateral_asset_symbol: String,
|
||||||
|
pub collateral_amount: f64,
|
||||||
|
pub collateral_value_usd: f64,
|
||||||
|
pub duration_days: i32,
|
||||||
|
pub profit_share_rate: f64,
|
||||||
|
pub profit_share_owed: f64,
|
||||||
|
pub total_to_repay: f64,
|
||||||
|
pub collateral_ratio: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory database for DeFi positions
|
||||||
|
pub struct DefiDatabase {
|
||||||
|
providing_positions: HashMap<String, ProvidingPosition>,
|
||||||
|
receiving_positions: HashMap<String, ReceivingPosition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl DefiDatabase {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
providing_positions: HashMap::new(),
|
||||||
|
receiving_positions: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providing operations
|
||||||
|
pub fn add_providing_position(&mut self, position: ProvidingPosition) {
|
||||||
|
self.providing_positions.insert(position.base.id.clone(), position);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_providing_position(&self, id: &str) -> Option<&ProvidingPosition> {
|
||||||
|
self.providing_positions.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_providing_positions(&self) -> Vec<&ProvidingPosition> {
|
||||||
|
self.providing_positions.values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_providing_positions(&self, user_id: &str) -> Vec<&ProvidingPosition> {
|
||||||
|
self.providing_positions
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.base.user_id == user_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receiving operations
|
||||||
|
pub fn add_receiving_position(&mut self, position: ReceivingPosition) {
|
||||||
|
self.receiving_positions.insert(position.base.id.clone(), position);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_receiving_position(&self, id: &str) -> Option<&ReceivingPosition> {
|
||||||
|
self.receiving_positions.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_receiving_positions(&self) -> Vec<&ReceivingPosition> {
|
||||||
|
self.receiving_positions.values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_receiving_positions(&self, user_id: &str) -> Vec<&ReceivingPosition> {
|
||||||
|
self.receiving_positions
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.base.user_id == user_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance of the DeFi database
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref DEFI_DB: Arc<Mutex<DefiDatabase>> = Arc::new(Mutex::new(DefiDatabase::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the database with mock data
|
||||||
|
pub fn initialize_mock_data() {
|
||||||
|
let mut db = DEFI_DB.lock().unwrap();
|
||||||
|
|
||||||
|
// Add mock providing positions
|
||||||
|
let providing_position = ProvidingPosition {
|
||||||
|
base: DefiPosition {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
position_type: DefiPositionType::Providing,
|
||||||
|
status: DefiPositionStatus::Active,
|
||||||
|
asset_id: "TFT".to_string(),
|
||||||
|
asset_name: "ThreeFold Token".to_string(),
|
||||||
|
asset_symbol: "TFT".to_string(),
|
||||||
|
amount: 1000.0,
|
||||||
|
value_usd: 500.0,
|
||||||
|
expected_return: 4.2,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
expires_at: Some(Utc::now() + chrono::Duration::days(30)),
|
||||||
|
user_id: "user123".to_string(),
|
||||||
|
},
|
||||||
|
duration_days: 30,
|
||||||
|
profit_share_earned: 3.5,
|
||||||
|
return_amount: 1003.5,
|
||||||
|
};
|
||||||
|
db.add_providing_position(providing_position);
|
||||||
|
|
||||||
|
// Add mock receiving positions
|
||||||
|
let receiving_position = ReceivingPosition {
|
||||||
|
base: DefiPosition {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
position_type: DefiPositionType::Receiving,
|
||||||
|
status: DefiPositionStatus::Active,
|
||||||
|
asset_id: "ZDFZ".to_string(),
|
||||||
|
asset_name: "Zanzibar Token".to_string(),
|
||||||
|
asset_symbol: "ZDFZ".to_string(),
|
||||||
|
amount: 500.0,
|
||||||
|
value_usd: 250.0,
|
||||||
|
expected_return: 5.8,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
expires_at: Some(Utc::now() + chrono::Duration::days(90)),
|
||||||
|
user_id: "user123".to_string(),
|
||||||
|
},
|
||||||
|
collateral_asset_id: "TFT".to_string(),
|
||||||
|
collateral_asset_name: "ThreeFold Token".to_string(),
|
||||||
|
collateral_asset_symbol: "TFT".to_string(),
|
||||||
|
collateral_amount: 1500.0,
|
||||||
|
collateral_value_usd: 750.0,
|
||||||
|
duration_days: 90,
|
||||||
|
profit_share_rate: 5.8,
|
||||||
|
profit_share_owed: 3.625,
|
||||||
|
total_to_repay: 503.625,
|
||||||
|
collateral_ratio: 300.0,
|
||||||
|
};
|
||||||
|
db.add_receiving_position(receiving_position);
|
||||||
|
}
|
||||||
254
actix_mvc_app/src/models/document.rs
Normal file
254
actix_mvc_app/src/models/document.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
#![allow(dead_code)] // Model utility functions may not all be used yet
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Document type enumeration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum DocumentType {
|
||||||
|
Articles, // Articles of Incorporation
|
||||||
|
Certificate, // Business certificates
|
||||||
|
License, // Business licenses
|
||||||
|
Contract, // Contracts and agreements
|
||||||
|
Financial, // Financial documents
|
||||||
|
Legal, // Legal documents
|
||||||
|
Other, // Other documents
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DocumentType {
|
||||||
|
fn default() -> Self {
|
||||||
|
DocumentType::Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentType {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
DocumentType::Articles => "Articles of Incorporation",
|
||||||
|
DocumentType::Certificate => "Business Certificate",
|
||||||
|
DocumentType::License => "Business License",
|
||||||
|
DocumentType::Contract => "Contract/Agreement",
|
||||||
|
DocumentType::Financial => "Financial Document",
|
||||||
|
DocumentType::Legal => "Legal Document",
|
||||||
|
DocumentType::Other => "Other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"Articles" => DocumentType::Articles,
|
||||||
|
"Certificate" => DocumentType::Certificate,
|
||||||
|
"License" => DocumentType::License,
|
||||||
|
"Contract" => DocumentType::Contract,
|
||||||
|
"Financial" => DocumentType::Financial,
|
||||||
|
"Legal" => DocumentType::Legal,
|
||||||
|
_ => DocumentType::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all() -> Vec<DocumentType> {
|
||||||
|
vec![
|
||||||
|
DocumentType::Articles,
|
||||||
|
DocumentType::Certificate,
|
||||||
|
DocumentType::License,
|
||||||
|
DocumentType::Contract,
|
||||||
|
DocumentType::Financial,
|
||||||
|
DocumentType::Legal,
|
||||||
|
DocumentType::Other,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Document model for company document management
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Document {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub company_id: u32,
|
||||||
|
pub document_type: DocumentType,
|
||||||
|
pub uploaded_by: String,
|
||||||
|
pub upload_date: DateTime<Utc>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub is_public: bool,
|
||||||
|
pub checksum: Option<String>,
|
||||||
|
// Template-friendly fields
|
||||||
|
pub is_pdf: bool,
|
||||||
|
pub is_image: bool,
|
||||||
|
pub document_type_str: String,
|
||||||
|
pub formatted_file_size: String,
|
||||||
|
pub formatted_upload_date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Document {
|
||||||
|
/// Creates a new document (ID will be assigned by database)
|
||||||
|
pub fn new(
|
||||||
|
name: String,
|
||||||
|
file_path: String,
|
||||||
|
file_size: u64,
|
||||||
|
mime_type: String,
|
||||||
|
company_id: u32,
|
||||||
|
uploaded_by: String,
|
||||||
|
) -> Self {
|
||||||
|
let upload_date = Utc::now();
|
||||||
|
let is_pdf = mime_type == "application/pdf";
|
||||||
|
let is_image = mime_type.starts_with("image/");
|
||||||
|
let document_type = DocumentType::default();
|
||||||
|
let document_type_str = document_type.as_str().to_string();
|
||||||
|
let formatted_file_size = Self::format_size_bytes(file_size);
|
||||||
|
let formatted_upload_date = upload_date.format("%Y-%m-%d %H:%M").to_string();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: 0, // Will be assigned by database
|
||||||
|
name,
|
||||||
|
file_path,
|
||||||
|
file_size,
|
||||||
|
mime_type,
|
||||||
|
company_id,
|
||||||
|
document_type,
|
||||||
|
uploaded_by,
|
||||||
|
upload_date,
|
||||||
|
description: None,
|
||||||
|
is_public: false,
|
||||||
|
checksum: None,
|
||||||
|
is_pdf,
|
||||||
|
is_image,
|
||||||
|
document_type_str,
|
||||||
|
formatted_file_size,
|
||||||
|
formatted_upload_date,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder pattern methods
|
||||||
|
pub fn document_type(mut self, document_type: DocumentType) -> Self {
|
||||||
|
self.document_type_str = document_type.as_str().to_string();
|
||||||
|
self.document_type = document_type;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(mut self, description: String) -> Self {
|
||||||
|
self.description = Some(description);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_public(mut self, is_public: bool) -> Self {
|
||||||
|
self.is_public = is_public;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checksum(mut self, checksum: String) -> Self {
|
||||||
|
self.checksum = Some(checksum);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the file extension from the filename
|
||||||
|
pub fn file_extension(&self) -> Option<String> {
|
||||||
|
std::path::Path::new(&self.name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the document is an image
|
||||||
|
pub fn is_image(&self) -> bool {
|
||||||
|
self.mime_type.starts_with("image/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the document is a PDF
|
||||||
|
pub fn is_pdf(&self) -> bool {
|
||||||
|
self.mime_type == "application/pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a human-readable file size
|
||||||
|
pub fn formatted_file_size(&self) -> String {
|
||||||
|
let size = self.file_size as f64;
|
||||||
|
if size < 1024.0 {
|
||||||
|
format!("{} B", size)
|
||||||
|
} else if size < 1024.0 * 1024.0 {
|
||||||
|
format!("{:.1} KB", size / 1024.0)
|
||||||
|
} else if size < 1024.0 * 1024.0 * 1024.0 {
|
||||||
|
format!("{:.1} MB", size / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the upload date formatted for display
|
||||||
|
pub fn formatted_upload_date(&self) -> String {
|
||||||
|
self.upload_date.format("%Y-%m-%d %H:%M").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static method to format file size
|
||||||
|
fn format_size_bytes(bytes: u64) -> String {
|
||||||
|
let size = bytes as f64;
|
||||||
|
if size < 1024.0 {
|
||||||
|
format!("{} B", size)
|
||||||
|
} else if size < 1024.0 * 1024.0 {
|
||||||
|
format!("{:.1} KB", size / 1024.0)
|
||||||
|
} else if size < 1024.0 * 1024.0 * 1024.0 {
|
||||||
|
format!("{:.1} MB", size / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Document statistics for dashboard
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DocumentStatistics {
|
||||||
|
pub total_documents: usize,
|
||||||
|
pub total_size: u64,
|
||||||
|
pub formatted_total_size: String,
|
||||||
|
pub by_type: std::collections::HashMap<String, usize>,
|
||||||
|
pub recent_uploads: usize, // Last 30 days
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentStatistics {
|
||||||
|
pub fn new(documents: &[Document]) -> Self {
|
||||||
|
let mut by_type = std::collections::HashMap::new();
|
||||||
|
let mut total_size = 0;
|
||||||
|
let mut recent_uploads = 0;
|
||||||
|
|
||||||
|
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
|
||||||
|
|
||||||
|
for doc in documents {
|
||||||
|
total_size += doc.file_size;
|
||||||
|
|
||||||
|
let type_key = doc.document_type.as_str().to_string();
|
||||||
|
*by_type.entry(type_key).or_insert(0) += 1;
|
||||||
|
|
||||||
|
if doc.upload_date > thirty_days_ago {
|
||||||
|
recent_uploads += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatted_total_size = Self::format_size_bytes(total_size);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
total_documents: documents.len(),
|
||||||
|
total_size,
|
||||||
|
formatted_total_size,
|
||||||
|
by_type,
|
||||||
|
recent_uploads,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn formatted_total_size(&self) -> String {
|
||||||
|
Self::format_size_bytes(self.total_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_size_bytes(bytes: u64) -> String {
|
||||||
|
let size = bytes as f64;
|
||||||
|
if size < 1024.0 {
|
||||||
|
format!("{} B", size)
|
||||||
|
} else if size < 1024.0 * 1024.0 {
|
||||||
|
format!("{:.1} KB", size / 1024.0)
|
||||||
|
} else if size < 1024.0 * 1024.0 * 1024.0 {
|
||||||
|
format!("{:.1} MB", size / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
actix_mvc_app/src/models/flow.rs
Normal file
390
actix_mvc_app/src/models/flow.rs
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Status of a flow
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum FlowStatus {
|
||||||
|
/// Flow is in progress
|
||||||
|
InProgress,
|
||||||
|
/// Flow is completed
|
||||||
|
Completed,
|
||||||
|
/// Flow is stuck at a step
|
||||||
|
Stuck,
|
||||||
|
/// Flow is cancelled
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for FlowStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
FlowStatus::InProgress => write!(f, "In Progress"),
|
||||||
|
FlowStatus::Completed => write!(f, "Completed"),
|
||||||
|
FlowStatus::Stuck => write!(f, "Stuck"),
|
||||||
|
FlowStatus::Cancelled => write!(f, "Cancelled"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of flow
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum FlowType {
|
||||||
|
/// Company registration flow
|
||||||
|
CompanyRegistration,
|
||||||
|
/// User onboarding flow
|
||||||
|
UserOnboarding,
|
||||||
|
/// Service activation flow
|
||||||
|
ServiceActivation,
|
||||||
|
/// Payment processing flow
|
||||||
|
PaymentProcessing,
|
||||||
|
/// Asset tokenization flow
|
||||||
|
AssetTokenization,
|
||||||
|
/// Certification flow
|
||||||
|
Certification,
|
||||||
|
/// License application flow
|
||||||
|
LicenseApplication,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for FlowType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
FlowType::CompanyRegistration => write!(f, "Company Registration"),
|
||||||
|
FlowType::UserOnboarding => write!(f, "User Onboarding"),
|
||||||
|
FlowType::ServiceActivation => write!(f, "Service Activation"),
|
||||||
|
FlowType::PaymentProcessing => write!(f, "Payment Processing"),
|
||||||
|
FlowType::AssetTokenization => write!(f, "Asset Tokenization"),
|
||||||
|
FlowType::Certification => write!(f, "Certification"),
|
||||||
|
FlowType::LicenseApplication => write!(f, "License Application"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter for flows
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum FlowFilter {
|
||||||
|
/// All flows
|
||||||
|
All,
|
||||||
|
/// Only in progress flows
|
||||||
|
InProgress,
|
||||||
|
/// Only completed flows
|
||||||
|
Completed,
|
||||||
|
/// Only stuck flows
|
||||||
|
Stuck,
|
||||||
|
/// Only cancelled flows
|
||||||
|
Cancelled,
|
||||||
|
/// Flows of a specific type
|
||||||
|
ByType(FlowType),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for FlowFilter {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
FlowFilter::All => write!(f, "All"),
|
||||||
|
FlowFilter::InProgress => write!(f, "In Progress"),
|
||||||
|
FlowFilter::Completed => write!(f, "Completed"),
|
||||||
|
FlowFilter::Stuck => write!(f, "Stuck"),
|
||||||
|
FlowFilter::Cancelled => write!(f, "Cancelled"),
|
||||||
|
FlowFilter::ByType(flow_type) => write!(f, "Type: {}", flow_type),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A step in a flow
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FlowStep {
|
||||||
|
/// Step ID
|
||||||
|
pub id: String,
|
||||||
|
/// Step name
|
||||||
|
pub name: String,
|
||||||
|
/// Step description
|
||||||
|
pub description: String,
|
||||||
|
/// Step status
|
||||||
|
pub status: StepStatus,
|
||||||
|
/// Step order in the flow
|
||||||
|
pub order: u32,
|
||||||
|
/// Step started at
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
/// Step completed at
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
/// Step logs
|
||||||
|
pub logs: Vec<FlowLog>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl FlowStep {
|
||||||
|
/// Creates a new flow step
|
||||||
|
pub fn new(name: String, description: String, order: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
status: StepStatus::Pending,
|
||||||
|
order,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
logs: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the step
|
||||||
|
pub fn start(&mut self) {
|
||||||
|
self.status = StepStatus::InProgress;
|
||||||
|
self.started_at = Some(Utc::now());
|
||||||
|
self.add_log("Step started".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes the step
|
||||||
|
pub fn complete(&mut self) {
|
||||||
|
self.status = StepStatus::Completed;
|
||||||
|
self.completed_at = Some(Utc::now());
|
||||||
|
self.add_log("Step completed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the step as stuck
|
||||||
|
pub fn mark_stuck(&mut self, reason: String) {
|
||||||
|
self.status = StepStatus::Stuck;
|
||||||
|
self.add_log(format!("Step stuck: {}", reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a log entry to the step
|
||||||
|
pub fn add_log(&mut self, message: String) {
|
||||||
|
self.logs.push(FlowLog::new(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of a step in a flow
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum StepStatus {
|
||||||
|
/// Step is pending
|
||||||
|
Pending,
|
||||||
|
/// Step is in progress
|
||||||
|
InProgress,
|
||||||
|
/// Step is completed
|
||||||
|
Completed,
|
||||||
|
/// Step is stuck
|
||||||
|
Stuck,
|
||||||
|
/// Step is skipped
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for StepStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
StepStatus::Pending => write!(f, "Pending"),
|
||||||
|
StepStatus::InProgress => write!(f, "In Progress"),
|
||||||
|
StepStatus::Completed => write!(f, "Completed"),
|
||||||
|
StepStatus::Stuck => write!(f, "Stuck"),
|
||||||
|
StepStatus::Skipped => write!(f, "Skipped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A log entry in a flow step
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FlowLog {
|
||||||
|
/// Log ID
|
||||||
|
pub id: String,
|
||||||
|
/// Log message
|
||||||
|
pub message: String,
|
||||||
|
/// Log timestamp
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl FlowLog {
|
||||||
|
/// Creates a new flow log
|
||||||
|
pub fn new(message: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
message,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flow with multiple steps
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Flow {
|
||||||
|
/// Flow ID
|
||||||
|
pub id: String,
|
||||||
|
/// Flow name
|
||||||
|
pub name: String,
|
||||||
|
/// Flow description
|
||||||
|
pub description: String,
|
||||||
|
/// Flow type
|
||||||
|
pub flow_type: FlowType,
|
||||||
|
/// Flow status
|
||||||
|
pub status: FlowStatus,
|
||||||
|
/// Flow owner ID
|
||||||
|
pub owner_id: String,
|
||||||
|
/// Flow owner name
|
||||||
|
pub owner_name: String,
|
||||||
|
/// Flow created at
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// Flow updated at
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
/// Flow completed at
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
/// Flow steps
|
||||||
|
pub steps: Vec<FlowStep>,
|
||||||
|
/// Progress percentage
|
||||||
|
pub progress_percentage: u8,
|
||||||
|
/// Current step
|
||||||
|
pub current_step: Option<FlowStep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl Flow {
|
||||||
|
/// Creates a new flow
|
||||||
|
pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now();
|
||||||
|
let steps = vec![
|
||||||
|
FlowStep::new("Initialization".to_string(), "Setting up the flow".to_string(), 1),
|
||||||
|
FlowStep::new("Processing".to_string(), "Processing the flow data".to_string(), 2),
|
||||||
|
FlowStep::new("Finalization".to_string(), "Completing the flow".to_string(), 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Set the first step as in progress
|
||||||
|
let mut flow = Self {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
description: description.to_string(),
|
||||||
|
flow_type,
|
||||||
|
status: FlowStatus::InProgress,
|
||||||
|
owner_id: owner_id.to_string(),
|
||||||
|
owner_name: owner_name.to_string(),
|
||||||
|
steps,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
completed_at: None,
|
||||||
|
progress_percentage: 0,
|
||||||
|
current_step: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate progress and set current step
|
||||||
|
flow.update_progress();
|
||||||
|
|
||||||
|
flow
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_progress(&mut self) {
|
||||||
|
// Calculate progress percentage
|
||||||
|
let total_steps = self.steps.len();
|
||||||
|
if total_steps == 0 {
|
||||||
|
self.progress_percentage = 100;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed_steps = self.steps.iter().filter(|s| s.status == StepStatus::Completed).count();
|
||||||
|
self.progress_percentage = ((completed_steps as f32 / total_steps as f32) * 100.0) as u8;
|
||||||
|
|
||||||
|
// Find current step
|
||||||
|
self.current_step = self.steps.iter()
|
||||||
|
.find(|s| s.status == StepStatus::InProgress)
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
// Update flow status based on steps
|
||||||
|
if self.progress_percentage == 100 {
|
||||||
|
self.status = FlowStatus::Completed;
|
||||||
|
} else if self.steps.iter().any(|s| s.status == StepStatus::Stuck) {
|
||||||
|
self.status = FlowStatus::Stuck;
|
||||||
|
} else {
|
||||||
|
self.status = FlowStatus::InProgress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance_step(&mut self) -> Result<(), String> {
|
||||||
|
let current_index = self.steps.iter().position(|s| s.status == StepStatus::InProgress);
|
||||||
|
|
||||||
|
if let Some(index) = current_index {
|
||||||
|
// Mark current step as completed
|
||||||
|
self.steps[index].status = StepStatus::Completed;
|
||||||
|
self.steps[index].completed_at = Some(Utc::now());
|
||||||
|
|
||||||
|
// If there's a next step, mark it as in progress
|
||||||
|
if index + 1 < self.steps.len() {
|
||||||
|
self.steps[index + 1].status = StepStatus::InProgress;
|
||||||
|
self.steps[index + 1].started_at = Some(Utc::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
self.update_progress();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("No step in progress to advance".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_step_stuck(&mut self, reason: &str) -> Result<(), String> {
|
||||||
|
let current_index = self.steps.iter().position(|s| s.status == StepStatus::InProgress);
|
||||||
|
|
||||||
|
if let Some(index) = current_index {
|
||||||
|
// Mark current step as stuck
|
||||||
|
self.steps[index].status = StepStatus::Stuck;
|
||||||
|
|
||||||
|
// Add a log entry for the stuck reason
|
||||||
|
self.steps[index].add_log(reason.to_string());
|
||||||
|
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
self.update_progress();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("No step in progress to mark as stuck".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_log_to_step(&mut self, step_id: &str, message: &str) -> Result<(), String> {
|
||||||
|
if let Some(step) = self.steps.iter_mut().find(|s| s.id == step_id) {
|
||||||
|
step.add_log(message.to_string());
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Step with ID {} not found", step_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flow statistics
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FlowStatistics {
|
||||||
|
/// Total number of flows
|
||||||
|
pub total_flows: usize,
|
||||||
|
/// Number of in progress flows
|
||||||
|
pub in_progress_flows: usize,
|
||||||
|
/// Number of completed flows
|
||||||
|
pub completed_flows: usize,
|
||||||
|
/// Number of stuck flows
|
||||||
|
pub stuck_flows: usize,
|
||||||
|
/// Number of cancelled flows
|
||||||
|
pub cancelled_flows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlowStatistics {
|
||||||
|
/// Creates new flow statistics
|
||||||
|
pub fn new(flows: &[Flow]) -> Self {
|
||||||
|
let total_flows = flows.len();
|
||||||
|
let in_progress_flows = flows.iter()
|
||||||
|
.filter(|flow| flow.status == FlowStatus::InProgress)
|
||||||
|
.count();
|
||||||
|
let completed_flows = flows.iter()
|
||||||
|
.filter(|flow| flow.status == FlowStatus::Completed)
|
||||||
|
.count();
|
||||||
|
let stuck_flows = flows.iter()
|
||||||
|
.filter(|flow| flow.status == FlowStatus::Stuck)
|
||||||
|
.count();
|
||||||
|
let cancelled_flows = flows.iter()
|
||||||
|
.filter(|flow| flow.status == FlowStatus::Cancelled)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
total_flows,
|
||||||
|
in_progress_flows,
|
||||||
|
completed_flows,
|
||||||
|
stuck_flows,
|
||||||
|
cancelled_flows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
actix_mvc_app/src/models/marketplace.rs
Normal file
315
actix_mvc_app/src/models/marketplace.rs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
use crate::models::asset::AssetType;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Status of a marketplace listing
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ListingStatus {
|
||||||
|
Active,
|
||||||
|
Sold,
|
||||||
|
Cancelled,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl ListingStatus {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ListingStatus::Active => "Active",
|
||||||
|
ListingStatus::Sold => "Sold",
|
||||||
|
ListingStatus::Cancelled => "Cancelled",
|
||||||
|
ListingStatus::Expired => "Expired",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of marketplace listing
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ListingType {
|
||||||
|
FixedPrice,
|
||||||
|
Auction,
|
||||||
|
Exchange,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListingType {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ListingType::FixedPrice => "Fixed Price",
|
||||||
|
ListingType::Auction => "Auction",
|
||||||
|
ListingType::Exchange => "Exchange",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a bid on an auction listing
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Bid {
|
||||||
|
pub id: String,
|
||||||
|
pub listing_id: String,
|
||||||
|
pub bidder_id: String,
|
||||||
|
pub bidder_name: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub currency: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub status: BidStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of a bid
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum BidStatus {
|
||||||
|
Active,
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl BidStatus {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
BidStatus::Active => "Active",
|
||||||
|
BidStatus::Accepted => "Accepted",
|
||||||
|
BidStatus::Rejected => "Rejected",
|
||||||
|
BidStatus::Cancelled => "Cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a marketplace listing
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Listing {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub asset_id: String,
|
||||||
|
pub asset_name: String,
|
||||||
|
pub asset_type: AssetType,
|
||||||
|
pub seller_id: String,
|
||||||
|
pub seller_name: String,
|
||||||
|
pub price: f64,
|
||||||
|
pub currency: String,
|
||||||
|
pub listing_type: ListingType,
|
||||||
|
pub status: ListingStatus,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub sold_at: Option<DateTime<Utc>>,
|
||||||
|
pub buyer_id: Option<String>,
|
||||||
|
pub buyer_name: Option<String>,
|
||||||
|
pub sale_price: Option<f64>,
|
||||||
|
pub bids: Vec<Bid>,
|
||||||
|
pub views: u32,
|
||||||
|
pub featured: bool,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl Listing {
|
||||||
|
/// Creates a new listing
|
||||||
|
pub fn new(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
asset_id: String,
|
||||||
|
asset_name: String,
|
||||||
|
asset_type: AssetType,
|
||||||
|
seller_id: String,
|
||||||
|
seller_name: String,
|
||||||
|
price: f64,
|
||||||
|
currency: String,
|
||||||
|
listing_type: ListingType,
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
image_url: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: format!("listing-{}", Uuid::new_v4().to_string()),
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
asset_id,
|
||||||
|
asset_name,
|
||||||
|
asset_type,
|
||||||
|
seller_id,
|
||||||
|
seller_name,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
listing_type,
|
||||||
|
status: ListingStatus::Active,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
expires_at,
|
||||||
|
sold_at: None,
|
||||||
|
buyer_id: None,
|
||||||
|
buyer_name: None,
|
||||||
|
sale_price: None,
|
||||||
|
bids: Vec::new(),
|
||||||
|
views: 0,
|
||||||
|
featured: false,
|
||||||
|
tags,
|
||||||
|
image_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a bid to the listing
|
||||||
|
pub fn add_bid(
|
||||||
|
&mut self,
|
||||||
|
bidder_id: String,
|
||||||
|
bidder_name: String,
|
||||||
|
amount: f64,
|
||||||
|
currency: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.status != ListingStatus::Active {
|
||||||
|
return Err("Listing is not active".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.listing_type != ListingType::Auction {
|
||||||
|
return Err("Listing is not an auction".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if currency != self.currency {
|
||||||
|
return Err(format!(
|
||||||
|
"Currency mismatch: expected {}, got {}",
|
||||||
|
self.currency, currency
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bid amount is higher than current highest bid or starting price
|
||||||
|
let highest_bid = self.highest_bid();
|
||||||
|
let min_bid = match highest_bid {
|
||||||
|
Some(bid) => bid.amount,
|
||||||
|
None => self.price,
|
||||||
|
};
|
||||||
|
|
||||||
|
if amount <= min_bid {
|
||||||
|
return Err(format!("Bid amount must be higher than {}", min_bid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bid = Bid {
|
||||||
|
id: format!("bid-{}", Uuid::new_v4().to_string()),
|
||||||
|
listing_id: self.id.clone(),
|
||||||
|
bidder_id,
|
||||||
|
bidder_name,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
status: BidStatus::Active,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.bids.push(bid);
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the highest bid on the listing
|
||||||
|
pub fn highest_bid(&self) -> Option<&Bid> {
|
||||||
|
self.bids
|
||||||
|
.iter()
|
||||||
|
.filter(|b| b.status == BidStatus::Active)
|
||||||
|
.max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the listing as sold
|
||||||
|
pub fn mark_as_sold(
|
||||||
|
&mut self,
|
||||||
|
buyer_id: String,
|
||||||
|
buyer_name: String,
|
||||||
|
sale_price: f64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.status != ListingStatus::Active {
|
||||||
|
return Err("Listing is not active".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status = ListingStatus::Sold;
|
||||||
|
self.sold_at = Some(Utc::now());
|
||||||
|
self.buyer_id = Some(buyer_id);
|
||||||
|
self.buyer_name = Some(buyer_name);
|
||||||
|
self.sale_price = Some(sale_price);
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the listing
|
||||||
|
pub fn cancel(&mut self) -> Result<(), String> {
|
||||||
|
if self.status != ListingStatus::Active {
|
||||||
|
return Err("Listing is not active".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status = ListingStatus::Cancelled;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increments the view count
|
||||||
|
pub fn increment_views(&mut self) {
|
||||||
|
self.views += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the listing as featured
|
||||||
|
pub fn set_featured(&mut self, featured: bool) {
|
||||||
|
self.featured = featured;
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistics for marketplace
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MarketplaceStatistics {
|
||||||
|
pub total_listings: usize,
|
||||||
|
pub active_listings: usize,
|
||||||
|
pub sold_listings: usize,
|
||||||
|
pub total_value: f64,
|
||||||
|
pub total_sales: f64,
|
||||||
|
pub listings_by_type: std::collections::HashMap<String, usize>,
|
||||||
|
pub sales_by_asset_type: std::collections::HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketplaceStatistics {
|
||||||
|
pub fn new(listings: &[Listing]) -> Self {
|
||||||
|
let mut total_value = 0.0;
|
||||||
|
let mut total_sales = 0.0;
|
||||||
|
let mut listings_by_type = std::collections::HashMap::new();
|
||||||
|
let mut sales_by_asset_type = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
let active_listings = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.status == ListingStatus::Active)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let sold_listings = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.status == ListingStatus::Sold)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
for listing in listings {
|
||||||
|
if listing.status == ListingStatus::Active {
|
||||||
|
total_value += listing.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
if listing.status == ListingStatus::Sold {
|
||||||
|
if let Some(sale_price) = listing.sale_price {
|
||||||
|
total_sales += sale_price;
|
||||||
|
let asset_type = listing.asset_type.as_str().to_string();
|
||||||
|
*sales_by_asset_type.entry(asset_type).or_insert(0.0) += sale_price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let listing_type = listing.listing_type.as_str().to_string();
|
||||||
|
*listings_by_type.entry(listing_type).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
total_listings: listings.len(),
|
||||||
|
active_listings,
|
||||||
|
sold_listings,
|
||||||
|
total_value,
|
||||||
|
total_sales,
|
||||||
|
listings_by_type,
|
||||||
|
sales_by_asset_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
actix_mvc_app/src/models/mock_user.rs
Normal file
81
actix_mvc_app/src/models/mock_user.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#![allow(dead_code)] // Mock user utility functions may not all be used yet
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Mock user object for development and testing
|
||||||
|
/// This will be replaced with real user authentication later
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MockUser {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub role: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockUser {
|
||||||
|
/// Create a new mock user
|
||||||
|
pub fn new(id: u32, name: String, email: String, role: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
created_at: chrono::Utc::now().timestamp(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System-wide mock user constant
|
||||||
|
/// Use this throughout the application until real authentication is implemented
|
||||||
|
pub const MOCK_USER_ID: u32 = 1;
|
||||||
|
|
||||||
|
/// Get the default mock user object
|
||||||
|
/// This provides a consistent mock user across the entire system
|
||||||
|
pub fn get_mock_user() -> MockUser {
|
||||||
|
MockUser::new(
|
||||||
|
MOCK_USER_ID,
|
||||||
|
"Mock User".to_string(),
|
||||||
|
"mock@example.com".to_string(),
|
||||||
|
"admin".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mock user ID for database operations
|
||||||
|
/// Use this function instead of hardcoding user IDs
|
||||||
|
pub fn get_mock_user_id() -> u32 {
|
||||||
|
MOCK_USER_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_user_creation() {
|
||||||
|
let user = get_mock_user();
|
||||||
|
assert_eq!(user.id, MOCK_USER_ID);
|
||||||
|
assert_eq!(user.name, "Mock User");
|
||||||
|
assert_eq!(user.email, "mock@example.com");
|
||||||
|
assert_eq!(user.role, "admin");
|
||||||
|
assert!(user.created_at > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_user_id_consistency() {
|
||||||
|
assert_eq!(get_mock_user_id(), MOCK_USER_ID);
|
||||||
|
assert_eq!(get_mock_user().id, MOCK_USER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_user_immutability() {
|
||||||
|
let user1 = get_mock_user();
|
||||||
|
let user2 = get_mock_user();
|
||||||
|
|
||||||
|
// Should have same ID and basic info
|
||||||
|
assert_eq!(user1.id, user2.id);
|
||||||
|
assert_eq!(user1.name, user2.name);
|
||||||
|
assert_eq!(user1.email, user2.email);
|
||||||
|
assert_eq!(user1.role, user2.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
// Export models
|
// Export models
|
||||||
pub mod user;
|
pub mod asset;
|
||||||
pub mod ticket;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
|
pub mod contract;
|
||||||
|
pub mod defi;
|
||||||
|
pub mod document;
|
||||||
|
pub mod flow;
|
||||||
|
pub mod marketplace;
|
||||||
|
pub mod mock_user;
|
||||||
|
pub mod ticket;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
// Re-export models for easier imports
|
// Re-export models for easier imports
|
||||||
|
pub use calendar::CalendarViewMode;
|
||||||
|
pub use defi::initialize_mock_data;
|
||||||
|
// Mock user exports removed - import directly from mock_user module when needed
|
||||||
|
pub use ticket::{Ticket, TicketComment, TicketPriority, TicketStatus};
|
||||||
pub use user::User;
|
pub use user::User;
|
||||||
pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority, TicketFilter};
|
|
||||||
pub use calendar::{CalendarEvent, CalendarViewMode};
|
|
||||||
@@ -76,6 +76,7 @@ pub struct Ticket {
|
|||||||
pub assigned_to: Option<i32>,
|
pub assigned_to: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl Ticket {
|
impl Ticket {
|
||||||
/// Creates a new ticket
|
/// Creates a new ticket
|
||||||
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {
|
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use bcrypt::{hash, verify, DEFAULT_COST};
|
|||||||
|
|
||||||
/// Represents a user in the system
|
/// Represents a user in the system
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
/// Unique identifier for the user
|
/// Unique identifier for the user
|
||||||
pub id: Option<i32>,
|
pub id: Option<i32>,
|
||||||
@@ -31,6 +32,7 @@ pub enum UserRole {
|
|||||||
Admin,
|
Admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl User {
|
impl User {
|
||||||
/// Creates a new user with default values
|
/// Creates a new user with default values
|
||||||
pub fn new(name: String, email: String) -> Self {
|
pub fn new(name: String, email: String) -> Self {
|
||||||
@@ -125,6 +127,7 @@ impl User {
|
|||||||
|
|
||||||
/// Represents user login credentials
|
/// Represents user login credentials
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct LoginCredentials {
|
pub struct LoginCredentials {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
@@ -132,6 +135,7 @@ pub struct LoginCredentials {
|
|||||||
|
|
||||||
/// Represents user registration data
|
/// Represents user registration data
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct RegistrationData {
|
pub struct RegistrationData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -145,8 +149,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_user() {
|
fn test_new_user() {
|
||||||
let user = User::new("John Doe".to_string(), "john@example.com".to_string());
|
let user = User::new("Robert Callingham".to_string(), "john@example.com".to_string());
|
||||||
assert_eq!(user.name, "John Doe");
|
assert_eq!(user.name, "Robert Callingham");
|
||||||
assert_eq!(user.email, "john@example.com");
|
assert_eq!(user.email, "john@example.com");
|
||||||
assert!(!user.is_admin());
|
assert!(!user.is_admin());
|
||||||
}
|
}
|
||||||
@@ -161,13 +165,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_update_user() {
|
fn test_update_user() {
|
||||||
let mut user = User::new("John Doe".to_string(), "john@example.com".to_string());
|
let mut user = User::new("Robert Callingham".to_string(), "john@example.com".to_string());
|
||||||
user.update(Some("Jane Doe".to_string()), None);
|
user.update(Some("Mary Hewell".to_string()), None);
|
||||||
assert_eq!(user.name, "Jane Doe");
|
assert_eq!(user.name, "Mary Hewell");
|
||||||
assert_eq!(user.email, "john@example.com");
|
assert_eq!(user.email, "john@example.com");
|
||||||
|
|
||||||
user.update(None, Some("jane@example.com".to_string()));
|
user.update(None, Some("jane@example.com".to_string()));
|
||||||
assert_eq!(user.name, "Jane Doe");
|
assert_eq!(user.name, "Mary Hewell");
|
||||||
assert_eq!(user.email, "jane@example.com");
|
assert_eq!(user.email, "jane@example.com");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
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::middleware::JwtAuth;
|
|
||||||
use crate::SESSION_KEY;
|
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::document::DocumentController;
|
||||||
|
use crate::controllers::flow::FlowController;
|
||||||
|
use crate::controllers::governance::GovernanceController;
|
||||||
|
use crate::controllers::home::HomeController;
|
||||||
|
use crate::controllers::marketplace::MarketplaceController;
|
||||||
|
use crate::controllers::payment::PaymentController;
|
||||||
|
use crate::controllers::ticket::TicketController;
|
||||||
|
use crate::middleware::JwtAuth;
|
||||||
|
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
|
||||||
|
use actix_web::web;
|
||||||
|
|
||||||
/// Configures all application routes
|
/// Configures all application routes
|
||||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
// Configure health check routes (no authentication required)
|
||||||
|
crate::controllers::health::configure_health_routes(cfg);
|
||||||
|
|
||||||
// Configure session middleware with the consistent key
|
// Configure session middleware with the consistent key
|
||||||
let session_middleware = SessionMiddleware::builder(
|
let session_middleware =
|
||||||
CookieSessionStore::default(),
|
SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone())
|
||||||
SESSION_KEY.clone()
|
.cookie_secure(false) // Set to true in production with HTTPS
|
||||||
)
|
.build();
|
||||||
.cookie_secure(false) // Set to true in production with HTTPS
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Public routes that don't require authentication
|
// Public routes that don't require authentication
|
||||||
cfg.service(
|
cfg.service(
|
||||||
@@ -26,37 +36,304 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
|||||||
.route("/about", web::get().to(HomeController::about))
|
.route("/about", web::get().to(HomeController::about))
|
||||||
.route("/contact", web::get().to(HomeController::contact))
|
.route("/contact", web::get().to(HomeController::contact))
|
||||||
.route("/contact", web::post().to(HomeController::submit_contact))
|
.route("/contact", web::post().to(HomeController::submit_contact))
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
.route("/login", web::get().to(AuthController::login_page))
|
.route("/login", web::get().to(AuthController::login_page))
|
||||||
.route("/login", web::post().to(AuthController::login))
|
.route("/login", web::post().to(AuthController::login))
|
||||||
.route("/register", web::get().to(AuthController::register_page))
|
.route("/register", web::get().to(AuthController::register_page))
|
||||||
.route("/register", web::post().to(AuthController::register))
|
.route("/register", web::post().to(AuthController::register))
|
||||||
.route("/logout", web::get().to(AuthController::logout))
|
.route("/logout", web::get().to(AuthController::logout))
|
||||||
|
|
||||||
// Protected routes that require authentication
|
// Protected routes that require authentication
|
||||||
// These routes will be protected by the JwtAuth middleware in the main.rs file
|
// These routes will be protected by the JwtAuth middleware in the main.rs file
|
||||||
.route("/editor", web::get().to(HomeController::editor))
|
.route("/editor", web::get().to(HomeController::editor))
|
||||||
|
|
||||||
// Ticket routes
|
// Ticket routes
|
||||||
.route("/tickets", web::get().to(TicketController::list_tickets))
|
.route("/tickets", web::get().to(TicketController::list_tickets))
|
||||||
.route("/tickets/new", web::get().to(TicketController::new_ticket))
|
.route("/tickets/new", web::get().to(TicketController::new_ticket))
|
||||||
.route("/tickets", web::post().to(TicketController::create_ticket))
|
.route("/tickets", web::post().to(TicketController::create_ticket))
|
||||||
.route("/tickets/{id}", web::get().to(TicketController::show_ticket))
|
.route(
|
||||||
.route("/tickets/{id}/comment", web::post().to(TicketController::add_comment))
|
"/tickets/{id}",
|
||||||
.route("/tickets/{id}/status/{status}", web::post().to(TicketController::update_status))
|
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))
|
.route("/my-tickets", web::get().to(TicketController::my_tickets))
|
||||||
|
|
||||||
// Calendar routes
|
// Calendar routes
|
||||||
.route("/calendar", web::get().to(CalendarController::calendar))
|
.route("/calendar", web::get().to(CalendarController::calendar))
|
||||||
.route("/calendar/events/new", web::get().to(CalendarController::new_event))
|
.route(
|
||||||
.route("/calendar/events", web::post().to(CalendarController::create_event))
|
"/calendar/events/new",
|
||||||
.route("/calendar/events/{id}/delete", web::post().to(CalendarController::delete_event))
|
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/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("/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)),
|
||||||
|
)
|
||||||
|
// Contract routes
|
||||||
|
.service(
|
||||||
|
web::scope("/contracts")
|
||||||
|
.route("", web::get().to(ContractController::index))
|
||||||
|
.route("/", web::get().to(ContractController::index)) // Handle trailing slash
|
||||||
|
.route("/list", web::get().to(ContractController::list))
|
||||||
|
.route("/list/", web::get().to(ContractController::list)) // Handle trailing slash
|
||||||
|
.route(
|
||||||
|
"/my-contracts",
|
||||||
|
web::get().to(ContractController::my_contracts),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/my-contracts/",
|
||||||
|
web::get().to(ContractController::my_contracts),
|
||||||
|
) // Handle trailing slash
|
||||||
|
.route("/create", web::get().to(ContractController::create_form))
|
||||||
|
.route("/create/", web::get().to(ContractController::create_form)) // Handle trailing slash
|
||||||
|
.route("/create", web::post().to(ContractController::create))
|
||||||
|
.route("/create/", web::post().to(ContractController::create)) // Handle trailing slash
|
||||||
|
.route("/statistics", web::get().to(ContractController::statistics))
|
||||||
|
.route(
|
||||||
|
"/activities",
|
||||||
|
web::get().to(ContractController::all_activities),
|
||||||
|
)
|
||||||
|
.route("/{id}/edit", web::get().to(ContractController::edit_form))
|
||||||
|
.route("/{id}/edit", web::post().to(ContractController::update))
|
||||||
|
.route(
|
||||||
|
"/filter/{status}",
|
||||||
|
web::get().to(ContractController::filter_by_status),
|
||||||
|
)
|
||||||
|
.route("/{id}", web::get().to(ContractController::detail))
|
||||||
|
.route(
|
||||||
|
"/{id}/status/{status}",
|
||||||
|
web::post().to(ContractController::update_status),
|
||||||
|
)
|
||||||
|
.route("/{id}/delete", web::post().to(ContractController::delete))
|
||||||
|
.route(
|
||||||
|
"/{id}/add-signer",
|
||||||
|
web::get().to(ContractController::add_signer_form),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/add-signer",
|
||||||
|
web::post().to(ContractController::add_signer),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/remind",
|
||||||
|
web::post().to(ContractController::remind_to_sign),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/send",
|
||||||
|
web::post().to(ContractController::send_for_signatures),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/reminder-status",
|
||||||
|
web::get().to(ContractController::get_reminder_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/add-revision",
|
||||||
|
web::post().to(ContractController::add_revision),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/signer/{signer_id}/status/{status}",
|
||||||
|
web::post().to(ContractController::update_signer_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/sign/{signer_id}",
|
||||||
|
web::post().to(ContractController::sign_contract),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/reject/{signer_id}",
|
||||||
|
web::post().to(ContractController::reject_contract),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/cancel",
|
||||||
|
web::post().to(ContractController::cancel_contract),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/clone",
|
||||||
|
web::post().to(ContractController::clone_contract),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/signed/{signer_id}",
|
||||||
|
web::get().to(ContractController::view_signed_document),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/share",
|
||||||
|
web::post().to(ContractController::share_contract),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Asset routes
|
||||||
|
.service(
|
||||||
|
web::scope("/assets")
|
||||||
|
.route("", web::get().to(AssetController::index))
|
||||||
|
.route("/list", web::get().to(AssetController::list))
|
||||||
|
.route("/my", web::get().to(AssetController::my_assets))
|
||||||
|
.route("/create", web::get().to(AssetController::create_form))
|
||||||
|
.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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Marketplace routes
|
||||||
|
.service(
|
||||||
|
web::scope("/marketplace")
|
||||||
|
.route("", web::get().to(MarketplaceController::index))
|
||||||
|
.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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// 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("/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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Company routes
|
||||||
|
.service(
|
||||||
|
web::scope("/company")
|
||||||
|
.route("", web::get().to(CompanyController::index))
|
||||||
|
// OLD REGISTRATION ROUTE REMOVED - Now only payment flow creates companies
|
||||||
|
.route("/view/{id}", web::get().to(CompanyController::view_company))
|
||||||
|
.route("/edit/{id}", web::get().to(CompanyController::edit_form))
|
||||||
|
.route("/edit/{id}", web::post().to(CompanyController::edit))
|
||||||
|
.route(
|
||||||
|
"/switch/{id}",
|
||||||
|
web::get().to(CompanyController::switch_entity),
|
||||||
|
)
|
||||||
|
// Payment routes - ONLY way to create companies now
|
||||||
|
.route(
|
||||||
|
"/create-payment-intent",
|
||||||
|
web::post().to(PaymentController::create_payment_intent),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/payment-success",
|
||||||
|
web::get().to(PaymentController::payment_success),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/payment-webhook",
|
||||||
|
web::post().to(PaymentController::webhook),
|
||||||
|
)
|
||||||
|
// Document management routes
|
||||||
|
.route("/documents/{id}", web::get().to(DocumentController::index))
|
||||||
|
.route(
|
||||||
|
"/documents/{id}/upload",
|
||||||
|
web::post().to(DocumentController::upload),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/documents/{company_id}/delete/{document_id}",
|
||||||
|
web::get().to(DocumentController::delete),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the /protected scope for any future routes that should be under that path
|
// Keep the /protected scope for any future routes that should be under that path
|
||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("/protected")
|
web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
|
||||||
.wrap(JwtAuth) // Apply JWT authentication middleware
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
173
actix_mvc_app/src/static/js/company.js
Normal file
173
actix_mvc_app/src/static/js/company.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// Company data (would be loaded from backend in production)
|
||||||
|
var companyData = {
|
||||||
|
'company1': {
|
||||||
|
name: 'Zanzibar Digital Solutions',
|
||||||
|
type: 'Startup FZC',
|
||||||
|
status: 'Active',
|
||||||
|
registrationDate: '2025-04-01',
|
||||||
|
purpose: 'Digital solutions and blockchain development',
|
||||||
|
plan: 'Startup FZC - $50/month',
|
||||||
|
nextBilling: '2025-06-01',
|
||||||
|
paymentMethod: 'Credit Card (****4582)',
|
||||||
|
shareholders: [
|
||||||
|
{ name: 'John Smith', percentage: '60%' },
|
||||||
|
{ name: 'Sarah Johnson', percentage: '40%' }
|
||||||
|
],
|
||||||
|
contracts: [
|
||||||
|
{ name: 'Articles of Incorporation', status: 'Signed' },
|
||||||
|
{ name: 'Terms & Conditions', status: 'Signed' },
|
||||||
|
{ name: 'Digital Asset Issuance', status: 'Signed' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'company2': {
|
||||||
|
name: 'Blockchain Innovations Ltd',
|
||||||
|
type: 'Growth FZC',
|
||||||
|
status: 'Active',
|
||||||
|
registrationDate: '2025-03-15',
|
||||||
|
purpose: 'Blockchain technology research and development',
|
||||||
|
plan: 'Growth FZC - $100/month',
|
||||||
|
nextBilling: '2025-06-15',
|
||||||
|
paymentMethod: 'Bank Transfer',
|
||||||
|
shareholders: [
|
||||||
|
{ name: 'Michael Chen', percentage: '35%' },
|
||||||
|
{ name: 'Aisha Patel', percentage: '35%' },
|
||||||
|
{ name: 'David Okonkwo', percentage: '30%' }
|
||||||
|
],
|
||||||
|
contracts: [
|
||||||
|
{ name: 'Articles of Incorporation', status: 'Signed' },
|
||||||
|
{ name: 'Terms & Conditions', status: 'Signed' },
|
||||||
|
{ name: 'Digital Asset Issuance', status: 'Signed' },
|
||||||
|
{ name: 'Physical Asset Holding', status: 'Signed' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'company3': {
|
||||||
|
name: 'Sustainable Energy Cooperative',
|
||||||
|
type: 'Cooperative FZC',
|
||||||
|
status: 'Pending',
|
||||||
|
registrationDate: '2025-05-01',
|
||||||
|
purpose: 'Renewable energy production and distribution',
|
||||||
|
plan: 'Cooperative FZC - $200/month',
|
||||||
|
nextBilling: 'Pending Activation',
|
||||||
|
paymentMethod: 'Pending',
|
||||||
|
shareholders: [
|
||||||
|
{ name: 'Community Energy Group', percentage: '40%' },
|
||||||
|
{ name: 'Green Future Initiative', percentage: '30%' },
|
||||||
|
{ name: 'Sustainable Living Collective', percentage: '30%' }
|
||||||
|
],
|
||||||
|
contracts: [
|
||||||
|
{ name: 'Articles of Incorporation', status: 'Signed' },
|
||||||
|
{ name: 'Terms & Conditions', status: 'Signed' },
|
||||||
|
{ name: 'Cooperative Governance', status: 'Pending' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current company ID for modal
|
||||||
|
var currentCompanyId = null;
|
||||||
|
|
||||||
|
// View company details function
|
||||||
|
function viewCompanyDetails(companyId) {
|
||||||
|
// Store current company ID
|
||||||
|
currentCompanyId = companyId;
|
||||||
|
|
||||||
|
// Get company data
|
||||||
|
const company = companyData[companyId];
|
||||||
|
if (!company) return;
|
||||||
|
|
||||||
|
// Update modal title
|
||||||
|
document.getElementById('companyDetailsModalLabel').innerHTML =
|
||||||
|
`<i class="bi bi-building me-2"></i>${company.name} Details`;
|
||||||
|
|
||||||
|
// Update general information
|
||||||
|
document.getElementById('modal-company-name').textContent = company.name;
|
||||||
|
document.getElementById('modal-company-type').textContent = company.type;
|
||||||
|
document.getElementById('modal-registration-date').textContent = company.registrationDate;
|
||||||
|
|
||||||
|
// Update status with appropriate badge
|
||||||
|
const statusBadge = company.status === 'Active' ?
|
||||||
|
`<span class="badge bg-success">${company.status}</span>` :
|
||||||
|
`<span class="badge bg-warning text-dark">${company.status}</span>`;
|
||||||
|
document.getElementById('modal-status').innerHTML = statusBadge;
|
||||||
|
|
||||||
|
document.getElementById('modal-purpose').textContent = company.purpose;
|
||||||
|
|
||||||
|
// Update billing information
|
||||||
|
document.getElementById('modal-plan').textContent = company.plan;
|
||||||
|
document.getElementById('modal-next-billing').textContent = company.nextBilling;
|
||||||
|
document.getElementById('modal-payment-method').textContent = company.paymentMethod;
|
||||||
|
|
||||||
|
// Update shareholders table
|
||||||
|
const shareholdersTable = document.getElementById('modal-shareholders');
|
||||||
|
shareholdersTable.innerHTML = '';
|
||||||
|
company.shareholders.forEach(shareholder => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${shareholder.name}</td>
|
||||||
|
<td>${shareholder.percentage}</td>
|
||||||
|
`;
|
||||||
|
shareholdersTable.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update contracts table
|
||||||
|
const contractsTable = document.getElementById('modal-contracts');
|
||||||
|
contractsTable.innerHTML = '';
|
||||||
|
company.contracts.forEach(contract => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const statusBadge = contract.status === 'Signed' ?
|
||||||
|
`<span class="badge bg-success">${contract.status}</span>` :
|
||||||
|
`<span class="badge bg-warning text-dark">${contract.status}</span>`;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${contract.name}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-primary" onclick="viewContract('${contract.name.toLowerCase().replace(/\s+/g, '-')}')">View</button></td>
|
||||||
|
`;
|
||||||
|
contractsTable.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('companyDetailsModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to entity function
|
||||||
|
function switchToEntity(companyId) {
|
||||||
|
const company = companyData[companyId];
|
||||||
|
if (!company) return;
|
||||||
|
|
||||||
|
// In a real application, this would redirect to the entity context
|
||||||
|
// For now, we'll just show an alert
|
||||||
|
alert(`Switching to ${company.name} entity context. All UI will now reflect this entity's governance, billing, and other features.`);
|
||||||
|
|
||||||
|
// This would typically involve:
|
||||||
|
// 1. Setting a session/cookie for the current entity
|
||||||
|
// 2. Redirecting to the dashboard with that entity context
|
||||||
|
// window.location.href = `/dashboard?entity=${companyId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to entity from modal
|
||||||
|
function switchToEntityFromModal() {
|
||||||
|
if (currentCompanyId) {
|
||||||
|
switchToEntity(currentCompanyId);
|
||||||
|
// Close the modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('companyDetailsModal'));
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View contract function
|
||||||
|
function viewContract(contractId) {
|
||||||
|
// In a real application, this would open the contract document
|
||||||
|
// For now, we'll just show an alert
|
||||||
|
alert(`Viewing contract: ${contractId.replace(/-/g, ' ')}`);
|
||||||
|
|
||||||
|
// This would typically involve:
|
||||||
|
// 1. Fetching the contract document from the server
|
||||||
|
// 2. Opening it in a viewer or new tab
|
||||||
|
// window.open(`/contracts/view/${contractId}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('Company management script loaded');
|
||||||
|
});
|
||||||
@@ -1,16 +1,44 @@
|
|||||||
|
use actix_web::{Error, HttpResponse};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use tera::{self, Function, Result, Value};
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
use std::error::Error as StdError;
|
||||||
|
use tera::{self, Context, Function, Tera, Value};
|
||||||
|
|
||||||
// Export modules
|
// Export modules
|
||||||
pub mod redis_service;
|
pub mod redis_service;
|
||||||
|
pub mod secure_logging;
|
||||||
|
pub mod stripe_security;
|
||||||
|
|
||||||
// Re-export for easier imports
|
// Re-export for easier imports
|
||||||
pub use redis_service::RedisCalendarService;
|
// pub use redis_service::RedisCalendarService; // Currently unused
|
||||||
|
|
||||||
/// Registers custom Tera functions
|
/// Error type for template rendering
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct TemplateError {
|
||||||
|
pub message: String,
|
||||||
|
pub details: String,
|
||||||
|
pub location: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TemplateError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "Template error in {}: {}", self.location, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TemplateError {}
|
||||||
|
|
||||||
|
/// Registers custom Tera functions and filters
|
||||||
pub fn register_tera_functions(tera: &mut tera::Tera) {
|
pub fn register_tera_functions(tera: &mut tera::Tera) {
|
||||||
tera.register_function("now", NowFunction);
|
tera.register_function("now", NowFunction);
|
||||||
tera.register_function("format_date", FormatDateFunction);
|
tera.register_function("format_date", FormatDateFunction);
|
||||||
|
tera.register_function("local_time", LocalTimeFunction);
|
||||||
|
|
||||||
|
// Register custom filters
|
||||||
|
tera.register_filter("format_hour", format_hour_filter);
|
||||||
|
tera.register_filter("extract_hour", extract_hour_filter);
|
||||||
|
tera.register_filter("format_time", format_time_filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tera function to get the current date/time
|
/// Tera function to get the current date/time
|
||||||
@@ -18,7 +46,7 @@ pub fn register_tera_functions(tera: &mut tera::Tera) {
|
|||||||
pub struct NowFunction;
|
pub struct NowFunction;
|
||||||
|
|
||||||
impl Function for NowFunction {
|
impl Function for NowFunction {
|
||||||
fn call(&self, args: &std::collections::HashMap<String, Value>) -> Result<Value> {
|
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
let format = match args.get("format") {
|
let format = match args.get("format") {
|
||||||
Some(val) => match val.as_str() {
|
Some(val) => match val.as_str() {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
@@ -43,21 +71,17 @@ impl Function for NowFunction {
|
|||||||
pub struct FormatDateFunction;
|
pub struct FormatDateFunction;
|
||||||
|
|
||||||
impl Function for FormatDateFunction {
|
impl Function for FormatDateFunction {
|
||||||
fn call(&self, args: &std::collections::HashMap<String, Value>) -> Result<Value> {
|
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
let timestamp = match args.get("timestamp") {
|
let timestamp = match args.get("timestamp") {
|
||||||
Some(val) => match val.as_i64() {
|
Some(val) => match val.as_i64() {
|
||||||
Some(ts) => ts,
|
Some(ts) => ts,
|
||||||
None => {
|
None => {
|
||||||
return Err(tera::Error::msg(
|
return Err(tera::Error::msg(
|
||||||
"The 'timestamp' argument must be a valid timestamp",
|
"The 'timestamp' argument must be a valid timestamp",
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => return Err(tera::Error::msg("The 'timestamp' argument is required")),
|
||||||
return Err(tera::Error::msg(
|
|
||||||
"The 'timestamp' argument is required",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let format = match args.get("format") {
|
let format = match args.get("format") {
|
||||||
@@ -71,23 +95,130 @@ impl Function for FormatDateFunction {
|
|||||||
// Convert timestamp to DateTime using the non-deprecated method
|
// Convert timestamp to DateTime using the non-deprecated method
|
||||||
let datetime = match DateTime::from_timestamp(timestamp, 0) {
|
let datetime = match DateTime::from_timestamp(timestamp, 0) {
|
||||||
Some(dt) => dt,
|
Some(dt) => dt,
|
||||||
None => {
|
None => return Err(tera::Error::msg("Failed to convert timestamp to datetime")),
|
||||||
return Err(tera::Error::msg(
|
|
||||||
"Failed to convert timestamp to datetime",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Value::String(datetime.format(format).to_string()))
|
Ok(Value::String(datetime.format(format).to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tera function to convert UTC datetime to local time
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LocalTimeFunction;
|
||||||
|
|
||||||
|
impl Function for LocalTimeFunction {
|
||||||
|
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
|
let datetime_value = match args.get("datetime") {
|
||||||
|
Some(val) => val,
|
||||||
|
None => return Err(tera::Error::msg("The 'datetime' argument is required")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let format = match args.get("format") {
|
||||||
|
Some(val) => match val.as_str() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => "%Y-%m-%d %H:%M",
|
||||||
|
},
|
||||||
|
None => "%Y-%m-%d %H:%M",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The datetime comes from Rust as a serialized DateTime<Utc>
|
||||||
|
// We need to handle it properly
|
||||||
|
let utc_datetime = if let Some(dt_str) = datetime_value.as_str() {
|
||||||
|
// Try to parse as RFC3339 first
|
||||||
|
match DateTime::parse_from_rfc3339(dt_str) {
|
||||||
|
Ok(dt) => dt.with_timezone(&Utc),
|
||||||
|
Err(_) => {
|
||||||
|
// Try to parse as our standard format
|
||||||
|
match DateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
|
||||||
|
Ok(dt) => dt.with_timezone(&Utc),
|
||||||
|
Err(_) => return Err(tera::Error::msg("Invalid datetime string format")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(tera::Error::msg("Datetime must be a string"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert UTC to local time (EEST = UTC+3)
|
||||||
|
// In a real application, you'd want to get the user's timezone from their profile
|
||||||
|
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
|
||||||
|
let local_datetime = utc_datetime.with_timezone(&local_offset);
|
||||||
|
|
||||||
|
Ok(Value::String(local_datetime.format(format).to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tera filter to format hour with zero padding
|
||||||
|
pub fn format_hour_filter(
|
||||||
|
value: &Value,
|
||||||
|
_args: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> tera::Result<Value> {
|
||||||
|
match value.as_i64() {
|
||||||
|
Some(hour) => Ok(Value::String(format!("{:02}", hour))),
|
||||||
|
None => Err(tera::Error::msg("Value must be a number")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tera filter to extract hour from datetime string
|
||||||
|
pub fn extract_hour_filter(
|
||||||
|
value: &Value,
|
||||||
|
_args: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> tera::Result<Value> {
|
||||||
|
match value.as_str() {
|
||||||
|
Some(datetime_str) => {
|
||||||
|
// Try to parse as RFC3339 first
|
||||||
|
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
|
||||||
|
Ok(Value::String(dt.format("%H").to_string()))
|
||||||
|
} else {
|
||||||
|
// Try to parse as our standard format
|
||||||
|
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
|
||||||
|
Ok(dt) => Ok(Value::String(dt.format("%H").to_string())),
|
||||||
|
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Err(tera::Error::msg("Value must be a string")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tera filter to format time from datetime string
|
||||||
|
pub fn format_time_filter(
|
||||||
|
value: &Value,
|
||||||
|
args: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> tera::Result<Value> {
|
||||||
|
let format = match args.get("format") {
|
||||||
|
Some(val) => match val.as_str() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => "%H:%M",
|
||||||
|
},
|
||||||
|
None => "%H:%M",
|
||||||
|
};
|
||||||
|
|
||||||
|
match value.as_str() {
|
||||||
|
Some(datetime_str) => {
|
||||||
|
// Try to parse as RFC3339 first
|
||||||
|
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
|
||||||
|
Ok(Value::String(dt.format(format).to_string()))
|
||||||
|
} else {
|
||||||
|
// Try to parse as our standard format
|
||||||
|
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
|
||||||
|
Ok(dt) => Ok(Value::String(dt.format(format).to_string())),
|
||||||
|
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Err(tera::Error::msg("Value must be a string")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Formats a date for display
|
/// Formats a date for display
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {
|
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {
|
||||||
date.format(format).to_string()
|
date.format(format).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Truncates a string to a maximum length and adds an ellipsis if truncated
|
/// Truncates a string to a maximum length and adds an ellipsis if truncated
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn truncate_string(s: &str, max_length: usize) -> String {
|
pub fn truncate_string(s: &str, max_length: usize) -> String {
|
||||||
if s.len() <= max_length {
|
if s.len() <= max_length {
|
||||||
s.to_string()
|
s.to_string()
|
||||||
@@ -96,6 +227,112 @@ pub fn truncate_string(s: &str, max_length: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses markdown content and returns HTML
|
||||||
|
pub fn parse_markdown(markdown_content: &str) -> String {
|
||||||
|
// Set up markdown parser options
|
||||||
|
let mut options = Options::empty();
|
||||||
|
options.insert(Options::ENABLE_TABLES);
|
||||||
|
options.insert(Options::ENABLE_FOOTNOTES);
|
||||||
|
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
options.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||||
|
|
||||||
|
// Create parser
|
||||||
|
let parser = Parser::new_ext(markdown_content, options);
|
||||||
|
|
||||||
|
// Render to HTML
|
||||||
|
let mut html_output = String::new();
|
||||||
|
html::push_html(&mut html_output, parser);
|
||||||
|
|
||||||
|
html_output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a template with error handling
|
||||||
|
///
|
||||||
|
/// This function attempts to render a template and handles any errors by rendering
|
||||||
|
/// the error template with detailed error information.
|
||||||
|
pub fn render_template(
|
||||||
|
tmpl: &Tera,
|
||||||
|
template_name: &str,
|
||||||
|
ctx: &Context,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
println!("DEBUG: Attempting to render template: {}", template_name);
|
||||||
|
|
||||||
|
// Print all context keys for debugging
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for (key, _) in ctx.clone().into_json().as_object().unwrap().iter() {
|
||||||
|
keys.push(key.clone());
|
||||||
|
}
|
||||||
|
println!("DEBUG: Context keys: {:?}", keys);
|
||||||
|
|
||||||
|
match tmpl.render(template_name, ctx) {
|
||||||
|
Ok(content) => {
|
||||||
|
println!("DEBUG: Successfully rendered template: {}", template_name);
|
||||||
|
Ok(HttpResponse::Ok().content_type("text/html").body(content))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Log the error with more details
|
||||||
|
println!(
|
||||||
|
"DEBUG: Template rendering error for {}: {}",
|
||||||
|
template_name, e
|
||||||
|
);
|
||||||
|
println!("DEBUG: Error details: {:?}", e);
|
||||||
|
|
||||||
|
// Print the error cause chain for better debugging
|
||||||
|
let mut current_error: Option<&dyn StdError> = Some(&e);
|
||||||
|
let mut error_chain = Vec::new();
|
||||||
|
|
||||||
|
while let Some(error) = current_error {
|
||||||
|
error_chain.push(format!("{}", error));
|
||||||
|
current_error = error.source();
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("DEBUG: Error chain: {:?}", error_chain);
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
log::error!("Template rendering error: {}", e);
|
||||||
|
|
||||||
|
// Create a simple error response with more detailed information
|
||||||
|
let error_html = format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Template Error</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }}
|
||||||
|
.error-container {{ border: 1px solid #f5c6cb; background-color: #f8d7da; padding: 20px; border-radius: 5px; }}
|
||||||
|
.error-title {{ color: #721c24; }}
|
||||||
|
.error-details {{ background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin-top: 20px; }}
|
||||||
|
pre {{ background-color: #f1f1f1; padding: 10px; overflow: auto; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<h1 class="error-title">Template Rendering Error</h1>
|
||||||
|
<p>There was an error rendering the template: <strong>{}</strong></p>
|
||||||
|
<div class="error-details">
|
||||||
|
<h3>Error Details:</h3>
|
||||||
|
<pre>{}</pre>
|
||||||
|
<h3>Error Chain:</h3>
|
||||||
|
<pre>{}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
template_name,
|
||||||
|
e,
|
||||||
|
error_chain.join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("DEBUG: Returning simple error page");
|
||||||
|
|
||||||
|
Ok(HttpResponse::InternalServerError()
|
||||||
|
.content_type("text/html")
|
||||||
|
.body(error_html))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
#![allow(dead_code)] // Redis utility functions may not all be used yet
|
||||||
|
|
||||||
|
use heromodels::models::Event as CalendarEvent;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
use redis::{Client, Commands, Connection, RedisError};
|
use redis::{Client, Commands, Connection, RedisError};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use crate::models::CalendarEvent;
|
|
||||||
|
|
||||||
// Create a lazy static Redis client that can be used throughout the application
|
// Create a lazy static Redis client that can be used throughout the application
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
@@ -59,11 +61,11 @@ impl RedisCalendarService {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Save the event
|
// Save the event
|
||||||
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.id);
|
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.base_data.id);
|
||||||
let _: () = conn.set(event_key, json)?;
|
let _: () = conn.set(event_key, json)?;
|
||||||
|
|
||||||
// Add the event ID to the set of all events
|
// Add the event ID to the set of all events
|
||||||
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.id)?;
|
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.base_data.id)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
315
actix_mvc_app/src/utils/secure_logging.rs
Normal file
315
actix_mvc_app/src/utils/secure_logging.rs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Secure logging utilities that prevent sensitive data exposure
|
||||||
|
pub struct SecureLogger;
|
||||||
|
|
||||||
|
impl SecureLogger {
|
||||||
|
/// Log payment events without exposing sensitive data
|
||||||
|
pub fn log_payment_event(event: &str, payment_id: &str, success: bool, details: Option<&str>) {
|
||||||
|
if success {
|
||||||
|
log::info!(
|
||||||
|
"Payment event: {} for payment ID: {} - SUCCESS{}",
|
||||||
|
event,
|
||||||
|
Self::sanitize_payment_id(payment_id),
|
||||||
|
details.map(|d| format!(" ({})", d)).unwrap_or_default()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"Payment event: {} for payment ID: {} - FAILED{}",
|
||||||
|
event,
|
||||||
|
Self::sanitize_payment_id(payment_id),
|
||||||
|
details.map(|d| format!(" ({})", d)).unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log security events with IP tracking
|
||||||
|
pub fn log_security_event(event: &str, ip: &str, success: bool, details: Option<&str>) {
|
||||||
|
let status = if success { "ALLOWED" } else { "BLOCKED" };
|
||||||
|
log::warn!(
|
||||||
|
"Security event: {} from IP: {} - {}{}",
|
||||||
|
event,
|
||||||
|
Self::sanitize_ip(ip),
|
||||||
|
status,
|
||||||
|
details.map(|d| format!(" ({})", d)).unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log webhook events securely
|
||||||
|
pub fn log_webhook_event(event_type: &str, success: bool, payment_intent_id: Option<&str>) {
|
||||||
|
let payment_info = payment_intent_id
|
||||||
|
.map(|id| format!(" for payment {}", Self::sanitize_payment_id(id)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if success {
|
||||||
|
log::info!("Webhook event: {} - SUCCESS{}", event_type, payment_info);
|
||||||
|
} else {
|
||||||
|
log::error!("Webhook event: {} - FAILED{}", event_type, payment_info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log company registration events
|
||||||
|
pub fn log_company_event(event: &str, company_id: u32, company_name: &str, success: bool) {
|
||||||
|
let sanitized_name = Self::sanitize_company_name(company_name);
|
||||||
|
if success {
|
||||||
|
log::info!(
|
||||||
|
"Company event: {} for company ID: {} ({}) - SUCCESS",
|
||||||
|
event, company_id, sanitized_name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"Company event: {} for company ID: {} ({}) - FAILED",
|
||||||
|
event, company_id, sanitized_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log validation errors without exposing user data
|
||||||
|
pub fn log_validation_error(field: &str, error_code: &str, ip: Option<&str>) {
|
||||||
|
let ip_info = ip
|
||||||
|
.map(|ip| format!(" from IP: {}", Self::sanitize_ip(ip)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"Validation error: field '{}' failed with code '{}'{}",
|
||||||
|
field, error_code, ip_info
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log performance metrics
|
||||||
|
pub fn log_performance_metric(operation: &str, duration_ms: u64, success: bool) {
|
||||||
|
if success {
|
||||||
|
log::info!("Performance: {} completed in {}ms", operation, duration_ms);
|
||||||
|
} else {
|
||||||
|
log::warn!("Performance: {} failed after {}ms", operation, duration_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log database operations
|
||||||
|
pub fn log_database_operation(operation: &str, table: &str, success: bool, duration_ms: Option<u64>) {
|
||||||
|
let duration_info = duration_ms
|
||||||
|
.map(|ms| format!(" in {}ms", ms))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if success {
|
||||||
|
log::debug!("Database: {} on {} - SUCCESS{}", operation, table, duration_info);
|
||||||
|
} else {
|
||||||
|
log::error!("Database: {} on {} - FAILED{}", operation, table, duration_info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create structured log entry for monitoring systems
|
||||||
|
pub fn create_structured_log(
|
||||||
|
level: &str,
|
||||||
|
event: &str,
|
||||||
|
details: HashMap<String, serde_json::Value>,
|
||||||
|
) -> String {
|
||||||
|
let mut log_entry = json!({
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"level": level,
|
||||||
|
"event": event,
|
||||||
|
"service": "freezone-registration"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add sanitized details
|
||||||
|
for (key, value) in details {
|
||||||
|
let sanitized_key = Self::sanitize_log_key(&key);
|
||||||
|
let sanitized_value = Self::sanitize_log_value(&value);
|
||||||
|
log_entry[sanitized_key] = sanitized_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&log_entry).unwrap_or_else(|_| {
|
||||||
|
format!("{{\"error\": \"Failed to serialize log entry for event: {}\"}}", event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize payment ID for logging (show only last 4 characters)
|
||||||
|
fn sanitize_payment_id(payment_id: &str) -> String {
|
||||||
|
if payment_id.len() > 4 {
|
||||||
|
format!("****{}", &payment_id[payment_id.len() - 4..])
|
||||||
|
} else {
|
||||||
|
"****".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize IP address for logging (mask last octet)
|
||||||
|
fn sanitize_ip(ip: &str) -> String {
|
||||||
|
if let Some(last_dot) = ip.rfind('.') {
|
||||||
|
format!("{}.***", &ip[..last_dot])
|
||||||
|
} else {
|
||||||
|
"***".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize company name for logging (truncate and remove special chars)
|
||||||
|
fn sanitize_company_name(name: &str) -> String {
|
||||||
|
let sanitized = name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '.')
|
||||||
|
.take(50)
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"***".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize log keys to prevent injection
|
||||||
|
fn sanitize_log_key(key: &str) -> String {
|
||||||
|
key.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '_')
|
||||||
|
.take(50)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize log values to prevent sensitive data exposure
|
||||||
|
fn sanitize_log_value(value: &serde_json::Value) -> serde_json::Value {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
// Check if this looks like sensitive data
|
||||||
|
if Self::is_sensitive_data(s) {
|
||||||
|
json!("***REDACTED***")
|
||||||
|
} else {
|
||||||
|
json!(s.chars().take(200).collect::<String>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Number(n) => json!(n),
|
||||||
|
serde_json::Value::Bool(b) => json!(b),
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
json!(arr.iter().take(10).map(|v| Self::sanitize_log_value(v)).collect::<Vec<_>>())
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(obj) => {
|
||||||
|
let sanitized: serde_json::Map<String, serde_json::Value> = obj
|
||||||
|
.iter()
|
||||||
|
.take(20)
|
||||||
|
.map(|(k, v)| (Self::sanitize_log_key(k), Self::sanitize_log_value(v)))
|
||||||
|
.collect();
|
||||||
|
json!(sanitized)
|
||||||
|
}
|
||||||
|
serde_json::Value::Null => json!(null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a string contains sensitive data patterns
|
||||||
|
fn is_sensitive_data(s: &str) -> bool {
|
||||||
|
let sensitive_patterns = [
|
||||||
|
"password", "secret", "key", "token", "card", "cvv", "cvc",
|
||||||
|
"ssn", "social", "credit", "bank", "account", "pin"
|
||||||
|
];
|
||||||
|
|
||||||
|
let lower_s = s.to_lowercase();
|
||||||
|
sensitive_patterns.iter().any(|pattern| lower_s.contains(pattern)) ||
|
||||||
|
s.len() > 100 || // Long strings might contain sensitive data
|
||||||
|
s.chars().all(|c| c.is_ascii_digit()) && s.len() > 8 // Might be a card number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audit trail logging for compliance
|
||||||
|
pub struct AuditLogger;
|
||||||
|
|
||||||
|
impl AuditLogger {
|
||||||
|
/// Log user actions for audit trail
|
||||||
|
pub fn log_user_action(
|
||||||
|
user_id: u32,
|
||||||
|
action: &str,
|
||||||
|
resource: &str,
|
||||||
|
success: bool,
|
||||||
|
ip: Option<&str>,
|
||||||
|
) {
|
||||||
|
let ip_info = ip
|
||||||
|
.map(|ip| format!(" from {}", SecureLogger::sanitize_ip(ip)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let status = if success { "SUCCESS" } else { "FAILED" };
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"AUDIT: User {} performed '{}' on '{}' - {}{}",
|
||||||
|
user_id, action, resource, status, ip_info
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log administrative actions
|
||||||
|
pub fn log_admin_action(
|
||||||
|
admin_id: u32,
|
||||||
|
action: &str,
|
||||||
|
target: &str,
|
||||||
|
success: bool,
|
||||||
|
details: Option<&str>,
|
||||||
|
) {
|
||||||
|
let details_info = details
|
||||||
|
.map(|d| format!(" ({})", d))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let status = if success { "SUCCESS" } else { "FAILED" };
|
||||||
|
|
||||||
|
log::warn!(
|
||||||
|
"ADMIN_AUDIT: Admin {} performed '{}' on '{}' - {}{}",
|
||||||
|
admin_id, action, target, status, details_info
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log data access for compliance
|
||||||
|
pub fn log_data_access(
|
||||||
|
user_id: u32,
|
||||||
|
data_type: &str,
|
||||||
|
operation: &str,
|
||||||
|
record_count: Option<usize>,
|
||||||
|
) {
|
||||||
|
let count_info = record_count
|
||||||
|
.map(|c| format!(" ({} records)", c))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"DATA_ACCESS: User {} performed '{}' on '{}'{}",
|
||||||
|
user_id, operation, data_type, count_info
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_payment_id() {
|
||||||
|
assert_eq!(SecureLogger::sanitize_payment_id("pi_1234567890"), "****7890");
|
||||||
|
assert_eq!(SecureLogger::sanitize_payment_id("123"), "****");
|
||||||
|
assert_eq!(SecureLogger::sanitize_payment_id(""), "****");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_ip() {
|
||||||
|
assert_eq!(SecureLogger::sanitize_ip("192.168.1.100"), "192.168.1.***");
|
||||||
|
assert_eq!(SecureLogger::sanitize_ip("invalid"), "***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_company_name() {
|
||||||
|
assert_eq!(SecureLogger::sanitize_company_name("Test Company Ltd."), "Test Company Ltd.");
|
||||||
|
assert_eq!(SecureLogger::sanitize_company_name("Test<script>alert(1)</script>"), "Testscriptalert1script");
|
||||||
|
assert_eq!(SecureLogger::sanitize_company_name(""), "***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_sensitive_data() {
|
||||||
|
assert!(SecureLogger::is_sensitive_data("password123"));
|
||||||
|
assert!(SecureLogger::is_sensitive_data("secret_key"));
|
||||||
|
assert!(SecureLogger::is_sensitive_data("4111111111111111")); // Card number pattern
|
||||||
|
assert!(!SecureLogger::is_sensitive_data("normal text"));
|
||||||
|
assert!(!SecureLogger::is_sensitive_data("123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_structured_log_creation() {
|
||||||
|
let mut details = HashMap::new();
|
||||||
|
details.insert("user_id".to_string(), json!(123));
|
||||||
|
details.insert("action".to_string(), json!("payment_created"));
|
||||||
|
|
||||||
|
let log_entry = SecureLogger::create_structured_log("INFO", "payment_event", details);
|
||||||
|
assert!(log_entry.contains("payment_event"));
|
||||||
|
assert!(log_entry.contains("freezone-registration"));
|
||||||
|
}
|
||||||
|
}
|
||||||
257
actix_mvc_app/src/utils/stripe_security.rs
Normal file
257
actix_mvc_app/src/utils/stripe_security.rs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// Stripe webhook signature verification
|
||||||
|
/// Implements proper HMAC-SHA256 verification as per Stripe documentation
|
||||||
|
pub struct StripeWebhookVerifier;
|
||||||
|
|
||||||
|
impl StripeWebhookVerifier {
|
||||||
|
/// Verify Stripe webhook signature
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `payload` - Raw webhook payload bytes
|
||||||
|
/// * `signature_header` - Stripe-Signature header value
|
||||||
|
/// * `webhook_secret` - Webhook endpoint secret from Stripe
|
||||||
|
/// * `tolerance_seconds` - Maximum age of webhook (default: 300 seconds)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Ok(true)` - Signature is valid
|
||||||
|
/// * `Ok(false)` - Signature is invalid
|
||||||
|
/// * `Err(String)` - Verification error
|
||||||
|
pub fn verify_signature(
|
||||||
|
payload: &[u8],
|
||||||
|
signature_header: &str,
|
||||||
|
webhook_secret: &str,
|
||||||
|
tolerance_seconds: Option<u64>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let tolerance = tolerance_seconds.unwrap_or(300); // 5 minutes default
|
||||||
|
|
||||||
|
// Parse signature header
|
||||||
|
let (timestamp, signatures) = Self::parse_signature_header(signature_header)?;
|
||||||
|
|
||||||
|
// Check timestamp tolerance
|
||||||
|
Self::verify_timestamp(timestamp, tolerance)?;
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
Self::verify_hmac(payload, timestamp, signatures, webhook_secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Stripe signature header
|
||||||
|
/// Format: "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
|
||||||
|
fn parse_signature_header(signature_header: &str) -> Result<(u64, Vec<String>), String> {
|
||||||
|
let mut timestamp = None;
|
||||||
|
let mut signatures = Vec::new();
|
||||||
|
|
||||||
|
for element in signature_header.split(',') {
|
||||||
|
let parts: Vec<&str> = element.splitn(2, '=').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match parts[0] {
|
||||||
|
"t" => {
|
||||||
|
timestamp = Some(
|
||||||
|
parts[1]
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| "Invalid timestamp in signature header".to_string())?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"v1" => {
|
||||||
|
signatures.push(parts[1].to_string());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Ignore unknown signature schemes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = timestamp.ok_or("Missing timestamp in signature header")?;
|
||||||
|
|
||||||
|
if signatures.is_empty() {
|
||||||
|
return Err("No valid signatures found in header".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((timestamp, signatures))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify timestamp is within tolerance
|
||||||
|
fn verify_timestamp(timestamp: u64, tolerance_seconds: u64) -> Result<(), String> {
|
||||||
|
let current_time = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| "Failed to get current time")?
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let age = current_time.saturating_sub(timestamp);
|
||||||
|
|
||||||
|
if age > tolerance_seconds {
|
||||||
|
return Err(format!(
|
||||||
|
"Webhook timestamp too old: {} seconds (max: {})",
|
||||||
|
age, tolerance_seconds
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify HMAC signature
|
||||||
|
fn verify_hmac(
|
||||||
|
payload: &[u8],
|
||||||
|
timestamp: u64,
|
||||||
|
signatures: Vec<String>,
|
||||||
|
webhook_secret: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// Create signed payload: timestamp + "." + payload
|
||||||
|
let signed_payload = format!(
|
||||||
|
"{}.{}",
|
||||||
|
timestamp,
|
||||||
|
std::str::from_utf8(payload).map_err(|_| "Invalid UTF-8 in payload")?
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create HMAC
|
||||||
|
let mut mac = HmacSha256::new_from_slice(webhook_secret.as_bytes())
|
||||||
|
.map_err(|_| "Invalid webhook secret")?;
|
||||||
|
mac.update(signed_payload.as_bytes());
|
||||||
|
|
||||||
|
// Get expected signature
|
||||||
|
let expected_signature = hex::encode(mac.finalize().into_bytes());
|
||||||
|
|
||||||
|
// Compare with provided signatures (constant-time comparison)
|
||||||
|
for signature in signatures {
|
||||||
|
if constant_time_compare(&expected_signature, &signature) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time string comparison to prevent timing attacks
|
||||||
|
fn constant_time_compare(a: &str, b: &str) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = 0u8;
|
||||||
|
for (byte_a, byte_b) in a.bytes().zip(b.bytes()) {
|
||||||
|
result |= byte_a ^ byte_b;
|
||||||
|
}
|
||||||
|
|
||||||
|
result == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_signature_header() {
|
||||||
|
let header =
|
||||||
|
"t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd";
|
||||||
|
let (timestamp, signatures) =
|
||||||
|
StripeWebhookVerifier::parse_signature_header(header).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(timestamp, 1492774577);
|
||||||
|
assert_eq!(signatures.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
signatures[0],
|
||||||
|
"5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_signature_header_multiple_signatures() {
|
||||||
|
let header = "t=1492774577,v1=sig1,v1=sig2";
|
||||||
|
let (timestamp, signatures) =
|
||||||
|
StripeWebhookVerifier::parse_signature_header(header).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(timestamp, 1492774577);
|
||||||
|
assert_eq!(signatures.len(), 2);
|
||||||
|
assert_eq!(signatures[0], "sig1");
|
||||||
|
assert_eq!(signatures[1], "sig2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_signature_header_invalid() {
|
||||||
|
let header = "invalid_header";
|
||||||
|
let result = StripeWebhookVerifier::parse_signature_header(header);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_constant_time_compare() {
|
||||||
|
assert!(constant_time_compare("hello", "hello"));
|
||||||
|
assert!(!constant_time_compare("hello", "world"));
|
||||||
|
assert!(!constant_time_compare("hello", "hello123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_timestamp_valid() {
|
||||||
|
let current_time = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// Test with current timestamp (should pass)
|
||||||
|
assert!(StripeWebhookVerifier::verify_timestamp(current_time, 300).is_ok());
|
||||||
|
|
||||||
|
// Test with timestamp 100 seconds ago (should pass)
|
||||||
|
assert!(StripeWebhookVerifier::verify_timestamp(current_time - 100, 300).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_timestamp_too_old() {
|
||||||
|
let current_time = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// Test with timestamp 400 seconds ago (should fail with 300s tolerance)
|
||||||
|
let result = StripeWebhookVerifier::verify_timestamp(current_time - 400, 300);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("too old"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_signature_integration() {
|
||||||
|
// Test with known good signature from Stripe documentation
|
||||||
|
let payload = b"test payload";
|
||||||
|
let webhook_secret = "whsec_test_secret";
|
||||||
|
let timestamp = 1492774577u64;
|
||||||
|
|
||||||
|
// Create expected signature manually for testing
|
||||||
|
let signed_payload = format!("{}.{}", timestamp, std::str::from_utf8(payload).unwrap());
|
||||||
|
let mut mac = HmacSha256::new_from_slice(webhook_secret.as_bytes()).unwrap();
|
||||||
|
mac.update(signed_payload.as_bytes());
|
||||||
|
let expected_sig = hex::encode(mac.finalize().into_bytes());
|
||||||
|
|
||||||
|
let _signature_header = format!("t={},v1={}", timestamp, expected_sig);
|
||||||
|
|
||||||
|
// This would fail due to timestamp being too old, so we test with a recent timestamp
|
||||||
|
let current_time = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let signed_payload_current =
|
||||||
|
format!("{}.{}", current_time, std::str::from_utf8(payload).unwrap());
|
||||||
|
let mut mac_current = HmacSha256::new_from_slice(webhook_secret.as_bytes()).unwrap();
|
||||||
|
mac_current.update(signed_payload_current.as_bytes());
|
||||||
|
let current_sig = hex::encode(mac_current.finalize().into_bytes());
|
||||||
|
|
||||||
|
let current_signature_header = format!("t={},v1={}", current_time, current_sig);
|
||||||
|
|
||||||
|
let result = StripeWebhookVerifier::verify_signature(
|
||||||
|
payload,
|
||||||
|
¤t_signature_header,
|
||||||
|
webhook_secret,
|
||||||
|
Some(300),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
403
actix_mvc_app/src/validators/company.rs
Normal file
403
actix_mvc_app/src/validators/company.rs
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Validation error details
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ValidationError {
|
||||||
|
pub field: String,
|
||||||
|
pub message: String,
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validation result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ValidationResult {
|
||||||
|
pub is_valid: bool,
|
||||||
|
pub errors: Vec<ValidationError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationResult {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
is_valid: true,
|
||||||
|
errors: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_error(&mut self, field: &str, message: &str, code: &str) {
|
||||||
|
self.is_valid = false;
|
||||||
|
self.errors.push(ValidationError {
|
||||||
|
field: field.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
code: code.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge(&mut self, other: ValidationResult) {
|
||||||
|
if !other.is_valid {
|
||||||
|
self.is_valid = false;
|
||||||
|
self.errors.extend(other.errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Company registration data validator
|
||||||
|
pub struct CompanyRegistrationValidator;
|
||||||
|
|
||||||
|
impl CompanyRegistrationValidator {
|
||||||
|
/// Validate complete company registration data
|
||||||
|
pub fn validate(
|
||||||
|
data: &crate::controllers::payment::CompanyRegistrationData,
|
||||||
|
) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
// Validate company name
|
||||||
|
result.merge(Self::validate_company_name(&data.company_name));
|
||||||
|
|
||||||
|
// Validate company type
|
||||||
|
result.merge(Self::validate_company_type(&data.company_type));
|
||||||
|
|
||||||
|
// Validate email (if provided)
|
||||||
|
if let Some(ref email) = data.company_email {
|
||||||
|
if !email.is_empty() {
|
||||||
|
result.merge(Self::validate_email(email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate phone (if provided)
|
||||||
|
if let Some(ref phone) = data.company_phone {
|
||||||
|
if !phone.is_empty() {
|
||||||
|
result.merge(Self::validate_phone(phone));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate website (if provided)
|
||||||
|
if let Some(ref website) = data.company_website {
|
||||||
|
if !website.is_empty() {
|
||||||
|
result.merge(Self::validate_website(website));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate address (if provided)
|
||||||
|
if let Some(ref address) = data.company_address {
|
||||||
|
if !address.is_empty() {
|
||||||
|
result.merge(Self::validate_address(address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate shareholders JSON
|
||||||
|
result.merge(Self::validate_shareholders(&data.shareholders));
|
||||||
|
|
||||||
|
// Validate payment plan
|
||||||
|
result.merge(Self::validate_payment_plan(&data.payment_plan));
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate company name
|
||||||
|
fn validate_company_name(name: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
result.add_error("company_name", "Company name is required", "required");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.len() < 2 {
|
||||||
|
result.add_error(
|
||||||
|
"company_name",
|
||||||
|
"Company name must be at least 2 characters long",
|
||||||
|
"min_length",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.len() > 100 {
|
||||||
|
result.add_error(
|
||||||
|
"company_name",
|
||||||
|
"Company name must be less than 100 characters",
|
||||||
|
"max_length",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid characters (letters, numbers, spaces, common punctuation)
|
||||||
|
let valid_name_regex = Regex::new(r"^[a-zA-Z0-9\s\-\.\&\(\)]+$").unwrap();
|
||||||
|
if !valid_name_regex.is_match(name) {
|
||||||
|
result.add_error(
|
||||||
|
"company_name",
|
||||||
|
"Company name contains invalid characters",
|
||||||
|
"invalid_format",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate company type
|
||||||
|
fn validate_company_type(company_type: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
let valid_types = vec![
|
||||||
|
"Single FZC",
|
||||||
|
"Startup FZC",
|
||||||
|
"Growth FZC",
|
||||||
|
"Global FZC",
|
||||||
|
"Cooperative FZC",
|
||||||
|
"Twin FZC",
|
||||||
|
];
|
||||||
|
|
||||||
|
if !valid_types.contains(&company_type) {
|
||||||
|
result.add_error(
|
||||||
|
"company_type",
|
||||||
|
"Invalid company type selected",
|
||||||
|
"invalid_option",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate email address
|
||||||
|
fn validate_email(email: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
if email.trim().is_empty() {
|
||||||
|
return result; // Email is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email regex
|
||||||
|
let email_regex = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
|
||||||
|
if !email_regex.is_match(email) {
|
||||||
|
result.add_error(
|
||||||
|
"company_email",
|
||||||
|
"Please enter a valid email address",
|
||||||
|
"invalid_format",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if email.len() > 254 {
|
||||||
|
result.add_error("company_email", "Email address is too long", "max_length");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate phone number
|
||||||
|
fn validate_phone(phone: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
if phone.trim().is_empty() {
|
||||||
|
return result; // Phone is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove common formatting characters
|
||||||
|
let cleaned_phone = phone.replace(&[' ', '-', '(', ')', '+'][..], "");
|
||||||
|
|
||||||
|
if cleaned_phone.len() < 7 {
|
||||||
|
result.add_error("company_phone", "Phone number is too short", "min_length");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleaned_phone.len() > 15 {
|
||||||
|
result.add_error("company_phone", "Phone number is too long", "max_length");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if contains only digits after cleaning
|
||||||
|
if !cleaned_phone.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
result.add_error(
|
||||||
|
"company_phone",
|
||||||
|
"Phone number contains invalid characters",
|
||||||
|
"invalid_format",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate website URL
|
||||||
|
fn validate_website(website: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
if website.trim().is_empty() {
|
||||||
|
return result; // Website is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
let url_regex = Regex::new(r"^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$").unwrap();
|
||||||
|
if !url_regex.is_match(website) {
|
||||||
|
result.add_error(
|
||||||
|
"company_website",
|
||||||
|
"Please enter a valid website URL (e.g., https://example.com)",
|
||||||
|
"invalid_format",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if website.len() > 255 {
|
||||||
|
result.add_error("company_website", "Website URL is too long", "max_length");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate address
|
||||||
|
fn validate_address(address: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
if address.trim().is_empty() {
|
||||||
|
return result; // Address is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
if address.len() < 5 {
|
||||||
|
result.add_error("company_address", "Address is too short", "min_length");
|
||||||
|
}
|
||||||
|
|
||||||
|
if address.len() > 500 {
|
||||||
|
result.add_error("company_address", "Address is too long", "max_length");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate shareholders JSON
|
||||||
|
fn validate_shareholders(shareholders: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
if shareholders.trim().is_empty() {
|
||||||
|
result.add_error(
|
||||||
|
"shareholders",
|
||||||
|
"Shareholders information is required",
|
||||||
|
"required",
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as JSON
|
||||||
|
match serde_json::from_str::<serde_json::Value>(shareholders) {
|
||||||
|
Ok(json) => {
|
||||||
|
if let Some(array) = json.as_array() {
|
||||||
|
if array.is_empty() {
|
||||||
|
result.add_error(
|
||||||
|
"shareholders",
|
||||||
|
"At least one shareholder is required",
|
||||||
|
"min_items",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.add_error(
|
||||||
|
"shareholders",
|
||||||
|
"Shareholders must be a valid JSON array",
|
||||||
|
"invalid_format",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
result.add_error(
|
||||||
|
"shareholders",
|
||||||
|
"Invalid shareholders data format",
|
||||||
|
"invalid_json",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate payment plan
|
||||||
|
fn validate_payment_plan(payment_plan: &str) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult::new();
|
||||||
|
|
||||||
|
let valid_plans = vec!["monthly", "yearly", "two_year"];
|
||||||
|
|
||||||
|
if !valid_plans.contains(&payment_plan) {
|
||||||
|
result.add_error(
|
||||||
|
"payment_plan",
|
||||||
|
"Invalid payment plan selected",
|
||||||
|
"invalid_option",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::controllers::payment::CompanyRegistrationData;
|
||||||
|
|
||||||
|
fn create_valid_registration_data() -> CompanyRegistrationData {
|
||||||
|
CompanyRegistrationData {
|
||||||
|
company_name: "Test Company Ltd".to_string(),
|
||||||
|
company_type: "Single FZC".to_string(),
|
||||||
|
company_email: Some("test@example.com".to_string()),
|
||||||
|
company_phone: Some("+1234567890".to_string()),
|
||||||
|
company_website: Some("https://example.com".to_string()),
|
||||||
|
company_address: Some("123 Test Street, Test City".to_string()),
|
||||||
|
company_industry: Some("Technology".to_string()),
|
||||||
|
company_purpose: Some("Software development".to_string()),
|
||||||
|
fiscal_year_end: Some("December".to_string()),
|
||||||
|
shareholders: r#"[{"name": "John Doe", "percentage": 100}]"#.to_string(),
|
||||||
|
payment_plan: "monthly".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_registration_data() {
|
||||||
|
let data = create_valid_registration_data();
|
||||||
|
let result = CompanyRegistrationValidator::validate(&data);
|
||||||
|
assert!(result.is_valid, "Valid data should pass validation");
|
||||||
|
assert!(result.errors.is_empty(), "Valid data should have no errors");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_company_name() {
|
||||||
|
let mut data = create_valid_registration_data();
|
||||||
|
data.company_name = "".to_string();
|
||||||
|
let result = CompanyRegistrationValidator::validate(&data);
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.errors.iter().any(|e| e.field == "company_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_email() {
|
||||||
|
let mut data = create_valid_registration_data();
|
||||||
|
data.company_email = Some("invalid-email".to_string());
|
||||||
|
let result = CompanyRegistrationValidator::validate(&data);
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.errors.iter().any(|e| e.field == "company_email"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_phone() {
|
||||||
|
let mut data = create_valid_registration_data();
|
||||||
|
data.company_phone = Some("123".to_string());
|
||||||
|
let result = CompanyRegistrationValidator::validate(&data);
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.errors.iter().any(|e| e.field == "company_phone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_website() {
|
||||||
|
let mut data = create_valid_registration_data();
|
||||||
|
data.company_website = Some("not-a-url".to_string());
|
||||||
|
let result = CompanyRegistrationValidator::validate(&data);
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.errors.iter().any(|e| e.field == "company_website"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_shareholders() {
|
||||||
|
let mut data = create_valid_registration_data();
|
||||||
|
data.shareholders = "invalid json".to_string();
|
||||||
|
let result = CompanyRegistrationValidator::validate(&data);
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.errors.iter().any(|e| e.field == "shareholders"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_payment_plan() {
|
||||||
|
let mut data = create_valid_registration_data();
|
||||||
|
data.payment_plan = "invalid_plan".to_string();
|
||||||
|
let result = CompanyRegistrationValidator::validate(&data);
|
||||||
|
assert!(!result.is_valid);
|
||||||
|
assert!(result.errors.iter().any(|e| e.field == "payment_plan"));
|
||||||
|
}
|
||||||
|
}
|
||||||
4
actix_mvc_app/src/validators/mod.rs
Normal file
4
actix_mvc_app/src/validators/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod company;
|
||||||
|
|
||||||
|
// Re-export for easier imports
|
||||||
|
pub use company::{CompanyRegistrationValidator, ValidationError, ValidationResult};
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}About - Actix MVC App{% endblock %}
|
{% block title %}About - Zanzibar Digital Freezone{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h1 class="card-title">About Actix MVC App</h1>
|
<h1 class="card-title">About Zanzibar Digital Freezone</h1>
|
||||||
<p class="card-text">This is a sample application demonstrating how to build a web application using Rust with an MVC architecture.</p>
|
<p class="card-text">Convenience, Safety and Privacy</p>
|
||||||
|
|
||||||
<h2 class="mt-4">Technology Stack</h2>
|
<h2 class="mt-4">Technology Stack</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
271
actix_mvc_app/src/views/assets/create.html
Normal file
271
actix_mvc_app/src/views/assets/create.html
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Create New Digital Asset{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<h1 class="mt-4">Create New Digital Asset</h1>
|
||||||
|
<ol class="breadcrumb mb-4">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/assets">Digital Assets</a></li>
|
||||||
|
<li class="breadcrumb-item active">Create New Asset</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-plus-circle me-1"></i>
|
||||||
|
Asset Details
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="createAssetForm" method="post" action="/assets/create">
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>Basic Information</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="name" class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="asset_type" class="form-label">Asset Type</label>
|
||||||
|
<select class="form-select" id="asset_type" name="asset_type" required>
|
||||||
|
{% for type_value, type_label in asset_types %}
|
||||||
|
<option value="{{ type_value }}">{{ type_label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="description" name="description" rows="3" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="image_url" class="form-label">Image URL (optional)</label>
|
||||||
|
<input type="url" class="form-control" id="image_url" name="image_url">
|
||||||
|
<div class="form-text">URL to an image representing this asset</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="external_url" class="form-label">External URL (optional)</label>
|
||||||
|
<input type="url" class="form-control" id="external_url" name="external_url">
|
||||||
|
<div class="form-text">URL to an external resource for this asset</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blockchain Information -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>Blockchain Information (optional)</h5>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="has_blockchain_info" name="has_blockchain_info">
|
||||||
|
<label class="form-check-label" for="has_blockchain_info">
|
||||||
|
This asset has blockchain information
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="blockchainInfoSection" style="display: none;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="blockchain" class="form-label">Blockchain</label>
|
||||||
|
<input type="text" class="form-control" id="blockchain" name="blockchain">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="token_id" class="form-label">Token ID</label>
|
||||||
|
<input type="text" class="form-control" id="token_id" name="token_id">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="contract_address" class="form-label">Contract Address</label>
|
||||||
|
<input type="text" class="form-control" id="contract_address" name="contract_address">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="owner_address" class="form-label">Owner Address</label>
|
||||||
|
<input type="text" class="form-control" id="owner_address" name="owner_address">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="transaction_hash" class="form-label">Transaction Hash (optional)</label>
|
||||||
|
<input type="text" class="form-control" id="transaction_hash" name="transaction_hash">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="block_number" class="form-label">Block Number (optional)</label>
|
||||||
|
<input type="number" class="form-control" id="block_number" name="block_number">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Initial Valuation -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>Initial Valuation (optional)</h5>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="has_valuation" name="has_valuation">
|
||||||
|
<label class="form-check-label" for="has_valuation">
|
||||||
|
Add an initial valuation for this asset
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="valuationSection" style="display: none;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="value" class="form-label">Value</label>
|
||||||
|
<input type="number" class="form-control" id="value" name="value" step="0.01">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="currency" class="form-label">Currency</label>
|
||||||
|
<input type="text" class="form-control" id="currency" name="currency" value="USD">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="source" class="form-label">Source</label>
|
||||||
|
<input type="text" class="form-control" id="source" name="source">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="valuation_notes" class="form-label">Notes</label>
|
||||||
|
<textarea class="form-control" id="valuation_notes" name="valuation_notes" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>Additional Metadata (optional)</h5>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="has_metadata" name="has_metadata">
|
||||||
|
<label class="form-check-label" for="has_metadata">
|
||||||
|
Add additional metadata for this asset
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="metadataSection" style="display: none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="metadata" class="form-label">Metadata (JSON format)</label>
|
||||||
|
<textarea class="form-control" id="metadata" name="metadata" rows="5"></textarea>
|
||||||
|
<div class="form-text">Enter additional metadata in JSON format</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
|
<a href="/assets" class="btn btn-secondary me-md-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Asset</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Toggle blockchain info section
|
||||||
|
const hasBlockchainInfo = document.getElementById('has_blockchain_info');
|
||||||
|
const blockchainInfoSection = document.getElementById('blockchainInfoSection');
|
||||||
|
|
||||||
|
hasBlockchainInfo.addEventListener('change', function() {
|
||||||
|
blockchainInfoSection.style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle valuation section
|
||||||
|
const hasValuation = document.getElementById('has_valuation');
|
||||||
|
const valuationSection = document.getElementById('valuationSection');
|
||||||
|
|
||||||
|
hasValuation.addEventListener('change', function() {
|
||||||
|
valuationSection.style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle metadata section
|
||||||
|
const hasMetadata = document.getElementById('has_metadata');
|
||||||
|
const metadataSection = document.getElementById('metadataSection');
|
||||||
|
|
||||||
|
hasMetadata.addEventListener('change', function() {
|
||||||
|
metadataSection.style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
const form = document.getElementById('createAssetForm');
|
||||||
|
form.addEventListener('submit', function(event) {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const name = document.getElementById('name').value.trim();
|
||||||
|
const description = document.getElementById('description').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
isValid = false;
|
||||||
|
document.getElementById('name').classList.add('is-invalid');
|
||||||
|
} else {
|
||||||
|
document.getElementById('name').classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description) {
|
||||||
|
isValid = false;
|
||||||
|
document.getElementById('description').classList.add('is-invalid');
|
||||||
|
} else {
|
||||||
|
document.getElementById('description').classList.remove('is-invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate blockchain info if checked
|
||||||
|
if (hasBlockchainInfo.checked) {
|
||||||
|
const blockchain = document.getElementById('blockchain').value.trim();
|
||||||
|
const tokenId = document.getElementById('token_id').value.trim();
|
||||||
|
const contractAddress = document.getElementById('contract_address').value.trim();
|
||||||
|
const ownerAddress = document.getElementById('owner_address').value.trim();
|
||||||
|
|
||||||
|
if (!blockchain || !tokenId || !contractAddress || !ownerAddress) {
|
||||||
|
isValid = false;
|
||||||
|
if (!blockchain) document.getElementById('blockchain').classList.add('is-invalid');
|
||||||
|
if (!tokenId) document.getElementById('token_id').classList.add('is-invalid');
|
||||||
|
if (!contractAddress) document.getElementById('contract_address').classList.add('is-invalid');
|
||||||
|
if (!ownerAddress) document.getElementById('owner_address').classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate valuation if checked
|
||||||
|
if (hasValuation.checked) {
|
||||||
|
const value = document.getElementById('value').value.trim();
|
||||||
|
const currency = document.getElementById('currency').value.trim();
|
||||||
|
const source = document.getElementById('source').value.trim();
|
||||||
|
|
||||||
|
if (!value || !currency || !source) {
|
||||||
|
isValid = false;
|
||||||
|
if (!value) document.getElementById('value').classList.add('is-invalid');
|
||||||
|
if (!currency) document.getElementById('currency').classList.add('is-invalid');
|
||||||
|
if (!source) document.getElementById('source').classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate metadata if checked
|
||||||
|
if (hasMetadata.checked) {
|
||||||
|
const metadata = document.getElementById('metadata').value.trim();
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
try {
|
||||||
|
JSON.parse(metadata);
|
||||||
|
document.getElementById('metadata').classList.remove('is-invalid');
|
||||||
|
} catch (e) {
|
||||||
|
isValid = false;
|
||||||
|
document.getElementById('metadata').classList.add('is-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
event.preventDefault();
|
||||||
|
alert('Please fix the errors in the form before submitting.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form-check-input:checked {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-invalid {
|
||||||
|
border-color: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
556
actix_mvc_app/src/views/assets/detail.html
Normal file
556
actix_mvc_app/src/views/assets/detail.html
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Asset Details - {{ asset.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<h1 class="mt-4">Asset Details</h1>
|
||||||
|
<ol class="breadcrumb mb-4">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/assets">Digital Assets</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ asset.name }}</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<!-- Asset Overview -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
Asset Information
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
{% if asset.image_url %}
|
||||||
|
<img src="{{ asset.image_url }}" alt="{{ asset.name }}" class="img-fluid asset-image mb-3">
|
||||||
|
{% else %}
|
||||||
|
<div class="asset-placeholder mb-3">
|
||||||
|
<i class="fas fa-cube fa-5x"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<h3>{{ asset.name }}</h3>
|
||||||
|
<div>
|
||||||
|
{% if asset.status == "Active" %}
|
||||||
|
<span class="badge bg-success">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "For Sale" %}
|
||||||
|
<span class="badge bg-warning">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Locked" %}
|
||||||
|
<span class="badge bg-secondary">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Transferred" %}
|
||||||
|
<span class="badge bg-info">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Archived" %}
|
||||||
|
<span class="badge bg-danger">{{ asset.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary">{{ asset.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge bg-primary">{{ asset.asset_type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5>Description</h5>
|
||||||
|
<p>{{ asset.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5>Current Valuation</h5>
|
||||||
|
{% if asset.current_valuation %}
|
||||||
|
<h3 class="text-primary">{{ asset.valuation_currency }}{{ asset.current_valuation }}</h3>
|
||||||
|
<small class="text-muted">Last updated: {{ asset.valuation_date }}</small>
|
||||||
|
{% else %}
|
||||||
|
<p>No valuation available</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5>Owner Information</h5>
|
||||||
|
<p><strong>Owner:</strong> {{ asset.owner_name }}</p>
|
||||||
|
<p><strong>Owner ID:</strong> {{ asset.owner_id }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5>Dates</h5>
|
||||||
|
<p><strong>Created:</strong> {{ asset.created_at }}</p>
|
||||||
|
<p><strong>Last Updated:</strong> {{ asset.updated_at }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if asset.external_url %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{{ asset.external_url }}" target="_blank" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i> View External Resource
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#valuationModal">
|
||||||
|
<i class="fas fa-dollar-sign me-1"></i> Add Valuation
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#transactionModal">
|
||||||
|
<i class="fas fa-exchange-alt me-1"></i> Record Transaction
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" type="button" data-bs-toggle="modal" data-bs-target="#statusModal">
|
||||||
|
<i class="fas fa-edit me-1"></i> Change Status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-8">
|
||||||
|
<!-- Blockchain Information -->
|
||||||
|
{% if asset.blockchain_info %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-link me-1"></i>
|
||||||
|
Blockchain Information
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>Blockchain:</strong> {{ asset.blockchain_info.blockchain }}</p>
|
||||||
|
<p><strong>Token ID:</strong> {{ asset.blockchain_info.token_id }}</p>
|
||||||
|
<p><strong>Contract Address:</strong>
|
||||||
|
<code class="blockchain-address">{{ asset.blockchain_info.contract_address }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>Owner Address:</strong>
|
||||||
|
<code class="blockchain-address">{{ asset.blockchain_info.owner_address }}</code>
|
||||||
|
</p>
|
||||||
|
{% if asset.blockchain_info.transaction_hash %}
|
||||||
|
<p><strong>Transaction Hash:</strong>
|
||||||
|
<code class="blockchain-address">{{ asset.blockchain_info.transaction_hash }}</code>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if asset.blockchain_info.block_number %}
|
||||||
|
<p><strong>Block Number:</strong> {{ asset.blockchain_info.block_number }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if asset.blockchain_info.timestamp %}
|
||||||
|
<p><strong>Timestamp:</strong> {{ asset.blockchain_info.timestamp }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Valuation History Chart -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-chart-line me-1"></i>
|
||||||
|
Valuation History
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if valuation_history and valuation_history|length > 0 %}
|
||||||
|
<canvas id="valuationChart" width="100%" height="40"></canvas>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No valuation history available for this asset.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Valuation History Table -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-history me-1"></i>
|
||||||
|
Valuation History
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if asset.valuation_history and asset.valuation_history|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Currency</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for valuation in asset.valuation_history %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ valuation.date }}</td>
|
||||||
|
<td>{{ valuation.value }}</td>
|
||||||
|
<td>{{ valuation.currency }}</td>
|
||||||
|
<td>{{ valuation.source }}</td>
|
||||||
|
<td>{% if valuation.notes %}{{ valuation.notes }}{% else %}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No valuation history available for this asset.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction History -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-exchange-alt me-1"></i>
|
||||||
|
Transaction History
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if asset.transaction_history and asset.transaction_history|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Transaction Hash</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for transaction in asset.transaction_history %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ transaction.date }}</td>
|
||||||
|
<td>{{ transaction.transaction_type }}</td>
|
||||||
|
<td>
|
||||||
|
{% if transaction.from_address %}
|
||||||
|
<code class="blockchain-address-small">{{ transaction.from_address }}</code>
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if transaction.to_address %}
|
||||||
|
<code class="blockchain-address-small">{{ transaction.to_address }}</code>
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if transaction.amount %}
|
||||||
|
{{ transaction.currency }}{{ transaction.amount }}
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if transaction.transaction_hash %}
|
||||||
|
<code class="blockchain-address-small">{{ transaction.transaction_hash }}</code>
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{% if transaction.notes %}{{ transaction.notes }}{% else %}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No transaction history available for this asset.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Valuation Modal -->
|
||||||
|
<div class="modal fade" id="valuationModal" tabindex="-1" aria-labelledby="valuationModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="valuationModalLabel">Add Valuation</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="valuationForm" method="post" action="/assets/{{ asset.id }}/valuation">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="value" class="form-label">Value</label>
|
||||||
|
<input type="number" class="form-control" id="value" name="value" step="0.01" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="currency" class="form-label">Currency</label>
|
||||||
|
<input type="text" class="form-control" id="currency" name="currency" value="USD" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="source" class="form-label">Source</label>
|
||||||
|
<input type="text" class="form-control" id="source" name="source" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="notes" class="form-label">Notes</label>
|
||||||
|
<textarea class="form-control" id="notes" name="notes" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveValuationBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction Modal -->
|
||||||
|
<div class="modal fade" id="transactionModal" tabindex="-1" aria-labelledby="transactionModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="transactionModalLabel">Record Transaction</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="transactionForm" method="post" action="/assets/{{ asset.id }}/transaction">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transaction_type" class="form-label">Transaction Type</label>
|
||||||
|
<select class="form-select" id="transaction_type" name="transaction_type" required>
|
||||||
|
<option value="Purchase">Purchase</option>
|
||||||
|
<option value="Sale">Sale</option>
|
||||||
|
<option value="Transfer">Transfer</option>
|
||||||
|
<option value="Mint">Mint</option>
|
||||||
|
<option value="Burn">Burn</option>
|
||||||
|
<option value="Licensing">Licensing</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="from_address" class="form-label">From Address</label>
|
||||||
|
<input type="text" class="form-control" id="from_address" name="from_address">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="to_address" class="form-label">To Address</label>
|
||||||
|
<input type="text" class="form-control" id="to_address" name="to_address">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="amount" class="form-label">Amount</label>
|
||||||
|
<input type="number" class="form-control" id="amount" name="amount" step="0.01">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transaction_currency" class="form-label">Currency</label>
|
||||||
|
<input type="text" class="form-control" id="transaction_currency" name="currency" value="USD">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transaction_hash" class="form-label">Transaction Hash</label>
|
||||||
|
<input type="text" class="form-control" id="transaction_hash" name="transaction_hash">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="transaction_notes" class="form-label">Notes</label>
|
||||||
|
<textarea class="form-control" id="transaction_notes" name="notes" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveTransactionBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Modal -->
|
||||||
|
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="statusModalLabel">Change Asset Status</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="statusForm" method="post" action="/assets/{{ asset.id }}/status/">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newStatus" class="form-label">New Status</label>
|
||||||
|
<select class="form-select" id="newStatus" name="status">
|
||||||
|
<option value="Active" {% if asset.status == "Active" %}selected{% endif %}>Active</option>
|
||||||
|
<option value="Locked" {% if asset.status == "Locked" %}selected{% endif %}>Locked</option>
|
||||||
|
<option value="ForSale" {% if asset.status == "For Sale" %}selected{% endif %}>For Sale</option>
|
||||||
|
<option value="Transferred" {% if asset.status == "Transferred" %}selected{% endif %}>Transferred</option>
|
||||||
|
<option value="Archived" {% if asset.status == "Archived" %}selected{% endif %}>Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveStatusBtn">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Valuation History Chart
|
||||||
|
{% if valuation_history and valuation_history|length > 0 %}
|
||||||
|
const ctx = document.getElementById('valuationChart');
|
||||||
|
|
||||||
|
const dates = [
|
||||||
|
{% for point in valuation_history %}
|
||||||
|
"{{ point.date }}"{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
{% for point in valuation_history %}
|
||||||
|
{{ point.value }}{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: dates,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Valuation ({{ valuation_history[0].currency }})',
|
||||||
|
data: values,
|
||||||
|
lineTension: 0.3,
|
||||||
|
backgroundColor: "rgba(78, 115, 223, 0.05)",
|
||||||
|
borderColor: "rgba(78, 115, 223, 1)",
|
||||||
|
pointRadius: 3,
|
||||||
|
pointBackgroundColor: "rgba(78, 115, 223, 1)",
|
||||||
|
pointBorderColor: "rgba(78, 115, 223, 1)",
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
pointHoverBackgroundColor: "rgba(78, 115, 223, 1)",
|
||||||
|
pointHoverBorderColor: "rgba(78, 115, 223, 1)",
|
||||||
|
pointHitRadius: 10,
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
fill: true
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
drawBorder: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: 5,
|
||||||
|
padding: 10,
|
||||||
|
callback: function(value, index, values) {
|
||||||
|
return '{{ valuation_history[0].currency }}' + value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: "rgb(234, 236, 244)",
|
||||||
|
zeroLineColor: "rgb(234, 236, 244)",
|
||||||
|
drawBorder: false,
|
||||||
|
borderDash: [2],
|
||||||
|
zeroLineBorderDash: [2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgb(255,255,255)",
|
||||||
|
bodyFontColor: "#858796",
|
||||||
|
titleMarginBottom: 10,
|
||||||
|
titleFontColor: '#6e707e',
|
||||||
|
titleFontSize: 14,
|
||||||
|
borderColor: '#dddfeb',
|
||||||
|
borderWidth: 1,
|
||||||
|
xPadding: 15,
|
||||||
|
yPadding: 15,
|
||||||
|
displayColors: false,
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index',
|
||||||
|
caretPadding: 10,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
var label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
label += '{{ valuation_history[0].currency }}' + context.parsed.y;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Form submission handlers
|
||||||
|
const saveValuationBtn = document.getElementById('saveValuationBtn');
|
||||||
|
if (saveValuationBtn) {
|
||||||
|
saveValuationBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('valuationForm').submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTransactionBtn = document.getElementById('saveTransactionBtn');
|
||||||
|
if (saveTransactionBtn) {
|
||||||
|
saveTransactionBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('transactionForm').submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveStatusBtn = document.getElementById('saveStatusBtn');
|
||||||
|
if (saveStatusBtn) {
|
||||||
|
saveStatusBtn.addEventListener('click', function() {
|
||||||
|
const form = document.getElementById('statusForm');
|
||||||
|
const newStatus = document.getElementById('newStatus').value;
|
||||||
|
form.action = form.action + newStatus;
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.asset-image {
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockchain-address {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockchain-address-small {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
197
actix_mvc_app/src/views/assets/index.html
Normal file
197
actix_mvc_app/src/views/assets/index.html
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Digital Assets Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<h1 class="mt-4">Digital Assets Dashboard</h1>
|
||||||
|
<ol class="breadcrumb mb-4">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Digital Assets</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-primary text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="display-4">{{ stats.total_assets }}</h2>
|
||||||
|
<p class="mb-0">Total Assets</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex align-items-center justify-content-between">
|
||||||
|
<a class="small text-white stretched-link" href="/assets/list">View All Assets</a>
|
||||||
|
<div class="small text-white"><i class="bi bi-arrow-right"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-success text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="display-4">${{ stats.total_value }}</h2>
|
||||||
|
<p class="mb-0">Total Valuation</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex align-items-center justify-content-between">
|
||||||
|
<a class="small text-white stretched-link" href="/assets/list">View Details</a>
|
||||||
|
<div class="small text-white"><i class="bi bi-arrow-right"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-warning text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="display-4">{{ stats.assets_by_status.Active }}</h2>
|
||||||
|
<p class="mb-0">Active Assets</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex align-items-center justify-content-between">
|
||||||
|
<a class="small text-white stretched-link" href="/assets/list">View Active Assets</a>
|
||||||
|
<div class="small text-white"><i class="bi bi-arrow-right"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-danger text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="display-4">0</h2>
|
||||||
|
<p class="mb-0">Pending Transactions</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex align-items-center justify-content-between">
|
||||||
|
<a class="small text-white stretched-link" href="/assets/list">View Transactions</a>
|
||||||
|
<div class="small text-white"><i class="bi bi-arrow-right"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Assets Table -->
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-table me-1"></i>
|
||||||
|
Recent Assets
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Valuation</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for asset in recent_assets %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if asset.asset_type == "Token" %}
|
||||||
|
<i class="bi bi-coin me-2 text-warning"></i>
|
||||||
|
{% elif asset.asset_type == "Artwork" %}
|
||||||
|
<i class="bi bi-image me-2 text-primary"></i>
|
||||||
|
{% elif asset.asset_type == "Real Estate" %}
|
||||||
|
<i class="bi bi-building me-2 text-success"></i>
|
||||||
|
{% elif asset.asset_type == "Intellectual Property" %}
|
||||||
|
<i class="bi bi-file-earmark-text me-2 text-info"></i>
|
||||||
|
{% elif asset.asset_type == "Share" %}
|
||||||
|
<i class="bi bi-graph-up me-2 text-danger"></i>
|
||||||
|
{% elif asset.asset_type == "Bond" %}
|
||||||
|
<i class="bi bi-cash-stack me-2 text-secondary"></i>
|
||||||
|
{% elif asset.asset_type == "Commodity" %}
|
||||||
|
<i class="bi bi-box me-2 text-dark"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-question-circle me-2"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ asset.name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ asset.asset_type }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if asset.status == 'Active' %}bg-success{% elif asset.status == 'Locked' %}bg-warning{% elif asset.status == 'For Sale' %}bg-info{% elif asset.status == 'Transferred' %}bg-secondary{% else %}bg-dark{% endif %}">
|
||||||
|
{{ asset.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if asset.current_valuation %}
|
||||||
|
${{ asset.current_valuation }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not valued</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/assets/{{ asset.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-eye"></i> View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<a href="/assets/list" class="btn btn-primary">View All Assets</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asset Types Distribution -->
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-pie-chart me-1"></i>
|
||||||
|
Asset Types Distribution
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Asset Type</th>
|
||||||
|
<th>Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for asset_type in assets_by_type %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if asset_type.type == "Token" %}
|
||||||
|
<i class="bi bi-coin me-2 text-warning"></i>
|
||||||
|
{% elif asset_type.type == "Artwork" %}
|
||||||
|
<i class="bi bi-image me-2 text-primary"></i>
|
||||||
|
{% elif asset_type.type == "Real Estate" %}
|
||||||
|
<i class="bi bi-building me-2 text-success"></i>
|
||||||
|
{% elif asset_type.type == "Intellectual Property" %}
|
||||||
|
<i class="bi bi-file-earmark-text me-2 text-info"></i>
|
||||||
|
{% elif asset_type.type == "Share" %}
|
||||||
|
<i class="bi bi-graph-up me-2 text-danger"></i>
|
||||||
|
{% elif asset_type.type == "Bond" %}
|
||||||
|
<i class="bi bi-cash-stack me-2 text-secondary"></i>
|
||||||
|
{% elif asset_type.type == "Commodity" %}
|
||||||
|
<i class="bi bi-box me-2 text-dark"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-question-circle me-2"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ asset_type.type }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ asset_type.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/static/js/defi.js"></script>
|
||||||
286
actix_mvc_app/src/views/assets/list.html
Normal file
286
actix_mvc_app/src/views/assets/list.html
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Digital Assets List{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<h1 class="mt-4">Digital Assets</h1>
|
||||||
|
<ol class="breadcrumb mb-4">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/assets">Digital Assets</a></li>
|
||||||
|
<li class="breadcrumb-item active">All Assets</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-filter me-1"></i>
|
||||||
|
Filter Assets
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="assetTypeFilter" class="form-label">Asset Type</label>
|
||||||
|
<select class="form-select" id="assetTypeFilter">
|
||||||
|
<option value="all">All Types</option>
|
||||||
|
<option value="Artwork">Artwork</option>
|
||||||
|
<option value="Token">Token</option>
|
||||||
|
<option value="RealEstate">Real Estate</option>
|
||||||
|
<option value="Commodity">Commodity</option>
|
||||||
|
<option value="Share">Share</option>
|
||||||
|
<option value="Bond">Bond</option>
|
||||||
|
<option value="IntellectualProperty">Intellectual Property</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="statusFilter" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="statusFilter">
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="Active">Active</option>
|
||||||
|
<option value="Locked">Locked</option>
|
||||||
|
<option value="ForSale">For Sale</option>
|
||||||
|
<option value="Transferred">Transferred</option>
|
||||||
|
<option value="Archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="valuationFilter" class="form-label">Valuation</label>
|
||||||
|
<select class="form-select" id="valuationFilter">
|
||||||
|
<option value="all">All Valuations</option>
|
||||||
|
<option value="under1000">Under $1,000</option>
|
||||||
|
<option value="1000to10000">$1,000 - $10,000</option>
|
||||||
|
<option value="10000to100000">$10,000 - $100,000</option>
|
||||||
|
<option value="over100000">Over $100,000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="searchInput" class="form-label">Search</label>
|
||||||
|
<input type="text" class="form-control" id="searchInput" placeholder="Search by name or description">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button id="applyFilters" class="btn btn-primary">Apply Filters</button>
|
||||||
|
<button id="resetFilters" class="btn btn-secondary">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assets Table -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i class="fas fa-table me-1"></i>
|
||||||
|
All Digital Assets
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/assets/create" class="btn btn-primary btn-sm">
|
||||||
|
<i class="fas fa-plus"></i> Create New Asset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover" id="assetsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Valuation</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for asset in assets %}
|
||||||
|
<tr class="asset-row"
|
||||||
|
data-type="{{ asset.asset_type }}"
|
||||||
|
data-status="{{ asset.status }}"
|
||||||
|
data-valuation="{% if asset.current_valuation %}{{ asset.current_valuation }}{% else %}0{% endif %}">
|
||||||
|
<td>
|
||||||
|
{% if asset.image_url %}
|
||||||
|
<img src="{{ asset.image_url }}" alt="{{ asset.name }}" class="asset-thumbnail me-2">
|
||||||
|
{% endif %}
|
||||||
|
{{ asset.name }}
|
||||||
|
</td>
|
||||||
|
<td>{{ asset.asset_type }}</td>
|
||||||
|
<td>
|
||||||
|
{% if asset.status == "Active" %}
|
||||||
|
<span class="badge bg-success">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "For Sale" %}
|
||||||
|
<span class="badge bg-warning">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Locked" %}
|
||||||
|
<span class="badge bg-secondary">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Transferred" %}
|
||||||
|
<span class="badge bg-info">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Archived" %}
|
||||||
|
<span class="badge bg-danger">{{ asset.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary">{{ asset.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ asset.owner_name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if asset.current_valuation %}
|
||||||
|
{{ asset.valuation_currency }}{{ asset.current_valuation }}
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ asset.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="/assets/{{ asset.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if asset.status == "Active" %}
|
||||||
|
<button type="button" class="btn btn-sm btn-warning" data-bs-toggle="modal" data-bs-target="#statusModal" data-asset-id="{{ asset.id }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Change Modal -->
|
||||||
|
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="statusModalLabel">Change Asset Status</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="statusForm" method="post" action="">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newStatus" class="form-label">New Status</label>
|
||||||
|
<select class="form-select" id="newStatus" name="status">
|
||||||
|
<option value="Active">Active</option>
|
||||||
|
<option value="Locked">Locked</option>
|
||||||
|
<option value="ForSale">For Sale</option>
|
||||||
|
<option value="Transferred">Transferred</option>
|
||||||
|
<option value="Archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveStatusBtn">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Status modal functionality
|
||||||
|
const statusModal = document.getElementById('statusModal');
|
||||||
|
if (statusModal) {
|
||||||
|
statusModal.addEventListener('show.bs.modal', function(event) {
|
||||||
|
const button = event.relatedTarget;
|
||||||
|
const assetId = button.getAttribute('data-asset-id');
|
||||||
|
const form = document.getElementById('statusForm');
|
||||||
|
form.action = `/assets/${assetId}/status/`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveStatusBtn = document.getElementById('saveStatusBtn');
|
||||||
|
saveStatusBtn.addEventListener('click', function() {
|
||||||
|
const form = document.getElementById('statusForm');
|
||||||
|
const newStatus = document.getElementById('newStatus').value;
|
||||||
|
form.action = form.action + newStatus;
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering functionality
|
||||||
|
const applyFilters = document.getElementById('applyFilters');
|
||||||
|
if (applyFilters) {
|
||||||
|
applyFilters.addEventListener('click', function() {
|
||||||
|
filterAssets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = document.getElementById('resetFilters');
|
||||||
|
if (resetFilters) {
|
||||||
|
resetFilters.addEventListener('click', function() {
|
||||||
|
document.getElementById('assetTypeFilter').value = 'all';
|
||||||
|
document.getElementById('statusFilter').value = 'all';
|
||||||
|
document.getElementById('valuationFilter').value = 'all';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
filterAssets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('keyup', function(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
filterAssets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAssets() {
|
||||||
|
const typeFilter = document.getElementById('assetTypeFilter').value;
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const valuationFilter = document.getElementById('valuationFilter').value;
|
||||||
|
const searchText = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('#assetsTable tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const type = row.getAttribute('data-type');
|
||||||
|
const status = row.getAttribute('data-status');
|
||||||
|
const valuation = parseFloat(row.getAttribute('data-valuation'));
|
||||||
|
const name = row.querySelector('td:first-child').textContent.toLowerCase();
|
||||||
|
|
||||||
|
let typeMatch = typeFilter === 'all' || type === typeFilter;
|
||||||
|
let statusMatch = statusFilter === 'all' || status === statusFilter;
|
||||||
|
let searchMatch = searchText === '' || name.includes(searchText);
|
||||||
|
|
||||||
|
let valuationMatch = true;
|
||||||
|
if (valuationFilter === 'under1000') {
|
||||||
|
valuationMatch = valuation < 1000;
|
||||||
|
} else if (valuationFilter === '1000to10000') {
|
||||||
|
valuationMatch = valuation >= 1000 && valuation < 10000;
|
||||||
|
} else if (valuationFilter === '10000to100000') {
|
||||||
|
valuationMatch = valuation >= 10000 && valuation < 100000;
|
||||||
|
} else if (valuationFilter === 'over100000') {
|
||||||
|
valuationMatch = valuation >= 100000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeMatch && statusMatch && valuationMatch && searchMatch) {
|
||||||
|
row.style.display = '';
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.asset-thumbnail {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
373
actix_mvc_app/src/views/assets/my_assets.html
Normal file
373
actix_mvc_app/src/views/assets/my_assets.html
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}My Digital Assets{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<h1 class="mt-4">My Digital Assets</h1>
|
||||||
|
<ol class="breadcrumb mb-4">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/assets">Digital Assets</a></li>
|
||||||
|
<li class="breadcrumb-item active">My Assets</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-primary text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="display-4">{{ assets | length }}</h2>
|
||||||
|
<p class="mb-0">Total Assets</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-success text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
{% set active_count = 0 %}
|
||||||
|
{% for asset in assets %}
|
||||||
|
{% if asset.status == "Active" %}
|
||||||
|
{% set active_count = active_count + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<h2 class="display-4">{{ active_count }}</h2>
|
||||||
|
<p class="mb-0">Active Assets</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-warning text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
{% set for_sale_count = 0 %}
|
||||||
|
{% for asset in assets %}
|
||||||
|
{% if asset.status == "For Sale" %}
|
||||||
|
{% set for_sale_count = for_sale_count + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<h2 class="display-4">{{ for_sale_count }}</h2>
|
||||||
|
<p class="mb-0">For Sale</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-3 col-md-6">
|
||||||
|
<div class="card bg-info text-white mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
{% set total_value = 0 %}
|
||||||
|
{% for asset in assets %}
|
||||||
|
{% if asset.current_valuation %}
|
||||||
|
{% set total_value = total_value + asset.current_valuation %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<h2 class="display-4">${% if total_value %}{{ total_value }}{% else %}0.00{% endif %}</h2>
|
||||||
|
<p class="mb-0">Total Value</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assets Table -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i class="fas fa-table me-1"></i>
|
||||||
|
My Digital Assets
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/assets/create" class="btn btn-primary btn-sm">
|
||||||
|
<i class="fas fa-plus"></i> Create New Asset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if assets and assets|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover" id="myAssetsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Valuation</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for asset in assets %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if asset.image_url %}
|
||||||
|
<img src="{{ asset.image_url }}" alt="{{ asset.name }}" class="asset-thumbnail me-2">
|
||||||
|
{% endif %}
|
||||||
|
{{ asset.name }}
|
||||||
|
</td>
|
||||||
|
<td>{{ asset.asset_type }}</td>
|
||||||
|
<td>
|
||||||
|
{% if asset.status == "Active" %}
|
||||||
|
<span class="badge bg-success">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "For Sale" %}
|
||||||
|
<span class="badge bg-warning">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Locked" %}
|
||||||
|
<span class="badge bg-secondary">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Transferred" %}
|
||||||
|
<span class="badge bg-info">{{ asset.status }}</span>
|
||||||
|
{% elif asset.status == "Archived" %}
|
||||||
|
<span class="badge bg-danger">{{ asset.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary">{{ asset.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if asset.current_valuation %}
|
||||||
|
{{ asset.valuation_currency }}{{ asset.current_valuation }}
|
||||||
|
{% else %}
|
||||||
|
N/A
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ asset.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="/assets/{{ asset.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-warning" data-bs-toggle="modal" data-bs-target="#statusModal" data-asset-id="{{ asset.id }}">
|
||||||
|
<i class="fas fa-exchange-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#valuationModal" data-asset-id="{{ asset.id }}">
|
||||||
|
<i class="fas fa-dollar-sign"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p>You don't have any digital assets yet.</p>
|
||||||
|
<a href="/assets/create" class="btn btn-primary">Create Your First Asset</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asset Types Distribution -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-chart-pie me-1"></i>
|
||||||
|
Asset Types Distribution
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="assetTypesChart" width="100%" height="40"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fas fa-chart-bar me-1"></i>
|
||||||
|
Asset Value Distribution
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="assetValueChart" width="100%" height="40"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Change Modal -->
|
||||||
|
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="statusModalLabel">Change Asset Status</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="statusForm" method="post" action="">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="newStatus" class="form-label">New Status</label>
|
||||||
|
<select class="form-select" id="newStatus" name="status">
|
||||||
|
<option value="Active">Active</option>
|
||||||
|
<option value="Locked">Locked</option>
|
||||||
|
<option value="ForSale">For Sale</option>
|
||||||
|
<option value="Transferred">Transferred</option>
|
||||||
|
<option value="Archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveStatusBtn">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Valuation Modal -->
|
||||||
|
<div class="modal fade" id="valuationModal" tabindex="-1" aria-labelledby="valuationModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="valuationModalLabel">Add Valuation</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="valuationForm" method="post" action="">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="value" class="form-label">Value</label>
|
||||||
|
<input type="number" class="form-control" id="value" name="value" step="0.01" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="currency" class="form-label">Currency</label>
|
||||||
|
<input type="text" class="form-control" id="currency" name="currency" value="USD" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="source" class="form-label">Source</label>
|
||||||
|
<input type="text" class="form-control" id="source" name="source" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="notes" class="form-label">Notes</label>
|
||||||
|
<textarea class="form-control" id="notes" name="notes" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveValuationBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Status modal functionality
|
||||||
|
const statusModal = document.getElementById('statusModal');
|
||||||
|
if (statusModal) {
|
||||||
|
statusModal.addEventListener('show.bs.modal', function(event) {
|
||||||
|
const button = event.relatedTarget;
|
||||||
|
const assetId = button.getAttribute('data-asset-id');
|
||||||
|
const form = document.getElementById('statusForm');
|
||||||
|
form.action = `/assets/${assetId}/status/`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveStatusBtn = document.getElementById('saveStatusBtn');
|
||||||
|
saveStatusBtn.addEventListener('click', function() {
|
||||||
|
const form = document.getElementById('statusForm');
|
||||||
|
const newStatus = document.getElementById('newStatus').value;
|
||||||
|
form.action = form.action + newStatus;
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valuation modal functionality
|
||||||
|
const valuationModal = document.getElementById('valuationModal');
|
||||||
|
if (valuationModal) {
|
||||||
|
valuationModal.addEventListener('show.bs.modal', function(event) {
|
||||||
|
const button = event.relatedTarget;
|
||||||
|
const assetId = button.getAttribute('data-asset-id');
|
||||||
|
const form = document.getElementById('valuationForm');
|
||||||
|
form.action = `/assets/${assetId}/valuation`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveValuationBtn = document.getElementById('saveValuationBtn');
|
||||||
|
saveValuationBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('valuationForm').submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset Types Chart
|
||||||
|
const assetTypesCtx = document.getElementById('assetTypesChart');
|
||||||
|
if (assetTypesCtx) {
|
||||||
|
// Count assets by type
|
||||||
|
const assetTypes = {};
|
||||||
|
{% for asset in assets %}
|
||||||
|
if (!assetTypes['{{ asset.asset_type }}']) {
|
||||||
|
assetTypes['{{ asset.asset_type }}'] = 0;
|
||||||
|
}
|
||||||
|
assetTypes['{{ asset.asset_type }}']++;
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
const typeLabels = Object.keys(assetTypes);
|
||||||
|
const typeCounts = Object.values(assetTypes);
|
||||||
|
|
||||||
|
new Chart(assetTypesCtx, {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
labels: typeLabels,
|
||||||
|
datasets: [{
|
||||||
|
data: typeCounts,
|
||||||
|
backgroundColor: [
|
||||||
|
'#4e73df', '#1cc88a', '#36b9cc', '#f6c23e',
|
||||||
|
'#e74a3b', '#858796', '#5a5c69', '#2c9faf'
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset Value Chart
|
||||||
|
const assetValueCtx = document.getElementById('assetValueChart');
|
||||||
|
if (assetValueCtx) {
|
||||||
|
// Prepare data for assets with valuation
|
||||||
|
const assetNames = [];
|
||||||
|
const assetValues = [];
|
||||||
|
|
||||||
|
{% for asset in assets %}
|
||||||
|
{% if asset.current_valuation %}
|
||||||
|
assetNames.push('{{ asset.name }}');
|
||||||
|
assetValues.push({{ asset.current_valuation }});
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
new Chart(assetValueCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: assetNames,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Asset Value ($)',
|
||||||
|
data: assetValues,
|
||||||
|
backgroundColor: '#4e73df',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.asset-thumbnail {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Login - Actix MVC App{% endblock %}
|
{% block title %}Login{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Register - Actix MVC App{% endblock %}
|
{% block title %}Register{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -3,48 +3,96 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Actix MVC App{% endblock %}</title>
|
<title>{% block title %}Zanzibar Digital Freezone{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/unpoly@3.7.2/unpoly.min.css">
|
<link rel="stylesheet" href="https://unpkg.com/unpoly@3.7.2/unpoly.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
/* Minimal custom CSS that can't be achieved with Bootstrap classes */
|
||||||
|
body {
|
||||||
|
padding-top: 50px; /* Height of the fixed header */
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
height: 50px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
position: fixed;
|
||||||
|
height: calc(100vh - 90px); /* Subtract header and footer height */
|
||||||
|
top: 50px; /* Position below header */
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
margin-left: 240px;
|
||||||
|
min-height: calc(100vh - 90px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
position: fixed;
|
||||||
|
height: calc(100vh - 90px);
|
||||||
|
top: 50px;
|
||||||
|
left: -240px;
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
z-index: 1020;
|
||||||
|
}
|
||||||
|
.sidebar.show {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
<!-- Header - Full Width -->
|
||||||
<div class="container">
|
<header class="header bg-dark text-white">
|
||||||
<a class="navbar-brand" href="/">Actix MVC App</a>
|
<div class="d-flex container-fluid justify-content-between align-items-center h-100">
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
<div class="align-items-center">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<button class="navbar-toggler d-md-none me-2" type="button" id="sidebarToggle" aria-label="Toggle navigation">
|
||||||
</button>
|
<i class="bi bi-list text-white"></i>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
</button>
|
||||||
<ul class="navbar-nav me-auto">
|
<h5 class="mb-0">Zanzibar Digital Freezone {% if entity_name %}| <span class="text-info">{{ entity_name }}</span>{% endif %}</h5>
|
||||||
<li class="nav-item">
|
</div>
|
||||||
<a class="nav-link {% if active_page == 'home' %}active{% endif %}" href="/">Home</a>
|
<div class="d-none d-md-flex">
|
||||||
|
<ul class="navbar-nav flex-row">
|
||||||
|
<li class="nav-item mx-3">
|
||||||
|
<a class="nav-link text-white {% if active_page == 'about' %}active{% endif %}" target="_blank" href="https://info.ourworld.tf/zdfz">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item mx-3">
|
||||||
<a class="nav-link {% if active_page == 'about' %}active{% endif %}" href="/about">About</a>
|
<a class="nav-link text-white {% if active_page == 'contact' %}active{% endif %}" href="/contact">
|
||||||
</li>
|
Contact
|
||||||
<li class="nav-item">
|
</a>
|
||||||
<a class="nav-link {% if active_page == 'contact' %}active{% endif %}" href="/contact">Contact</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_page == 'tickets' %}active{% endif %}" href="/tickets">Support Tickets</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_page == 'editor' %}active{% endif %}" href="/editor">Markdown Editor</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if active_page == 'calendar' %}active{% endif %}" href="/calendar">Calendar</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav ms-auto">
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul class="navbar-nav flex-row">
|
||||||
{% if user and user.id %}
|
{% if user and user.id %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<a class="nav-link dropdown-toggle text-white" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
{{ user.name }}
|
<i class="bi bi-person-circle"></i> {{ user.name }}
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
|
||||||
<li><a class="dropdown-item" href="/tickets/new">New Ticket</a></li>
|
<li><a class="dropdown-item" href="/tickets/new">New Ticket</a></li>
|
||||||
<li><a class="dropdown-item" href="/tickets/my">My Tickets</a></li>
|
<li><a class="dropdown-item" href="/my-tickets">My Tickets</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/assets/my">My Assets</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/marketplace/my">My Listings</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/governance/my-votes">My Votes</a></li>
|
||||||
{% if user.role == "Admin" %}
|
{% if user.role == "Admin" %}
|
||||||
<li><a class="dropdown-item" href="/admin">Admin Panel</a></li>
|
<li><a class="dropdown-item" href="/admin">Admin Panel</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -53,39 +101,163 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item">
|
<li class="nav-item me-2">
|
||||||
<a class="nav-link {% if active_page == 'login' %}active{% endif %}" href="/login">Login</a>
|
<a class="nav-link text-white {% if active_page == 'login' %}active{% endif %}" href="/login">Login</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if active_page == 'register' %}active{% endif %}" href="/register">Register</a>
|
<a class="nav-link text-white {% if active_page == 'register' %}active{% endif %}" href="/register">Register</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</header>
|
||||||
|
|
||||||
<main class="container py-4">
|
<div class="d-flex flex-column min-vh-100">
|
||||||
{% block content %}{% endblock %}
|
<!-- Sidebar -->
|
||||||
</main>
|
<div class="sidebar bg-light shadow-sm border-end d-flex" id="sidebar">
|
||||||
|
<div class="py-2">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'home' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/">
|
||||||
|
<i class="bi bi-house-door me-2"></i> Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Support Tickets link hidden
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'tickets' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/tickets">
|
||||||
|
<i class="bi bi-ticket-perforated me-2"></i> Support Tickets
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
-->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'governance' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/governance">
|
||||||
|
<i class="bi bi-people me-2"></i> Governance
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'flows' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/flows">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i> Flows
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'contracts' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/contracts">
|
||||||
|
<i class="bi bi-file-earmark-text me-2"></i> Contracts
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'assets' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/assets">
|
||||||
|
<i class="bi bi-coin me-2"></i> Digital Assets
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'defi' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/defi">
|
||||||
|
<i class="bi bi-bank me-2"></i> DeFi Platform
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'company' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/company">
|
||||||
|
<i class="bi bi-building me-2"></i> Companies
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'marketplace' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/marketplace">
|
||||||
|
<i class="bi bi-shop me-2"></i> Marketplace
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- Markdown Editor link hidden
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'editor' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/editor">
|
||||||
|
<i class="bi bi-markdown me-2"></i> Markdown Editor
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
-->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center ps-3 py-2 {% if active_page == 'calendar' %}active fw-bold border-start border-4 border-primary bg-light{% endif %}" href="/calendar">
|
||||||
|
<i class="bi bi-calendar3 me-2"></i> Calendar
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="bg-dark text-white py-4 mt-5">
|
<!-- Main Content -->
|
||||||
<div class="container">
|
<div class="main-content flex-grow-1">
|
||||||
<div class="row">
|
<!-- Page Content -->
|
||||||
<div class="col-md-6">
|
<main class="py-3 w-100 d-block">
|
||||||
<h5>Actix MVC App</h5>
|
<div class="container-fluid">
|
||||||
<p>A Rust web application using Actix Web, Tera templates, and Bootstrap.</p>
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-md-end">
|
</main>
|
||||||
<p>© {{ now(year=true) }} Actix MVC App. All rights reserved.</p>
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer - Full Width -->
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast container for notifications -->
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
{% if success %}
|
||||||
|
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="toast-header bg-success text-white">
|
||||||
|
<strong class="me-auto">Success</strong>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="toast-header bg-danger text-white">
|
||||||
|
<strong class="me-auto">Error</strong>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="https://unpkg.com/unpoly@3.7.2/unpoly.min.js"></script>
|
<script src="https://unpkg.com/unpoly@3.7.2/unpoly.min.js"></script>
|
||||||
<script src="https://unpkg.com/unpoly@3.7.2/unpoly-bootstrap5.min.js"></script>
|
<script src="https://unpkg.com/unpoly@3.7.2/unpoly-bootstrap5.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Toggle sidebar on mobile
|
||||||
|
document.getElementById('sidebarToggle').addEventListener('click', function() {
|
||||||
|
document.getElementById('sidebar').classList.toggle('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-hide toasts after 5 seconds
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const toasts = document.querySelectorAll('.toast.show');
|
||||||
|
toasts.forEach(toast => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const bsToast = new bootstrap.Toast(toast);
|
||||||
|
bsToast.hide();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}New Calendar Event{% endblock %}
|
{% block title %}New Calendar Event{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container-fluid">
|
||||||
<h1>Create New Event</h1>
|
<h1>Create New Event</h1>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form action="/calendar/new" method="post">
|
<form action="/calendar/events" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="title" class="form-label">Event Title</label>
|
<label for="title" class="form-label">Event Title</label>
|
||||||
<input type="text" class="form-control" id="title" name="title" required>
|
<input type="text" class="form-control" id="title" name="title" required>
|
||||||
@@ -39,6 +39,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Show selected date info when coming from calendar date click -->
|
||||||
|
<div id="selected-date-info" class="alert alert-info" style="display: none;">
|
||||||
|
<strong>Selected Date:</strong> <span id="selected-date-display"></span>
|
||||||
|
<br>
|
||||||
|
<small>The date is pre-selected. You can only modify the time portion.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="color" class="form-label">Event Color</label>
|
<label for="color" class="form-label">Event Color</label>
|
||||||
<select class="form-control" id="color" name="color">
|
<select class="form-control" id="color" name="color">
|
||||||
@@ -59,14 +66,83 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Check if we came from a date click (URL parameter)
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const selectedDate = urlParams.get('date');
|
||||||
|
|
||||||
|
if (selectedDate) {
|
||||||
|
// Show the selected date info
|
||||||
|
document.getElementById('selected-date-info').style.display = 'block';
|
||||||
|
document.getElementById('selected-date-display').textContent = new Date(selectedDate).toLocaleDateString();
|
||||||
|
|
||||||
|
// Pre-fill the date portion and restrict date changes
|
||||||
|
const startTimeInput = document.getElementById('start_time');
|
||||||
|
const endTimeInput = document.getElementById('end_time');
|
||||||
|
|
||||||
|
// Set default times (9 AM to 10 AM on the selected date)
|
||||||
|
const startDateTime = new Date(selectedDate + 'T09:00');
|
||||||
|
const endDateTime = new Date(selectedDate + 'T10:00');
|
||||||
|
|
||||||
|
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
|
||||||
|
startTimeInput.value = startDateTime.toISOString().slice(0, 16);
|
||||||
|
endTimeInput.value = endDateTime.toISOString().slice(0, 16);
|
||||||
|
|
||||||
|
// Set minimum and maximum date to the selected date to prevent changing the date
|
||||||
|
const minDate = selectedDate + 'T00:00';
|
||||||
|
const maxDate = selectedDate + 'T23:59';
|
||||||
|
startTimeInput.min = minDate;
|
||||||
|
startTimeInput.max = maxDate;
|
||||||
|
endTimeInput.min = minDate;
|
||||||
|
endTimeInput.max = maxDate;
|
||||||
|
|
||||||
|
// Add event listeners to ensure end time is after start time
|
||||||
|
startTimeInput.addEventListener('change', function () {
|
||||||
|
const startTime = new Date(this.value);
|
||||||
|
const endTime = new Date(endTimeInput.value);
|
||||||
|
|
||||||
|
if (endTime <= startTime) {
|
||||||
|
// Set end time to 1 hour after start time
|
||||||
|
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
|
||||||
|
endTimeInput.value = newEndTime.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update end time minimum to be after start time
|
||||||
|
endTimeInput.min = this.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
endTimeInput.addEventListener('change', function () {
|
||||||
|
const startTime = new Date(startTimeInput.value);
|
||||||
|
const endTime = new Date(this.value);
|
||||||
|
|
||||||
|
if (endTime <= startTime) {
|
||||||
|
// Reset to 1 hour after start time
|
||||||
|
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
|
||||||
|
this.value = newEndTime.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No date selected, set default to current time
|
||||||
|
const now = new Date();
|
||||||
|
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
|
||||||
|
|
||||||
|
document.getElementById('start_time').value = now.toISOString().slice(0, 16);
|
||||||
|
document.getElementById('end_time').value = oneHourLater.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
// Convert datetime-local inputs to RFC3339 format on form submission
|
// Convert datetime-local inputs to RFC3339 format on form submission
|
||||||
document.querySelector('form').addEventListener('submit', function(e) {
|
document.querySelector('form').addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const startTime = document.getElementById('start_time').value;
|
const startTime = document.getElementById('start_time').value;
|
||||||
const endTime = document.getElementById('end_time').value;
|
const endTime = document.getElementById('end_time').value;
|
||||||
|
|
||||||
|
// Validate that end time is after start time
|
||||||
|
if (new Date(endTime) <= new Date(startTime)) {
|
||||||
|
alert('End time must be after start time');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to RFC3339 format
|
// Convert to RFC3339 format
|
||||||
const startRFC = new Date(startTime).toISOString();
|
const startRFC = new Date(startTime).toISOString();
|
||||||
const endRFC = new Date(endTime).toISOString();
|
const endRFC = new Date(endTime).toISOString();
|
||||||
|
|||||||
417
actix_mvc_app/src/views/company/documents.html
Normal file
417
actix_mvc_app/src/views/company/documents.html
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ company.name }} - Document Management{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
.document-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.dragover {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-folder me-2"></i>{{ company.name }} - Documents</h2>
|
||||||
|
<div>
|
||||||
|
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-outline-secondary me-2">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Company
|
||||||
|
</a>
|
||||||
|
<a href="/company" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-building me-1"></i>All Companies
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Document Statistics -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="bi bi-files text-primary" style="font-size: 2rem;"></i>
|
||||||
|
<h4 class="mt-2">{{ stats.total_documents }}</h4>
|
||||||
|
<p class="text-muted mb-0">Total Documents</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="bi bi-hdd text-info" style="font-size: 2rem;"></i>
|
||||||
|
<h4 class="mt-2">{{ stats.formatted_total_size }}</h4>
|
||||||
|
<p class="text-muted mb-0">Total Size</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="bi bi-upload text-success" style="font-size: 2rem;"></i>
|
||||||
|
<h4 class="mt-2">{{ stats.recent_uploads }}</h4>
|
||||||
|
<p class="text-muted mb-0">Recent Uploads</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="bi bi-folder-plus text-warning" style="font-size: 2rem;"></i>
|
||||||
|
<h4 class="mt-2">{{ stats.by_type | length }}</h4>
|
||||||
|
<p class="text-muted mb-0">Document Types</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document Upload Section -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-cloud-upload me-2"></i>Upload Documents</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/company/documents/{{ company_id }}/upload" method="post" enctype="multipart/form-data"
|
||||||
|
id="uploadForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="document_type" class="form-label">Document Type</label>
|
||||||
|
<select class="form-select" id="document_type" name="document_type" required>
|
||||||
|
<option value="">Select document type...</option>
|
||||||
|
{% for doc_type in document_types %}
|
||||||
|
<option value="{{ doc_type.0 }}">{{ doc_type.1 }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">Description (Optional)</label>
|
||||||
|
<textarea class="form-control" id="description" name="description" rows="3"
|
||||||
|
placeholder="Enter document description..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_public" name="is_public">
|
||||||
|
<label class="form-check-label" for="is_public">
|
||||||
|
Make document publicly accessible
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="documents" class="form-label">Select Files</label>
|
||||||
|
<div class="upload-area" id="uploadArea">
|
||||||
|
<i class="bi bi-cloud-upload file-icon text-muted"></i>
|
||||||
|
<p class="mb-2">Drag and drop files here or click to browse</p>
|
||||||
|
<input type="file" class="form-control" id="documents" name="documents" multiple
|
||||||
|
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.txt" style="display: none;">
|
||||||
|
<button type="button" class="btn btn-outline-primary"
|
||||||
|
onclick="document.getElementById('documents').click()">
|
||||||
|
<i class="bi bi-folder2-open me-1"></i>Browse Files
|
||||||
|
</button>
|
||||||
|
<div id="fileList" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary" id="uploadBtn">
|
||||||
|
<i class="bi bi-upload me-1"></i>Upload Documents
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-files me-2"></i>Documents ({{ documents | length }})</h5>
|
||||||
|
<div class="input-group" style="width: 300px;">
|
||||||
|
<input type="text" class="form-control" id="searchInput" placeholder="Search documents...">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="searchBtn">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if documents and documents | length > 0 %}
|
||||||
|
<div class="row" id="documentsGrid">
|
||||||
|
{% for document in documents %}
|
||||||
|
<div class="col-md-4 mb-3 document-item" data-name="{{ document.name | lower }}"
|
||||||
|
data-type="{{ document.document_type_str | lower }}">
|
||||||
|
<div class="card document-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div class="file-icon">
|
||||||
|
{% if document.is_pdf %}
|
||||||
|
<i class="bi bi-file-earmark-pdf text-danger"></i>
|
||||||
|
{% elif document.is_image %}
|
||||||
|
<i class="bi bi-file-earmark-image text-success"></i>
|
||||||
|
{% elif document.mime_type == "application/msword" %}
|
||||||
|
<i class="bi bi-file-earmark-word text-primary"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-file-earmark text-secondary"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
|
||||||
|
data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-three-dots"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
onclick="downloadDocument({{ document.id }})">
|
||||||
|
<i class="bi bi-download me-1"></i>Download
|
||||||
|
</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" onclick="editDocument({{ document.id }})">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</a></li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item text-danger" href="#"
|
||||||
|
onclick="deleteDocument({{ document.id }}, '{{ document.name }}')">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h6 class="card-title text-truncate" title="{{ document.name }}">{{ document.name }}</h6>
|
||||||
|
<p class="card-text">
|
||||||
|
<small class="text-muted">
|
||||||
|
<span class="badge bg-secondary mb-1">{{ document.document_type_str }}</span><br>
|
||||||
|
Size: {{ document.formatted_file_size }}<br>
|
||||||
|
Uploaded: {{ document.formatted_upload_date }}<br>
|
||||||
|
By: {{ document.uploaded_by }}
|
||||||
|
{% if document.is_public %}
|
||||||
|
<br><span class="badge bg-success">Public</span>
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
{% if document.description %}
|
||||||
|
<p class="card-text"><small>{{ document.description }}</small></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-folder-x text-muted" style="font-size: 4rem;"></i>
|
||||||
|
<h4 class="text-muted mt-3">No Documents Found</h4>
|
||||||
|
<p class="text-muted">Upload your first document using the form above.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirm Delete</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete the document "<span id="deleteDocumentName"></span>"?</p>
|
||||||
|
<p class="text-danger"><small>This action cannot be undone.</small></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<a href="#" class="btn btn-danger" id="confirmDeleteBtn">Delete Document</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const uploadArea = document.getElementById('uploadArea');
|
||||||
|
const fileInput = document.getElementById('documents');
|
||||||
|
const fileList = document.getElementById('fileList');
|
||||||
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
|
||||||
|
// File upload handling
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
console.log('Files selected:', this.files.length);
|
||||||
|
updateFileList();
|
||||||
|
updateUploadButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
uploadArea.addEventListener('dragover', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
console.log('Files dropped:', files.length);
|
||||||
|
|
||||||
|
// Create a new DataTransfer object and assign to input
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
dt.items.add(files[i]);
|
||||||
|
}
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
|
||||||
|
updateFileList();
|
||||||
|
updateUploadButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click to upload area
|
||||||
|
uploadArea.addEventListener('click', function (e) {
|
||||||
|
if (e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT') {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
const searchTerm = this.value.toLowerCase();
|
||||||
|
const documentItems = document.querySelectorAll('.document-item');
|
||||||
|
|
||||||
|
documentItems.forEach(function (item) {
|
||||||
|
const name = item.dataset.name;
|
||||||
|
const type = item.dataset.type;
|
||||||
|
const matches = name.includes(searchTerm) || type.includes(searchTerm);
|
||||||
|
item.style.display = matches ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateFileList() {
|
||||||
|
const files = Array.from(fileInput.files);
|
||||||
|
if (files.length === 0) {
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listHtml = files.map(file =>
|
||||||
|
`<div class="d-flex justify-content-between align-items-center p-2 border rounded mb-1">
|
||||||
|
<span class="text-truncate">${file.name}</span>
|
||||||
|
<small class="text-muted">${formatFileSize(file.size)}</small>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
fileList.innerHTML = `<div class="mt-2"><strong>Selected files:</strong>${listHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadButton() {
|
||||||
|
const hasFiles = fileInput.files.length > 0;
|
||||||
|
const hasDocumentType = document.getElementById('document_type').value !== '';
|
||||||
|
uploadBtn.disabled = !hasFiles || !hasDocumentType;
|
||||||
|
|
||||||
|
console.log('Update upload button - Files:', hasFiles, 'DocType:', hasDocumentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update button when document type changes
|
||||||
|
document.getElementById('document_type').addEventListener('change', function () {
|
||||||
|
updateUploadButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add form submission debugging
|
||||||
|
document.getElementById('uploadForm').addEventListener('submit', function (e) {
|
||||||
|
console.log('Form submitted');
|
||||||
|
console.log('Files:', fileInput.files.length);
|
||||||
|
console.log('Document type:', document.getElementById('document_type').value);
|
||||||
|
|
||||||
|
if (fileInput.files.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please select at least one file to upload.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById('document_type').value === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please select a document type.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteDocument(documentId, documentName) {
|
||||||
|
document.getElementById('deleteDocumentName').textContent = documentName;
|
||||||
|
document.getElementById('confirmDeleteBtn').href = `/company/documents/{{ company_id }}/delete/${documentId}`;
|
||||||
|
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadDocument(documentId) {
|
||||||
|
// TODO: Implement download functionality
|
||||||
|
alert('Download functionality will be implemented soon');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editDocument(documentId) {
|
||||||
|
// TODO: Implement edit functionality
|
||||||
|
alert('Edit functionality will be implemented soon');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
249
actix_mvc_app/src/views/company/edit.html
Normal file
249
actix_mvc_app/src/views/company/edit.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edit {{ company.name }} - Company Management{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-pencil-square me-2"></i>Edit Company</h2>
|
||||||
|
<div>
|
||||||
|
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-outline-secondary me-2">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Company
|
||||||
|
</a>
|
||||||
|
<a href="/company" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-building me-1"></i>All Companies
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Edit Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-building me-2"></i>Company Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/company/edit/{{ company.base_data.id }}" method="post" id="editCompanyForm">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-3">Basic Information</h6>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="company_name" class="form-label">Company Name <span
|
||||||
|
class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="company_name" name="company_name"
|
||||||
|
value="{{ company.name }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="company_type" class="form-label">Company Type <span
|
||||||
|
class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" id="company_type" name="company_type" required>
|
||||||
|
<option value="Startup FZC" {% if company.business_type=="Starter" %}selected{% endif
|
||||||
|
%}>Startup FZC</option>
|
||||||
|
<option value="Growth FZC" {% if company.business_type=="Global" %}selected{% endif %}>
|
||||||
|
Growth FZC</option>
|
||||||
|
<option value="Cooperative FZC" {% if company.business_type=="Coop" %}selected{% endif
|
||||||
|
%}>Cooperative FZC</option>
|
||||||
|
<option value="Single FZC" {% if company.business_type=="Single" %}selected{% endif %}>
|
||||||
|
Single FZC</option>
|
||||||
|
<option value="Twin FZC" {% if company.business_type=="Twin" %}selected{% endif %}>Twin
|
||||||
|
FZC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="status" name="status">
|
||||||
|
<option value="Active" {% if company.status=="Active" %}selected{% endif %}>Active
|
||||||
|
</option>
|
||||||
|
<option value="Inactive" {% if company.status=="Inactive" %}selected{% endif %}>Inactive
|
||||||
|
</option>
|
||||||
|
<option value="Suspended" {% if company.status=="Suspended" %}selected{% endif %}>
|
||||||
|
Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="industry" class="form-label">Industry</label>
|
||||||
|
<input type="text" class="form-control" id="industry" name="industry"
|
||||||
|
value="{{ company.industry | default(value='') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="fiscal_year_end" class="form-label">Fiscal Year End</label>
|
||||||
|
<input type="text" class="form-control" id="fiscal_year_end" name="fiscal_year_end"
|
||||||
|
value="{{ company.fiscal_year_end | default(value='') }}"
|
||||||
|
placeholder="MM-DD (e.g., 12-31)" pattern="^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"
|
||||||
|
title="Enter date in MM-DD format (e.g., 12-31)">
|
||||||
|
<div class="form-text">Enter the last day of your company's fiscal year (MM-DD format)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-3">Contact Information</h6>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
|
value="{{ company.email | default(value='') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phone" class="form-label">Phone</label>
|
||||||
|
<input type="tel" class="form-control" id="phone" name="phone"
|
||||||
|
value="{{ company.phone | default(value='') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="website" class="form-label">Website</label>
|
||||||
|
<input type="url" class="form-control" id="website" name="website"
|
||||||
|
value="{{ company.website | default(value='') }}" placeholder="https://example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="address" class="form-label">Address</label>
|
||||||
|
<textarea class="form-control" id="address" name="address"
|
||||||
|
rows="3">{{ company.address | default(value='') }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="text-muted mb-3">Additional Information</h6>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">Company Description</label>
|
||||||
|
<textarea class="form-control" id="description" name="description" rows="4"
|
||||||
|
placeholder="Describe the company's purpose and activities">{{ company.description | default(value='') }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Read-only Information -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="text-muted mb-3">Registration Information (Read-only)</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Registration Number</label>
|
||||||
|
<input type="text" class="form-control" value="{{ company.registration_number }}"
|
||||||
|
readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Incorporation Date</label>
|
||||||
|
<input type="text" class="form-control" value="{{ incorporation_date_formatted }}"
|
||||||
|
readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Company ID</label>
|
||||||
|
<input type="text" class="form-control" value="{{ company.base_data.id }}" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Update Company
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Form validation
|
||||||
|
const form = document.getElementById('editCompanyForm');
|
||||||
|
const companyName = document.getElementById('company_name');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (companyName.value.trim() === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
showValidationAlert('Company name is required', companyName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to show validation alert with consistent styling
|
||||||
|
function showValidationAlert(message, focusElement) {
|
||||||
|
// Remove existing alerts
|
||||||
|
const existingAlerts = document.querySelectorAll('.validation-alert');
|
||||||
|
existingAlerts.forEach(alert => alert.remove());
|
||||||
|
|
||||||
|
// Create new alert
|
||||||
|
const alertDiv = document.createElement('div');
|
||||||
|
alertDiv.className = 'alert alert-warning alert-dismissible fade show validation-alert mt-3';
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Insert alert at the top of the form
|
||||||
|
const form = document.getElementById('editCompanyForm');
|
||||||
|
form.insertBefore(alertDiv, form.firstChild);
|
||||||
|
|
||||||
|
// Focus on the problematic field
|
||||||
|
if (focusElement) {
|
||||||
|
focusElement.focus();
|
||||||
|
focusElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alertDiv.parentNode) {
|
||||||
|
alertDiv.remove();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-format website URL
|
||||||
|
const websiteInput = document.getElementById('website');
|
||||||
|
websiteInput.addEventListener('blur', function () {
|
||||||
|
let value = this.value.trim();
|
||||||
|
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
||||||
|
this.value = 'https://' + value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
111
actix_mvc_app/src/views/company/index.html
Normal file
111
actix_mvc_app/src/views/company/index.html
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Company Management{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Toast notification for success messages -->
|
||||||
|
{% if success_message %}
|
||||||
|
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||||
|
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="true" data-bs-delay="5000">
|
||||||
|
<div class="toast-header bg-success text-white">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>
|
||||||
|
<strong class="me-auto">Success</strong>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
{{ success_message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<h2 class="mb-4">Company & Legal Entity Management (Freezone)</h2>
|
||||||
|
|
||||||
|
<!-- Company Management Tabs -->
|
||||||
|
<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="nav-link active" id="manage-tab" data-bs-toggle="tab" data-bs-target="#manage" type="button" role="tab" aria-controls="manage" aria-selected="true">
|
||||||
|
<i class="bi bi-building me-1"></i> Manage Companies
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="register-tab" data-bs-toggle="tab" data-bs-target="#register" type="button" role="tab" aria-controls="register" aria-selected="false">
|
||||||
|
<i class="bi bi-file-earmark-plus me-1"></i> Register New Company
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content mt-3" id="companyTabsContent">
|
||||||
|
<div class="tab-pane fade show active" id="manage" role="tabpanel" aria-labelledby="manage-tab">
|
||||||
|
{% include "company/manage.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="register" role="tabpanel" aria-labelledby="register-tab">
|
||||||
|
{% include "company/register.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="/static/js/company.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Show toast if success message exists
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const successMessage = urlParams.get('success');
|
||||||
|
if (successMessage) {
|
||||||
|
const toastEl = document.querySelector('.toast');
|
||||||
|
if (toastEl) {
|
||||||
|
const toastBody = toastEl.querySelector('.toast-body');
|
||||||
|
toastBody.textContent = decodeURIComponent(successMessage);
|
||||||
|
const toast = new bootstrap.Toast(toastEl);
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
// Auto-hide after 5 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
toast.hide();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab tracking in URL
|
||||||
|
const tabParam = urlParams.get('tab');
|
||||||
|
if (tabParam) {
|
||||||
|
const tabButton = document.querySelector(`button[data-bs-target="#${tabParam}"]`);
|
||||||
|
if (tabButton) {
|
||||||
|
const tab = new bootstrap.Tab(tabButton);
|
||||||
|
tab.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL when tab changes
|
||||||
|
const tabButtons = document.querySelectorAll('button[data-bs-toggle="tab"]');
|
||||||
|
tabButtons.forEach(function(button) {
|
||||||
|
button.addEventListener('shown.bs.tab', function(event) {
|
||||||
|
const targetId = event.target.getAttribute('data-bs-target').substring(1);
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('tab', targetId);
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
210
actix_mvc_app/src/views/company/manage.html
Normal file
210
actix_mvc_app/src/views/company/manage.html
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<i class="bi bi-building me-1"></i> Your Companies
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Company list table -->
|
||||||
|
<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>
|
||||||
|
{% if companies and companies|length > 0 %}
|
||||||
|
{% for company in companies %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ company.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if company.business_type == "Starter" %}Startup FZC
|
||||||
|
{% elif company.business_type == "Global" %}Growth FZC
|
||||||
|
{% elif company.business_type == "Coop" %}Cooperative FZC
|
||||||
|
{% elif company.business_type == "Single" %}Single FZC
|
||||||
|
{% elif company.business_type == "Twin" %}Twin FZC
|
||||||
|
{% else %}{{ company.business_type }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if company.status == "Active" %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% elif company.status == "Inactive" %}
|
||||||
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
|
{% elif company.status == "Suspended" %}
|
||||||
|
<span class="badge bg-warning text-dark">Suspended</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ company.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ company.incorporation_date | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/company/view/{{ company.base_data.id }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View
|
||||||
|
</a>
|
||||||
|
<a href="/company/switch/{{ company.base_data.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> Switch to Entity
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-4">
|
||||||
|
<div class="text-muted">
|
||||||
|
<i class="bi bi-building display-4 mb-3"></i>
|
||||||
|
<h5>No Companies Found</h5>
|
||||||
|
<p>You haven't registered any companies yet. Get started by registering your first company.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary" onclick="document.querySelector('#register-tab').click()">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Register Your First Company
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Details Modal -->
|
||||||
|
<div class="modal fade" id="companyDetailsModal" tabindex="-1" aria-labelledby="companyDetailsModalLabel"
|
||||||
|
aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-light">
|
||||||
|
<h5 class="modal-title" id="companyDetailsModalLabel"><i class="bi bi-building me-2"></i>Company Details
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="companyDetailsContent">
|
||||||
|
<!-- Company details will be loaded here -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">General Information</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<th>Company Name:</th>
|
||||||
|
<td id="modal-company-name">Zanzibar Digital Solutions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Type:</th>
|
||||||
|
<td id="modal-company-type">Startup FZC</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Registration Date:</th>
|
||||||
|
<td id="modal-registration-date">2025-04-01</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td id="modal-status"><span class="badge bg-success">Active</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Purpose:</th>
|
||||||
|
<td id="modal-purpose">Digital solutions and blockchain development</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">Billing Information</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<th>Plan:</th>
|
||||||
|
<td id="modal-plan">Startup FZC - $50/month</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Next Billing:</th>
|
||||||
|
<td id="modal-next-billing">2025-06-01</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Payment Method:</th>
|
||||||
|
<td id="modal-payment-method">Credit Card (****4582)</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">Shareholders</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Percentage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="modal-shareholders">
|
||||||
|
<tr>
|
||||||
|
<td>John Smith</td>
|
||||||
|
<td>60%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sarah Johnson</td>
|
||||||
|
<td>40%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">Contracts</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Contract</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="modal-contracts">
|
||||||
|
<tr>
|
||||||
|
<td>Articles of Incorporation</td>
|
||||||
|
<td><span class="badge bg-success">Signed</span></td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-primary">View</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Terms & Conditions</td>
|
||||||
|
<td><span class="badge bg-success">Signed</span></td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-primary">View</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Digital Asset Issuance</td>
|
||||||
|
<td><span class="badge bg-success">Signed</span></td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-primary">View</button></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="switchToEntityFromModal()"><i
|
||||||
|
class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
78
actix_mvc_app/src/views/company/payment_error.html
Normal file
78
actix_mvc_app/src/views/company/payment_error.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Payment Error - Company Registration{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-header bg-danger text-white text-center">
|
||||||
|
<h3 class="mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
Payment Error
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="bi bi-x-circle text-danger" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="text-danger mb-3">Payment Processing Failed</h4>
|
||||||
|
|
||||||
|
<p class="lead mb-4">
|
||||||
|
We encountered an issue processing your payment. Your company registration could not be completed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<h6><i class="bi bi-exclamation-circle me-2"></i>Error Details</h6>
|
||||||
|
<p class="mb-0">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6><i class="bi bi-info-circle me-2"></i>What You Can Do</h6>
|
||||||
|
<ul class="list-unstyled mb-0 text-start">
|
||||||
|
<li><i class="bi bi-arrow-right me-2"></i>Check your payment method details</li>
|
||||||
|
<li><i class="bi bi-arrow-right me-2"></i>Ensure you have sufficient funds</li>
|
||||||
|
<li><i class="bi bi-arrow-right me-2"></i>Try a different payment method</li>
|
||||||
|
<li><i class="bi bi-arrow-right me-2"></i>Contact your bank if the issue persists</li>
|
||||||
|
<li><i class="bi bi-arrow-right me-2"></i>Contact our support team for assistance</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||||
|
<a href="/company?tab=register" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-arrow-clockwise me-2"></i>Try Again
|
||||||
|
</a>
|
||||||
|
<a href="/contact" class="btn btn-outline-primary btn-lg">
|
||||||
|
<i class="bi bi-envelope me-2"></i>Contact Support
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted text-center">
|
||||||
|
<small>
|
||||||
|
<i class="bi bi-shield-check me-1"></i>
|
||||||
|
No charges were made to your account
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
border-left: 4px solid #0dcaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
89
actix_mvc_app/src/views/company/payment_success.html
Normal file
89
actix_mvc_app/src/views/company/payment_success.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Payment Successful - Company Registration{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-header bg-success text-white text-center">
|
||||||
|
<h3 class="mb-0">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
Payment Successful!
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="bi bi-check-circle text-success" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="text-success mb-3">Company Registration Complete</h4>
|
||||||
|
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Congratulations! Your payment has been processed successfully and your company has been registered.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Company ID</h6>
|
||||||
|
<p class="card-text h5 text-primary">{{ company_id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Payment ID</h6>
|
||||||
|
<p class="card-text h6 text-muted">{{ payment_intent_id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6><i class="bi bi-info-circle me-2"></i>What's Next?</h6>
|
||||||
|
<ul class="list-unstyled mb-0 text-start">
|
||||||
|
<li><i class="bi bi-check me-2"></i>You will receive a confirmation email shortly</li>
|
||||||
|
<li><i class="bi bi-check me-2"></i>Your company documents will be prepared within 24 hours</li>
|
||||||
|
<li><i class="bi bi-check me-2"></i>You can now access your company dashboard</li>
|
||||||
|
<li><i class="bi bi-check me-2"></i>Your subscription billing will begin next month</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||||
|
<a href="/company" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-building me-2"></i>Go to Company Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/company/view/{{ company_id }}" class="btn btn-outline-primary btn-lg">
|
||||||
|
<i class="bi bi-eye me-2"></i>View Company Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted text-center">
|
||||||
|
<small>
|
||||||
|
<i class="bi bi-shield-check me-1"></i>
|
||||||
|
Your payment was processed securely by Stripe
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
border-left: 4px solid #0dcaf0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
3332
actix_mvc_app/src/views/company/register.html
Normal file
3332
actix_mvc_app/src/views/company/register.html
Normal file
File diff suppressed because it is too large
Load Diff
21
actix_mvc_app/src/views/company/tabs.html
Normal file
21
actix_mvc_app/src/views/company/tabs.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
<ul class="nav nav-tabs" id="companyTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="manage-tab" data-bs-toggle="tab" data-bs-target="#manage" type="button" role="tab" aria-controls="manage" aria-selected="true">
|
||||||
|
<i class="bi bi-building me-1"></i> Manage Companies
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="register-tab" data-bs-toggle="tab" data-bs-target="#register" type="button" role="tab" aria-controls="register" aria-selected="false">
|
||||||
|
<i class="bi bi-file-earmark-plus me-1"></i> Register New Company
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content mt-4" id="companyTabsContent">
|
||||||
|
<div class="tab-pane fade show active" id="manage" role="tabpanel" aria-labelledby="manage-tab">
|
||||||
|
{% include "company/manage.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="register" role="tabpanel" aria-labelledby="register-tab">
|
||||||
|
{% include "company/register.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
355
actix_mvc_app/src/views/company/view.html
Normal file
355
actix_mvc_app/src/views/company/view.html
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ company.name }} - Company Details{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
.badge-signed {
|
||||||
|
background-color: #198754;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-building me-2"></i>{{ company.name }}</h2>
|
||||||
|
<div>
|
||||||
|
<a href="/company" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left me-1"></i>Back to
|
||||||
|
Companies</a>
|
||||||
|
<a href="/company/switch/{{ company.base_data.id }}" class="btn btn-primary"><i
|
||||||
|
class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Completion Status -->
|
||||||
|
{% if not company.email or company.email == "" or not company.phone or company.phone == "" or not company.address or
|
||||||
|
company.address == "" %}
|
||||||
|
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="me-3">
|
||||||
|
<i class="bi bi-info-circle fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="alert-heading mb-1">Complete Your Company Profile</h6>
|
||||||
|
<p class="mb-2">Your company profile is missing some essential information. Add the missing details to
|
||||||
|
improve your company's visibility and professionalism.</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/company/edit/{{ company.base_data.id }}" class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Complete Profile
|
||||||
|
</a>
|
||||||
|
<small class="text-muted align-self-center">
|
||||||
|
Missing:
|
||||||
|
{% if not company.email or company.email == "" %}Email{% endif %}
|
||||||
|
{% if not company.phone or company.phone == "" %}{% if not company.email or company.email == ""
|
||||||
|
%}, {% endif %}Phone{% endif %}
|
||||||
|
{% if not company.address or company.address == "" %}{% if not company.email or company.email ==
|
||||||
|
"" or not company.phone or company.phone == "" %}, {% endif %}Address{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>General Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%">Company Name:</th>
|
||||||
|
<td>{{ company.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Type:</th>
|
||||||
|
<td>
|
||||||
|
{% if company.business_type == "Starter" %}Startup FZC
|
||||||
|
{% elif company.business_type == "Global" %}Growth FZC
|
||||||
|
{% elif company.business_type == "Coop" %}Cooperative FZC
|
||||||
|
{% elif company.business_type == "Single" %}Single FZC
|
||||||
|
{% elif company.business_type == "Twin" %}Twin FZC
|
||||||
|
{% else %}{{ company.business_type }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Registration Number:</th>
|
||||||
|
<td>{{ company.registration_number }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Registration Date:</th>
|
||||||
|
<td>{{ incorporation_date_formatted }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td>
|
||||||
|
{% if company.status == "Active" %}
|
||||||
|
<span class="badge bg-success">{{ company.status }}</span>
|
||||||
|
{% elif company.status == "Inactive" %}
|
||||||
|
<span class="badge bg-secondary">{{ company.status }}</span>
|
||||||
|
{% elif company.status == "Suspended" %}
|
||||||
|
<span class="badge bg-warning text-dark">{{ company.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ company.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Industry:</th>
|
||||||
|
<td>{{ company.industry | default(value="Not specified") }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Description:</th>
|
||||||
|
<td>{{ company.description | default(value="No description provided") }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Additional Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%">Email:</th>
|
||||||
|
<td>
|
||||||
|
{% if company.email and company.email != "" %}
|
||||||
|
{{ company.email }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not provided</span>
|
||||||
|
<a href="/company/edit/{{ company.base_data.id }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Phone:</th>
|
||||||
|
<td>
|
||||||
|
{% if company.phone and company.phone != "" %}
|
||||||
|
{{ company.phone }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not provided</span>
|
||||||
|
<a href="/company/edit/{{ company.base_data.id }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Website:</th>
|
||||||
|
<td>
|
||||||
|
{% if company.website and company.website != "" %}
|
||||||
|
<a href="{{ company.website }}" target="_blank">{{ company.website }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not provided</span>
|
||||||
|
<a href="/company/edit/{{ company.base_data.id }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Address:</th>
|
||||||
|
<td>
|
||||||
|
{% if company.address and company.address != "" %}
|
||||||
|
{{ company.address }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not provided</span>
|
||||||
|
<a href="/company/edit/{{ company.base_data.id }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Fiscal Year End:</th>
|
||||||
|
<td>
|
||||||
|
{% if company.fiscal_year_end and company.fiscal_year_end != "" %}
|
||||||
|
{{ company.fiscal_year_end }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Not specified</span>
|
||||||
|
<a href="/company/edit/{{ company.base_data.id }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-people me-2"></i>Shareholders</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Percentage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if shareholders and shareholders|length > 0 %}
|
||||||
|
{% for shareholder in shareholders %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ shareholder.name }}</td>
|
||||||
|
<td>{{ shareholder.percentage }}%</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-center text-muted py-3">
|
||||||
|
<i class="bi bi-people me-1"></i>
|
||||||
|
No shareholders registered yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-credit-card me-2"></i>Billing & Payment</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if payment_info %}
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40%">Payment Status:</th>
|
||||||
|
<td>
|
||||||
|
{% if payment_info.status == "Succeeded" %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Paid
|
||||||
|
</span>
|
||||||
|
{% elif payment_info.status == "Pending" %}
|
||||||
|
<span class="badge bg-warning">
|
||||||
|
<i class="bi bi-clock me-1"></i>Pending
|
||||||
|
</span>
|
||||||
|
{% elif payment_info.status == "Failed" %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>Failed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ payment_info.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Payment Plan:</th>
|
||||||
|
<td>{{ payment_plan_display }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Setup Fee:</th>
|
||||||
|
<td>${{ payment_info.setup_fee }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Monthly Fee:</th>
|
||||||
|
<td>${{ payment_info.monthly_fee }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total Paid:</th>
|
||||||
|
<td><strong>${{ payment_info.total_amount }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Payment Date:</th>
|
||||||
|
<td>{{ payment_created_formatted }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if payment_completed_formatted %}
|
||||||
|
<tr>
|
||||||
|
<th>Completed:</th>
|
||||||
|
<td>{{ payment_completed_formatted }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if payment_info.payment_intent_id %}
|
||||||
|
<tr>
|
||||||
|
<th>Payment ID:</th>
|
||||||
|
<td>
|
||||||
|
<code class="small">{{ payment_info.payment_intent_id }}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-3">
|
||||||
|
<i class="bi bi-credit-card me-1"></i>
|
||||||
|
No payment information available
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">This company may have been created before payment integration</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/company/edit/{{ company.base_data.id }}" class="btn btn-outline-primary"><i
|
||||||
|
class="bi bi-pencil me-1"></i>Edit Company</a>
|
||||||
|
<a href="/company/documents/{{ company.base_data.id }}" class="btn btn-outline-secondary"><i
|
||||||
|
class="bi bi-file-earmark me-1"></i>Manage Documents</a>
|
||||||
|
<a href="/company/switch/{{ company.base_data.id }}" class="btn btn-primary"><i
|
||||||
|
class="bi bi-box-arrow-in-right me-1"></i>Switch to Entity</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
console.log('Company view page loaded');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Contact - Actix MVC App{% endblock %}
|
{% block title %}Contact - Zanzibar Digital Freezone{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -37,15 +37,15 @@
|
|||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Email</h5>
|
<h5 class="card-title">Email</h5>
|
||||||
<p class="card-text">info@example.com</p>
|
<p class="card-text">info@ourworld.tf</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">GitHub</h5>
|
<h5 class="card-title">Website</h5>
|
||||||
<p class="card-text">github.com/example/actix-mvc-app</p>
|
<p class="card-text">https://info.ourworld.tf/zdfz</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
200
actix_mvc_app/src/views/contracts/add_signer.html
Normal file
200
actix_mvc_app/src/views/contracts/add_signer.html
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Add Signer - {{ contract.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/contracts">Contracts</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/contracts/{{ contract.id }}">{{ contract.title }}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Add Signer</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Add Signer</h1>
|
||||||
|
<p class="text-muted mb-0">Add a new signer to "{{ contract.title }}"</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> Back to Contract
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Add Signer Form -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-person-plus me-2"></i>Signer Information
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/contracts/{{ contract.id }}/add-signer">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">
|
||||||
|
Full Name <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name"
|
||||||
|
placeholder="Enter signer's full name" required>
|
||||||
|
<div class="form-text">The full legal name of the person who will sign</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="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"
|
||||||
|
placeholder="Enter signer's email address" required>
|
||||||
|
<div class="form-text">Email where signing instructions will be sent</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<strong>Note:</strong> The signer will receive an email with a secure link to sign the contract once you send it for signatures.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-person-plus me-1"></i> Add Signer
|
||||||
|
</button>
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contract Summary -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Contract Summary</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{{ contract.title }}</h6>
|
||||||
|
<p class="card-text text-muted">{{ contract.description }}</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="border-end">
|
||||||
|
<div class="h4 mb-0 text-primary">{{ contract.signers|length }}</div>
|
||||||
|
<small class="text-muted">Current Signers</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="h4 mb-0 text-success">{{ contract.signed_signers }}</div>
|
||||||
|
<small class="text-muted">Signed</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Signers List -->
|
||||||
|
{% if contract.signers|length > 0 %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Current Signers</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for signer in contract.signers %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-medium">{{ signer.name }}</div>
|
||||||
|
<small class="text-muted">{{ signer.email }}</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge {% if signer.status == 'Signed' %}bg-success{% elif signer.status == 'Rejected' %}bg-danger{% else %}bg-warning text-dark{% endif %}">
|
||||||
|
{{ signer.status }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Form validation
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const nameInput = document.getElementById('name');
|
||||||
|
const emailInput = document.getElementById('email');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Clear previous validation states
|
||||||
|
nameInput.classList.remove('is-invalid');
|
||||||
|
emailInput.classList.remove('is-invalid');
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
if (nameInput.value.trim().length < 2) {
|
||||||
|
nameInput.classList.add('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(emailInput.value)) {
|
||||||
|
emailInput.classList.add('is-invalid');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real-time validation feedback
|
||||||
|
nameInput.addEventListener('input', function() {
|
||||||
|
if (this.value.trim().length >= 2) {
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
this.classList.add('is-valid');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('is-valid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emailInput.addEventListener('input', function() {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (emailRegex.test(this.value)) {
|
||||||
|
this.classList.remove('is-invalid');
|
||||||
|
this.classList.add('is-valid');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('is-valid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
128
actix_mvc_app/src/views/contracts/all_activities.html
Normal file
128
actix_mvc_app/src/views/contracts/all_activities.html
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}All Contract Activities{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="display-5 mb-3">Contract Activities</h1>
|
||||||
|
<p class="lead">Complete history of contract actions and events across your organization.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activities List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="bi bi-activity"></i> Contract 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>Contract</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>
|
||||||
|
<span class="text-decoration-none">
|
||||||
|
{{ activity.contract_title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ activity.created_at | 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">
|
||||||
|
Contract activities will appear here as users create contracts and add signers.
|
||||||
|
</p>
|
||||||
|
<a href="/contracts/create" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create First Contract
|
||||||
|
</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-file-earmark-text text-primary"></i>
|
||||||
|
</h5>
|
||||||
|
<p class="card-text text-muted">Contract 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">Team Collaboration</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Back to Dashboard -->
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<a href="/contracts" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Contracts Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
1976
actix_mvc_app/src/views/contracts/contract_detail.html
Normal file
1976
actix_mvc_app/src/views/contracts/contract_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
226
actix_mvc_app/src/views/contracts/contracts.html
Normal file
226
actix_mvc_app/src/views/contracts/contracts.html
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}All Contracts{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/contracts">Contracts Dashboard</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">All Contracts</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="display-5 mb-0">All Contracts</h1>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/contracts/create" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Create New Contract
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<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">
|
||||||
|
<form action="/contracts/list" method="get" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="status" name="status">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>Draft
|
||||||
|
</option>
|
||||||
|
<option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
|
||||||
|
%}selected{% endif %}>Pending Signatures</option>
|
||||||
|
<option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
|
||||||
|
Signed</option>
|
||||||
|
<option value="Expired" {% if current_status_filter=="Expired" %}selected{% endif %}>
|
||||||
|
Expired</option>
|
||||||
|
<option value="Cancelled" {% if current_status_filter=="Cancelled" %}selected{% endif
|
||||||
|
%}>Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="type" class="form-label">Contract Type</label>
|
||||||
|
<select class="form-select" id="type" name="type">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="Service Agreement" {% if current_type_filter=="Service Agreement"
|
||||||
|
%}selected{% endif %}>Service Agreement</option>
|
||||||
|
<option value="Employment Contract" {% if current_type_filter=="Employment Contract"
|
||||||
|
%}selected{% endif %}>Employment Contract</option>
|
||||||
|
<option value="Non-Disclosure Agreement" {% if
|
||||||
|
current_type_filter=="Non-Disclosure Agreement" %}selected{% endif %}>Non-Disclosure
|
||||||
|
Agreement</option>
|
||||||
|
<option value="Service Level Agreement" {% if
|
||||||
|
current_type_filter=="Service Level Agreement" %}selected{% endif %}>Service Level
|
||||||
|
Agreement</option>
|
||||||
|
<option value="Other" {% if current_type_filter=="Other" %}selected{% endif %}>Other
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<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="{{ current_search_filter | default(value='') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contract List -->
|
||||||
|
<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">
|
||||||
|
{% if contracts and contracts | length > 0 %}
|
||||||
|
<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 contract in contracts %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/contracts/{{ contract.id }}">{{ contract.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ contract.contract_type }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge {% if contract.status == 'Signed' %}bg-success{% elif contract.status == 'PendingSignatures' %}bg-warning text-dark{% elif contract.status == 'Draft' %}bg-secondary{% elif contract.status == 'Expired' %}bg-danger{% else %}bg-dark{% endif %}">
|
||||||
|
{{ contract.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ contract.created_by }}</td>
|
||||||
|
<td>{{ contract.signed_signers }}/{{ contract.signers|length }}</td>
|
||||||
|
<td>{{ contract.created_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>{{ contract.updated_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if contract.status == 'Draft' %}
|
||||||
|
<a href="/contracts/{{ contract.id }}/edit"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick="deleteContract({{ contract.id }}, '{{ contract.title | replace(from="'", to="\\'") }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<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="/contracts/create" class="btn btn-primary mt-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Create New Contract
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<strong>Warning:</strong> This action cannot be undone!
|
||||||
|
</div>
|
||||||
|
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
|
||||||
|
<p>This will permanently remove the contract and all its associated data.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
||||||
|
<i class="bi bi-trash me-1"></i> Delete Contract
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
console.log('Contracts list scripts loading...');
|
||||||
|
|
||||||
|
// Delete function using Bootstrap modal
|
||||||
|
window.deleteContract = function (contractId, contractTitle) {
|
||||||
|
console.log('Delete function called:', contractId, contractTitle);
|
||||||
|
|
||||||
|
// Set the contract title in the modal
|
||||||
|
document.getElementById('contractTitle').textContent = contractTitle;
|
||||||
|
|
||||||
|
// Store the contract ID for later use
|
||||||
|
window.currentDeleteContractId = contractId;
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
|
deleteModal.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('deleteContract function defined:', typeof window.deleteContract);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Handle confirm delete button click
|
||||||
|
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
|
||||||
|
console.log('User confirmed deletion, submitting form...');
|
||||||
|
|
||||||
|
// Create and submit form
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
286
actix_mvc_app/src/views/contracts/create_contract.html
Normal file
286
actix_mvc_app/src/views/contracts/create_contract.html
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Create New Contract{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/contracts">Contracts Dashboard</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Create New Contract</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h1 class="display-5 mb-3">Create New Contract</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 action="/contracts/create" method="post">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<option value="" selected disabled>Select a contract type</option>
|
||||||
|
{% for type in contract_types %}
|
||||||
|
<option value="{{ type }}">{{ type }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</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></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.
|
||||||
|
<a href="/editor" target="_blank">Open Markdown Editor</a> for a live preview.
|
||||||
|
</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">
|
||||||
|
<a href="/contracts" class="btn btn-outline-secondary me-md-2">Cancel</a>
|
||||||
|
<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"
|
||||||
|
onclick="loadTemplate('nda')">
|
||||||
|
Non-Disclosure Agreement
|
||||||
|
</button>
|
||||||
|
<button type="button" class="list-group-item list-group-item-action"
|
||||||
|
onclick="loadTemplate('service')">
|
||||||
|
Service Agreement
|
||||||
|
</button>
|
||||||
|
<button type="button" class="list-group-item list-group-item-action"
|
||||||
|
onclick="loadTemplate('employment')">
|
||||||
|
Employment Contract
|
||||||
|
</button>
|
||||||
|
<button type="button" class="list-group-item list-group-item-action"
|
||||||
|
onclick="loadTemplate('sla')">
|
||||||
|
Service Level Agreement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function loadTemplate(type) {
|
||||||
|
// In a real application, this would load template content from the server
|
||||||
|
let title = '';
|
||||||
|
let description = '';
|
||||||
|
let content = '';
|
||||||
|
let contractType = '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'nda':
|
||||||
|
title = 'Non-Disclosure Agreement';
|
||||||
|
description = 'Standard NDA for protecting confidential information';
|
||||||
|
contractType = 'Non-Disclosure Agreement';
|
||||||
|
content = `# Non-Disclosure Agreement
|
||||||
|
|
||||||
|
This Non-Disclosure Agreement (the "**Agreement**") is entered into as of **[DATE]** by and between **[PARTY A]** and **[PARTY B]**.
|
||||||
|
|
||||||
|
## 1. Definition of Confidential Information
|
||||||
|
|
||||||
|
"Confidential Information" means any and all information disclosed by either party to the other party, whether orally or in writing, whether or not marked, designated or otherwise identified as "confidential."
|
||||||
|
|
||||||
|
## 2. Obligations of Receiving Party
|
||||||
|
|
||||||
|
The receiving party agrees to:
|
||||||
|
- Hold all Confidential Information in strict confidence
|
||||||
|
- Not disclose any Confidential Information to third parties
|
||||||
|
- Use Confidential Information solely for the purpose of evaluating potential business relationships
|
||||||
|
|
||||||
|
## 3. Term
|
||||||
|
|
||||||
|
This Agreement shall remain in effect for a period of **[DURATION]** years from the date first written above.
|
||||||
|
|
||||||
|
## 4. Return of Materials
|
||||||
|
|
||||||
|
Upon termination of this Agreement, each party shall promptly return all documents and materials containing Confidential Information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**IN WITNESS WHEREOF**, the parties have executed this Agreement as of the date first written above.
|
||||||
|
|
||||||
|
**[PARTY A]** **[PARTY B]**
|
||||||
|
|
||||||
|
_____________________ _____________________
|
||||||
|
Signature Signature
|
||||||
|
|
||||||
|
_____________________ _____________________
|
||||||
|
Print Name Print Name
|
||||||
|
|
||||||
|
_____________________ _____________________
|
||||||
|
Date Date`;
|
||||||
|
break;
|
||||||
|
case 'service':
|
||||||
|
title = 'Service Agreement';
|
||||||
|
description = 'Agreement for providing professional services';
|
||||||
|
contractType = 'Service Agreement';
|
||||||
|
content = `# Service Agreement
|
||||||
|
|
||||||
|
This Service Agreement (the "**Agreement**") is made and entered into as of **[DATE]** by and between **[SERVICE PROVIDER]** and **[CLIENT]**.
|
||||||
|
|
||||||
|
## 1. Services to be Provided
|
||||||
|
|
||||||
|
The Service Provider agrees to provide the following services:
|
||||||
|
|
||||||
|
- **[SERVICE 1]**: Description of service
|
||||||
|
- **[SERVICE 2]**: Description of service
|
||||||
|
- **[SERVICE 3]**: Description of service
|
||||||
|
|
||||||
|
## 2. Compensation
|
||||||
|
|
||||||
|
| Service | Rate | Payment Terms |
|
||||||
|
|---------|------|---------------|
|
||||||
|
| [SERVICE 1] | $[AMOUNT] | [TERMS] |
|
||||||
|
| [SERVICE 2] | $[AMOUNT] | [TERMS] |
|
||||||
|
|
||||||
|
**Total Contract Value**: $[TOTAL_AMOUNT]
|
||||||
|
|
||||||
|
## 3. Payment Schedule
|
||||||
|
|
||||||
|
- **Deposit**: [PERCENTAGE]% upon signing
|
||||||
|
- **Milestone 1**: [PERCENTAGE]% upon [MILESTONE]
|
||||||
|
- **Final Payment**: [PERCENTAGE]% upon completion
|
||||||
|
|
||||||
|
## 4. Term and Termination
|
||||||
|
|
||||||
|
This Agreement shall commence on **[START_DATE]** and shall continue until **[END_DATE]** unless terminated earlier.
|
||||||
|
|
||||||
|
> **Important**: Either party may terminate this agreement with [NUMBER] days written notice.
|
||||||
|
|
||||||
|
## 5. Deliverables
|
||||||
|
|
||||||
|
The Service Provider shall deliver:
|
||||||
|
|
||||||
|
1. [DELIVERABLE 1]
|
||||||
|
2. [DELIVERABLE 2]
|
||||||
|
3. [DELIVERABLE 3]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Service Provider** **Client**
|
||||||
|
|
||||||
|
_____________________ _____________________
|
||||||
|
Signature Signature`;
|
||||||
|
break;
|
||||||
|
case 'employment':
|
||||||
|
title = 'Employment Contract';
|
||||||
|
description = 'Standard employment agreement';
|
||||||
|
contractType = 'Employment Contract';
|
||||||
|
content = 'This Employment Agreement (the "Agreement") is made and entered into as of [DATE] by and between [EMPLOYER] and [EMPLOYEE].\n\n1. Position and Duties\n2. Compensation and Benefits\n3. Term and Termination\n...';
|
||||||
|
break;
|
||||||
|
case 'sla':
|
||||||
|
title = 'Service Level Agreement';
|
||||||
|
description = 'Agreement defining service levels and metrics';
|
||||||
|
contractType = 'Service Level Agreement';
|
||||||
|
content = 'This Service Level Agreement (the "SLA") is made and entered into as of [DATE] by and between [SERVICE PROVIDER] and [CLIENT].\n\n1. Service Levels\n2. Performance Metrics\n3. Remedies for Failure\n...';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('title').value = title;
|
||||||
|
document.getElementById('description').value = description;
|
||||||
|
document.getElementById('content').value = content;
|
||||||
|
|
||||||
|
// Set the select option
|
||||||
|
const selectElement = document.getElementById('contract_type');
|
||||||
|
for (let i = 0; i < selectElement.options.length; i++) {
|
||||||
|
if (selectElement.options[i].text === contractType) {
|
||||||
|
selectElement.selectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
215
actix_mvc_app/src/views/contracts/edit_contract.html
Normal file
215
actix_mvc_app/src/views/contracts/edit_contract.html
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edit Contract{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/contracts">Contracts Dashboard</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/contracts/{{ contract.id }}">{{ contract.title }}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Edit Contract</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h1 class="display-5 mb-3">Edit Contract</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 action="/contracts/{{ contract.id }}/edit" method="post">
|
||||||
|
<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" value="{{ contract.title }}"
|
||||||
|
required>
|
||||||
|
</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>
|
||||||
|
{% for type in contract_types %}
|
||||||
|
<option value="{{ type }}" {% if contract.contract_type==type %}selected{% endif %}>{{
|
||||||
|
type }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</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>{{ contract.description }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="content" class="form-label">Contract Content</label>
|
||||||
|
<textarea class="form-control" id="content" name="content"
|
||||||
|
rows="10">{{ contract.terms_and_conditions | default(value='') }}</textarea>
|
||||||
|
<div class="form-text">Edit the contract content as needed.</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"
|
||||||
|
value="{% if contract.start_date %}{{ contract.start_date | date(format='%Y-%m-%d') }}{% endif %}">
|
||||||
|
</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"
|
||||||
|
value="{% if contract.end_date %}{{ contract.end_date | date(format='%Y-%m-%d') }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-outline-secondary me-md-2">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Update 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">Contract Info</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p><strong>Status:</strong>
|
||||||
|
<span class="badge bg-secondary">{{ contract.status }}</span>
|
||||||
|
</p>
|
||||||
|
<p><strong>Created:</strong> {{ contract.created_at | date(format="%Y-%m-%d %H:%M") }}</p>
|
||||||
|
<p><strong>Last Updated:</strong> {{ contract.updated_at | date(format="%Y-%m-%d %H:%M") }}</p>
|
||||||
|
<p><strong>Version:</strong> {{ contract.current_version }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Edit Notes</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<strong>Note:</strong> Only contracts in <strong>Draft</strong> status can be edited.
|
||||||
|
Once a contract is sent for signatures, you'll need to create a new revision instead.
|
||||||
|
</div>
|
||||||
|
<p>After updating the contract:</p>
|
||||||
|
<ul>
|
||||||
|
<li>The contract will remain in Draft status</li>
|
||||||
|
<li>You can continue to make changes</li>
|
||||||
|
<li>Add signers when ready</li>
|
||||||
|
<li>Send for signatures when complete</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-eye me-1"></i> View Contract
|
||||||
|
</a>
|
||||||
|
<a href="/contracts/{{ contract.id }}/add-signer" class="btn btn-outline-success">
|
||||||
|
<i class="bi bi-person-plus me-1"></i> Add Signer
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-danger"
|
||||||
|
onclick="deleteContract({{ contract.id }}, '{{ contract.title | replace(from="'", to="\\'") }}')">
|
||||||
|
<i class="bi bi-trash me-1"></i> Delete Contract
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<strong>Warning:</strong> This action cannot be undone!
|
||||||
|
</div>
|
||||||
|
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
|
||||||
|
<p>This will permanently remove the contract and all its associated data.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
||||||
|
<i class="bi bi-trash me-1"></i> Delete Contract
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
console.log('Edit contract scripts loading...');
|
||||||
|
|
||||||
|
// Delete function using Bootstrap modal
|
||||||
|
window.deleteContract = function (contractId, contractTitle) {
|
||||||
|
console.log('Delete function called:', contractId, contractTitle);
|
||||||
|
|
||||||
|
// Set the contract title in the modal
|
||||||
|
document.getElementById('contractTitle').textContent = contractTitle;
|
||||||
|
|
||||||
|
// Store the contract ID for later use
|
||||||
|
window.currentDeleteContractId = contractId;
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
|
deleteModal.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('deleteContract function defined:', typeof window.deleteContract);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Handle confirm delete button click
|
||||||
|
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
|
||||||
|
console.log('User confirmed deletion, submitting form...');
|
||||||
|
|
||||||
|
// Create and submit form
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
const textarea = document.getElementById('content');
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener('input', function () {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = this.scrollHeight + 'px';
|
||||||
|
});
|
||||||
|
// Initial resize
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
349
actix_mvc_app/src/views/contracts/index.html
Normal file
349
actix_mvc_app/src/views/contracts/index.html
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Contracts Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="display-5 mb-3">Contracts Dashboard</h1>
|
||||||
|
<p class="lead">Manage legal agreements and contracts across your organization.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if stats.total_contracts > 0 %}
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card text-white bg-primary h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title mb-1">Total</h5>
|
||||||
|
<h3 class="mb-0">{{ stats.total_contracts }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card text-white bg-secondary h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title mb-1">Draft</h5>
|
||||||
|
<h3 class="mb-0">{{ stats.draft_contracts }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card text-white bg-warning h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title mb-1">Pending</h5>
|
||||||
|
<h3 class="mb-0">{{ stats.pending_signature_contracts }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card text-white bg-success h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title mb-1">Signed</h5>
|
||||||
|
<h3 class="mb-0">{{ stats.signed_contracts }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card text-white bg-danger h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title mb-1">Expired</h5>
|
||||||
|
<h3 class="mb-0">{{ stats.expired_contracts }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card text-white bg-dark h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title mb-1">Cancelled</h5>
|
||||||
|
<h3 class="mb-0">{{ stats.cancelled_contracts }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty State Welcome Message -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 bg-light">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="bi bi-file-earmark-text display-1 text-muted"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-muted mb-3">Welcome to Contract Management</h3>
|
||||||
|
<p class="lead text-muted mb-4">
|
||||||
|
You haven't created any contracts yet. Get started by creating your first contract to manage
|
||||||
|
legal agreements and track signatures.
|
||||||
|
</p>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="row g-3">
|
||||||
|
<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">Create Contract</h6>
|
||||||
|
<p class="card-text small text-muted">Start with a new legal agreement</p>
|
||||||
|
<a href="/contracts/create" class="btn btn-primary btn-sm">Get Started</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">Need Help?</h6>
|
||||||
|
<p class="card-text small text-muted">Learn how to use the system</p>
|
||||||
|
<button class="btn btn-outline-success btn-sm"
|
||||||
|
onclick="showHelpModal()">Learn More</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% if stats.total_contracts > 0 %}
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="/contracts/create" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Create New Contract
|
||||||
|
</a>
|
||||||
|
<a href="/contracts/list" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-list me-1"></i> View All Contracts
|
||||||
|
</a>
|
||||||
|
<a href="/contracts/my-contracts" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-person me-1"></i> My Contracts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pending Signature Contracts -->
|
||||||
|
{% if pending_signature_contracts and pending_signature_contracts | length > 0 %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<h5 class="mb-0">Pending Signature ({{ pending_signature_contracts|length }})</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Contract Title</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Created By</th>
|
||||||
|
<th>Pending Signers</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for contract in pending_signature_contracts %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/contracts/{{ contract.id }}">{{ contract.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ contract.contract_type }}</td>
|
||||||
|
<td>{{ contract.created_by }}</td>
|
||||||
|
<td>{{ contract.pending_signers }} of {{ contract.signers|length }}</td>
|
||||||
|
<td>{{ contract.created_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Draft Contracts -->
|
||||||
|
{% if draft_contracts and draft_contracts | length > 0 %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Draft Contracts</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Contract Title</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Created By</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for contract in draft_contracts %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/contracts/{{ contract.id }}">{{ contract.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ contract.contract_type }}</td>
|
||||||
|
<td>{{ contract.created_by }}</td>
|
||||||
|
<td>{{ contract.created_at | date(format="%Y-%m-%d") }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="/contracts/{{ contract.id }}/edit"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Activity Section -->
|
||||||
|
{% if recent_activities and recent_activities | length > 0 %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Recent Activity</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for activity in recent_activities %}
|
||||||
|
<div class="list-group-item border-start-0 border-end-0 py-3">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="me-3">
|
||||||
|
<i class="{{ activity.icon }} fs-5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong>{{ activity.user }}</strong>
|
||||||
|
<small class="text-muted">{{ activity.timestamp | date(format="%H:%M")
|
||||||
|
}}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1">{{ activity.description }}</p>
|
||||||
|
<small class="text-muted">{{ activity.title }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<a href="/contracts/activities" class="btn btn-sm btn-outline-info">See More Activities</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
<div class="modal fade" id="helpModal" tabindex="-1" aria-labelledby="helpModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="helpModalLabel">
|
||||||
|
<i class="bi bi-question-circle me-2"></i>Getting Started with Contract Management
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6><i class="bi bi-1-circle text-primary me-2"></i>Create Your First Contract</h6>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Start by creating a new contract. Choose from various contract types like Service
|
||||||
|
Agreements, NDAs, or Employment Contracts.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6><i class="bi bi-2-circle text-primary me-2"></i>Add Contract Details</h6>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Fill in the contract title, description, and terms. You can use Markdown formatting for rich
|
||||||
|
text content.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6><i class="bi bi-3-circle text-primary me-2"></i>Add Signers</h6>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Add people who need to sign the contract. Each signer will receive a unique signing link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6><i class="bi bi-4-circle text-success me-2"></i>Send for Signatures</h6>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Once your contract is ready, send it for signatures. Signers can review and sign digitally.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6><i class="bi bi-5-circle text-success me-2"></i>Track Progress</h6>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Monitor signature progress, send reminders, and view signed documents from the dashboard.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6><i class="bi bi-6-circle text-success me-2"></i>Manage Contracts</h6>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
View all contracts, filter by status, and manage the complete contract lifecycle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<i class="bi bi-lightbulb me-2"></i>
|
||||||
|
<strong>Tip:</strong> You can save contracts as drafts and come back to edit them later before
|
||||||
|
sending for signatures.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<a href="/contracts/create" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Create My First Contract
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function showHelpModal() {
|
||||||
|
const helpModal = new bootstrap.Modal(document.getElementById('helpModal'));
|
||||||
|
helpModal.show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% macro render_toc(items, section_param) %}
|
||||||
|
{% for item in items %}
|
||||||
|
<a href="?section={{ item.file }}" class="list-group-item list-group-item-action{% if section_param == item.file %} active{% endif %}">{{ item.title }}</a>
|
||||||
|
{% if item.children and item.children | length > 0 %}
|
||||||
|
<div class="ms-3">
|
||||||
|
{{ self::render_toc(items=item.children, section_param=section_param) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endmacro %}
|
||||||
477
actix_mvc_app/src/views/contracts/my_contracts.html
Normal file
477
actix_mvc_app/src/views/contracts/my_contracts.html
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}My Contracts{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/contracts">Contracts Dashboard</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">My Contracts</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-5 mb-0">My Contracts</h1>
|
||||||
|
<p class="text-muted mb-0">Manage and track your personal contracts</p>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/contracts/create" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Create New Contract
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-primary text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="card-title">Total Contracts</h6>
|
||||||
|
<h3 class="mb-0">{{ contracts|length }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-file-earmark-text fs-2"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-warning text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="card-title">Pending Signatures</h6>
|
||||||
|
<h3 class="mb-0" id="pending-count">0</h3>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-clock fs-2"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-success text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="card-title">Signed</h6>
|
||||||
|
<h3 class="mb-0" id="signed-count">0</h3>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-check-circle fs-2"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-secondary text-white">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="card-title">Drafts</h6>
|
||||||
|
<h3 class="mb-0" id="draft-count">0</h3>
|
||||||
|
</div>
|
||||||
|
<div class="align-self-center">
|
||||||
|
<i class="bi bi-pencil fs-2"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<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">
|
||||||
|
<i class="bi bi-funnel me-1"></i> Filters & Search
|
||||||
|
</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#filtersCollapse" aria-expanded="false" aria-controls="filtersCollapse">
|
||||||
|
<i class="bi bi-chevron-down"></i> Toggle Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="collapse show" id="filtersCollapse">
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/contracts/my-contracts" method="get" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="status" name="status">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="Draft" {% if current_status_filter=="Draft" %}selected{% endif %}>
|
||||||
|
Draft</option>
|
||||||
|
<option value="PendingSignatures" {% if current_status_filter=="PendingSignatures"
|
||||||
|
%}selected{% endif %}>Pending Signatures</option>
|
||||||
|
<option value="Signed" {% if current_status_filter=="Signed" %}selected{% endif %}>
|
||||||
|
Signed</option>
|
||||||
|
<option value="Active" {% if current_status_filter=="Active" %}selected{% endif %}>
|
||||||
|
Active</option>
|
||||||
|
<option value="Expired" {% if current_status_filter=="Expired" %}selected{% endif
|
||||||
|
%}>Expired</option>
|
||||||
|
<option value="Cancelled" {% if current_status_filter=="Cancelled" %}selected{%
|
||||||
|
endif %}>Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="type" class="form-label">Contract Type</label>
|
||||||
|
<select class="form-select" id="type" name="type">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="Service Agreement" {% if current_type_filter=="Service Agreement"
|
||||||
|
%}selected{% endif %}>Service Agreement</option>
|
||||||
|
<option value="Employment Contract" {% if current_type_filter=="Employment Contract"
|
||||||
|
%}selected{% endif %}>Employment Contract</option>
|
||||||
|
<option value="Non-Disclosure Agreement" {% if
|
||||||
|
current_type_filter=="Non-Disclosure Agreement" %}selected{% endif %}>
|
||||||
|
Non-Disclosure Agreement</option>
|
||||||
|
<option value="Service Level Agreement" {% if
|
||||||
|
current_type_filter=="Service Level Agreement" %}selected{% endif %}>Service
|
||||||
|
Level Agreement</option>
|
||||||
|
<option value="Partnership Agreement" {% if
|
||||||
|
current_type_filter=="Partnership Agreement" %}selected{% endif %}>Partnership
|
||||||
|
Agreement</option>
|
||||||
|
<option value="Other" {% if current_type_filter=="Other" %}selected{% endif %}>Other
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<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="{{ current_search_filter | default(value='') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contract List -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i> My Contracts
|
||||||
|
{% if contracts and contracts | length > 0 %}
|
||||||
|
<span class="badge bg-primary ms-2">{{ contracts|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/contracts/statistics" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-graph-up me-1"></i> Statistics
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if contracts and contracts | length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
Contract Title
|
||||||
|
<i class="bi bi-arrow-down-up ms-1 text-muted" style="cursor: pointer;"
|
||||||
|
onclick="sortTable(0)"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Progress</th>
|
||||||
|
<th scope="col">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
Created
|
||||||
|
<i class="bi bi-arrow-down-up ms-1 text-muted" style="cursor: pointer;"
|
||||||
|
onclick="sortTable(4)"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col">Last Updated</th>
|
||||||
|
<th scope="col" class="text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for contract in contracts %}
|
||||||
|
<tr
|
||||||
|
class="{% if contract.status == 'Expired' %}table-danger{% elif contract.status == 'PendingSignatures' %}table-warning{% elif contract.status == 'Signed' %}table-success{% endif %}">
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="fw-bold text-decoration-none">
|
||||||
|
{{ contract.title }}
|
||||||
|
</a>
|
||||||
|
{% if contract.description %}
|
||||||
|
<div class="small text-muted">{{ contract.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark">{{ contract.contract_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge {% if contract.status == 'Signed' or contract.status == 'Active' %}bg-success{% elif contract.status == 'PendingSignatures' %}bg-warning text-dark{% elif contract.status == 'Draft' %}bg-secondary{% elif contract.status == 'Expired' %}bg-danger{% elif contract.status == 'Cancelled' %}bg-dark{% else %}bg-info{% endif %}">
|
||||||
|
{% if contract.status == 'PendingSignatures' %}
|
||||||
|
<i class="bi bi-clock me-1"></i>
|
||||||
|
{% elif contract.status == 'Signed' %}
|
||||||
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
|
{% elif contract.status == 'Draft' %}
|
||||||
|
<i class="bi bi-pencil me-1"></i>
|
||||||
|
{% elif contract.status == 'Expired' %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ contract.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if contract.signers|length > 0 %}
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="progress me-2" style="width: 60px; height: 8px;">
|
||||||
|
<div class="progress-bar bg-success" role="progressbar"
|
||||||
|
style="width: 0%" data-contract-id="{{ contract.id }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ contract.signed_signers }}/{{
|
||||||
|
contract.signers|length }}</small>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">No signers</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="small">
|
||||||
|
{{ contract.created_at | date(format="%b %d, %Y") }}
|
||||||
|
<div class="text-muted">{{ contract.created_at | date(format="%I:%M %p") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="small">
|
||||||
|
{{ contract.updated_at | date(format="%b %d, %Y") }}
|
||||||
|
<div class="text-muted">{{ contract.updated_at | date(format="%I:%M %p") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/contracts/{{ contract.id }}" class="btn btn-sm btn-primary"
|
||||||
|
title="View Details">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if contract.status == 'Draft' %}
|
||||||
|
<a href="/contracts/{{ contract.id }}/edit"
|
||||||
|
class="btn btn-sm btn-outline-secondary" title="Edit Contract">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Delete Contract"
|
||||||
|
onclick="deleteContract('{{ contract.id }}', '{{ contract.title }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="bi bi-file-earmark-text display-1 text-muted"></i>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-muted mb-3">No Contracts Found</h4>
|
||||||
|
<p class="text-muted mb-4">You haven't created any contracts yet. Get started by creating your
|
||||||
|
first contract.</p>
|
||||||
|
<div class="d-flex justify-content-center gap-2">
|
||||||
|
<a href="/contracts/create" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Create Your First Contract
|
||||||
|
</a>
|
||||||
|
<a href="/contracts" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteModalLabel">Delete Contract</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<strong>Warning:</strong> This action cannot be undone!
|
||||||
|
</div>
|
||||||
|
<p>Are you sure you want to delete the contract "<strong id="contractTitle"></strong>"?</p>
|
||||||
|
<p>This will permanently remove:</p>
|
||||||
|
<ul>
|
||||||
|
<li>The contract document and all its content</li>
|
||||||
|
<li>All signers and their signatures</li>
|
||||||
|
<li>All revisions and history</li>
|
||||||
|
<li>Any associated files or attachments</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
||||||
|
<i class="bi bi-trash me-1"></i> Delete Contract
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
console.log('My Contracts page scripts loading...');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Delete contract functionality using Bootstrap modal
|
||||||
|
window.deleteContract = function (contractId, contractTitle) {
|
||||||
|
console.log('Delete contract called:', contractId, contractTitle);
|
||||||
|
|
||||||
|
// Set the contract title in the modal
|
||||||
|
document.getElementById('contractTitle').textContent = contractTitle;
|
||||||
|
|
||||||
|
// Store the contract ID for later use
|
||||||
|
window.currentDeleteContractId = contractId;
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
|
deleteModal.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple table sorting functionality
|
||||||
|
window.sortTable = function (columnIndex) {
|
||||||
|
console.log('Sorting table by column:', columnIndex);
|
||||||
|
const table = document.querySelector('.table tbody');
|
||||||
|
const rows = Array.from(table.querySelectorAll('tr'));
|
||||||
|
|
||||||
|
// Toggle sort direction
|
||||||
|
const isAscending = table.dataset.sortDirection !== 'asc';
|
||||||
|
table.dataset.sortDirection = isAscending ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aText = a.cells[columnIndex].textContent.trim();
|
||||||
|
const bText = b.cells[columnIndex].textContent.trim();
|
||||||
|
|
||||||
|
// Handle date sorting for created/updated columns
|
||||||
|
if (columnIndex === 4 || columnIndex === 5) {
|
||||||
|
const aDate = new Date(aText);
|
||||||
|
const bDate = new Date(bText);
|
||||||
|
return isAscending ? aDate - bDate : bDate - aDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text sorting
|
||||||
|
return isAscending ? aText.localeCompare(bText) : bText.localeCompare(aText);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-append sorted rows
|
||||||
|
rows.forEach(row => table.appendChild(row));
|
||||||
|
|
||||||
|
// Update sort indicators
|
||||||
|
document.querySelectorAll('.bi-arrow-down-up').forEach(icon => {
|
||||||
|
icon.className = 'bi bi-arrow-down-up ms-1 text-muted';
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIcon = document.querySelectorAll('.bi-arrow-down-up')[columnIndex === 4 ? 1 : 0];
|
||||||
|
if (currentIcon) {
|
||||||
|
currentIcon.className = `bi ${isAscending ? 'bi-arrow-up' : 'bi-arrow-down'} ms-1 text-primary`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate statistics and update progress bars
|
||||||
|
function updateStatistics() {
|
||||||
|
const rows = document.querySelectorAll('.table tbody tr');
|
||||||
|
let totalContracts = rows.length;
|
||||||
|
let pendingCount = 0;
|
||||||
|
let signedCount = 0;
|
||||||
|
let draftCount = 0;
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const statusCell = row.cells[2];
|
||||||
|
const statusText = statusCell.textContent.trim();
|
||||||
|
|
||||||
|
if (statusText.includes('PendingSignatures') || statusText.includes('Pending')) {
|
||||||
|
pendingCount++;
|
||||||
|
} else if (statusText.includes('Signed')) {
|
||||||
|
signedCount++;
|
||||||
|
} else if (statusText.includes('Draft')) {
|
||||||
|
draftCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bars
|
||||||
|
const progressBar = row.querySelector('.progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
const signersText = row.cells[3].textContent.trim();
|
||||||
|
if (signersText !== 'No signers') {
|
||||||
|
const [signed, total] = signersText.split('/').map(n => parseInt(n));
|
||||||
|
const percentage = total > 0 ? Math.round((signed / total) * 100) : 0;
|
||||||
|
progressBar.style.width = percentage + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update statistics cards
|
||||||
|
document.getElementById('pending-count').textContent = pendingCount;
|
||||||
|
document.getElementById('signed-count').textContent = signedCount;
|
||||||
|
document.getElementById('draft-count').textContent = draftCount;
|
||||||
|
|
||||||
|
// Update total count badge
|
||||||
|
const badge = document.querySelector('.badge.bg-primary');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = totalContracts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Calculate initial statistics
|
||||||
|
updateStatistics();
|
||||||
|
|
||||||
|
// Handle confirm delete button click
|
||||||
|
document.getElementById('confirmDeleteBtn').addEventListener('click', function () {
|
||||||
|
console.log('User confirmed deletion, submitting form...');
|
||||||
|
|
||||||
|
// Create and submit form
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/contracts/' + window.currentDeleteContractId + '/delete';
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('My Contracts page scripts loaded successfully');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user