Compare commits
No commits in common. "d680c8b093d4f72d26086a63835098873b2740d7" and "6c884b78048bf64c8e846f9f1420d135b44674ca" have entirely different histories.
d680c8b093
...
6c884b7804
44 changed files with 205 additions and 7063 deletions
|
|
@ -5,7 +5,6 @@ ODOO_USERNAME=CHANGEME
|
||||||
ODOO_PASSWORD=CHANGEME
|
ODOO_PASSWORD=CHANGEME
|
||||||
BROKER_USERNAME=broker
|
BROKER_USERNAME=broker
|
||||||
BROKER_PASSWORD=CHANGEME
|
BROKER_PASSWORD=CHANGEME
|
||||||
CSP_CALCULATOR_PASSWORD=servala2025
|
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
SECRET_KEY="django-insecure-CHANGEME"
|
SECRET_KEY="django-insecure-CHANGEME"
|
||||||
ODOO_LEAD_CAMPAIGN_ID=6
|
ODOO_LEAD_CAMPAIGN_ID=6
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -14,5 +14,5 @@ wheels/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
media/
|
media/
|
||||||
deployment/secret.yaml
|
deployment/secret.yaml
|
||||||
/*.json
|
*.json
|
||||||
/static/
|
static/
|
||||||
|
|
|
||||||
113
CLAUDE.md
113
CLAUDE.md
|
|
@ -1,113 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
This is a Django website for servala.com, built with Python 3.13+ using uv for dependency management. The project structure follows Django conventions with a main `hub` application containing multiple services.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
### Local Development Setup
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
source .env
|
|
||||||
uv run --extra dev manage.py migrate
|
|
||||||
uv run --extra dev manage.py runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Operations
|
|
||||||
```bash
|
|
||||||
uv run --extra dev manage.py migrate
|
|
||||||
uv run --extra dev manage.py makemigrations
|
|
||||||
uv run --extra dev manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
uv run --extra dev manage.py test
|
|
||||||
|
|
||||||
# Run specific pricing tests (comprehensive suite available)
|
|
||||||
./run_pricing_tests.sh
|
|
||||||
|
|
||||||
# Run specific test modules
|
|
||||||
uv run --extra dev manage.py test hub.services.tests.test_pricing --verbosity=2
|
|
||||||
uv run --extra dev manage.py test hub.services.tests.test_pricing_edge_cases --verbosity=2
|
|
||||||
uv run --extra dev manage.py test hub.services.tests.test_pricing_integration --verbosity=2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Asset Management
|
|
||||||
```bash
|
|
||||||
uv run --extra dev manage.py build_assets
|
|
||||||
uv run --extra dev manage.py collectstatic
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
### Core Django App Structure
|
|
||||||
- `hub/` - Main Django application
|
|
||||||
- `services/` - Core business logic with multiple domains:
|
|
||||||
- `models/` - Database models organized by domain (articles, pricing, providers, services, etc.)
|
|
||||||
- `views/` - View logic organized by feature
|
|
||||||
- `forms/` - Form classes for user input
|
|
||||||
- `admin/` - Django admin customizations
|
|
||||||
- `tests/` - Comprehensive test suite, especially for pricing logic
|
|
||||||
- `broker/` - Separate app for broker-related functionality
|
|
||||||
- `middleware.py` - Custom middleware
|
|
||||||
- `settings.py` - Django configuration using environs for environment variables
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- **Pricing Engine**: Complex pricing calculations with multiple models (ComputePlan, StoragePlan, etc.)
|
|
||||||
- **Content Management**: Articles, services, providers with image library support
|
|
||||||
- **Lead Generation**: Contact forms and lead management
|
|
||||||
- **Partner System**: Cloud providers and consulting partners
|
|
||||||
- **Price Calculator**: Interactive frontend calculator with ROI calculations
|
|
||||||
|
|
||||||
### Frontend Assets
|
|
||||||
- Static files in `hub/services/static/`
|
|
||||||
- JavaScript organized by feature:
|
|
||||||
- `price-calculator/` - Modular price calculator components
|
|
||||||
- `roi-calculator/` - Modular ROI calculator with separate concerns (core, UI, charts, exports)
|
|
||||||
- CSS using Bootstrap 5 with custom styling
|
|
||||||
- Chart.js for data visualization
|
|
||||||
|
|
||||||
### Database
|
|
||||||
- Uses Django ORM with extensive migrations in `hub/services/migrations/`
|
|
||||||
- SQLite for development and production (`db.sqlite3`)
|
|
||||||
- Media files stored in `media/` with organized subdirectories
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
- Docker specific code is in the folder docker/
|
|
||||||
- Kubernetes deployment specific files in deployment/
|
|
||||||
- GitLab CI is used as the main CI/CD system
|
|
||||||
- Forgejo Actions is the secondary CI/CD system
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
- Uses `environs` library for environment variable management
|
|
||||||
- Requires `.env` file for local development (copy from `.env.example`)
|
|
||||||
- Key settings: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
- Extensive pricing test suite with edge cases and integration tests
|
|
||||||
- Use `--keepdb` flag for faster test runs during development
|
|
||||||
- Dedicated test runner script for pricing functionality
|
|
||||||
|
|
||||||
### Asset Pipeline
|
|
||||||
- Django Compressor for CSS/JS optimization
|
|
||||||
- Static files collection required for production
|
|
||||||
- Custom management commands for asset building
|
|
||||||
|
|
||||||
### Image Management
|
|
||||||
- Comprehensive image library system with SVG support
|
|
||||||
- Organized media directories for different content types
|
|
||||||
- Custom template tags for image handling
|
|
||||||
|
|
||||||
### Django specifics
|
|
||||||
- Use function-based views and follow the Django conventions for naming and structuring views
|
|
||||||
- Templates use the Django template language
|
|
||||||
|
|
||||||
### Claude Code specific
|
|
||||||
- Use context7 to get up-to-date documentation about Bootstrap, Python and Django
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
# Servala Investment Models: Comprehensive Guide
|
|
||||||
|
|
||||||
This document provides detailed information about Servala's two investment models for Cloud Service Providers (CSPs) looking to expand their service portfolio through partnership with Servala's managed services platform.
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Servala offers two distinct investment approaches for CSPs:
|
|
||||||
|
|
||||||
1. **Loan Model**: Low-risk, predictable returns through lending to Servala
|
|
||||||
2. **Direct Investment Model**: Higher potential returns through performance-based revenue sharing
|
|
||||||
|
|
||||||
Both models enable CSPs to capitalize on the growing demand for managed cloud services while supporting Servala's platform expansion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Investment Model #1: Loan Model
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
The Loan Model provides CSPs with a traditional lending arrangement where they provide capital to Servala at a fixed interest rate, receiving guaranteed monthly payments regardless of business performance.
|
|
||||||
|
|
||||||
### Key Characteristics
|
|
||||||
|
|
||||||
#### Financial Structure
|
|
||||||
- **Investment Range**: CHF 100,000 - CHF 2,000,000
|
|
||||||
- **Interest Rates**: Typically 3-7% annually (negotiable)
|
|
||||||
- **Payment Schedule**: Fixed monthly payments using standard amortization
|
|
||||||
- **Term Length**: 1-5 years (customizable)
|
|
||||||
|
|
||||||
#### Payment Calculation
|
|
||||||
Monthly payments are calculated using the standard amortization formula:
|
|
||||||
```
|
|
||||||
Monthly Payment = P × [r(1+r)^n] / [(1+r)^n - 1]
|
|
||||||
Where:
|
|
||||||
- P = Principal loan amount
|
|
||||||
- r = Monthly interest rate (annual rate ÷ 12)
|
|
||||||
- n = Total number of payments (years × 12)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Revenue Distribution
|
|
||||||
- **CSP**: 100% of monthly loan payments
|
|
||||||
- **Servala**: 0% (receives loan capital for operations)
|
|
||||||
|
|
||||||
#### Break-Even Analysis
|
|
||||||
- **Break-even occurs**: When cumulative payments received equal the original loan amount
|
|
||||||
- **Typical timeline**: 12-18 months depending on interest rate and term
|
|
||||||
- **Risk level**: Very low - payments are contractually guaranteed
|
|
||||||
|
|
||||||
### Advantages for CSPs
|
|
||||||
- **Predictable cash flow**: Fixed monthly income regardless of market conditions
|
|
||||||
- **Low risk**: Not dependent on Servala's business performance
|
|
||||||
- **Simple structure**: Traditional lending arrangement with clear terms
|
|
||||||
- **Principal protection**: Loan principal is protected by Servala's assets and business
|
|
||||||
|
|
||||||
### Considerations
|
|
||||||
- **Limited upside**: Returns are capped at the agreed interest rate
|
|
||||||
- **No performance benefits**: CSP doesn't benefit from exceptional business growth
|
|
||||||
- **Market rate dependency**: Returns may lag behind high-performing investment alternatives
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Investment Model #2: Direct Investment Model
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
The Direct Investment Model allows CSPs to invest directly in Servala's platform operations, earning returns through revenue sharing that scales with business performance and their active involvement in sales.
|
|
||||||
|
|
||||||
### Key Characteristics
|
|
||||||
|
|
||||||
#### Financial Structure
|
|
||||||
- **Investment Range**: CHF 100,000 - CHF 2,000,000
|
|
||||||
- **Revenue Sharing**: 15-35% to Servala (remainder to CSP)
|
|
||||||
- **Grace Period**: 6+ months of 100% revenue retention for CSP
|
|
||||||
- **Performance Bonuses**: Up to 15% additional revenue share for high performers
|
|
||||||
|
|
||||||
#### Investment Scaling Benefits
|
|
||||||
Direct investments unlock operational advantages through progressive scaling:
|
|
||||||
|
|
||||||
| Investment Amount | Scaling Factor | Customer Acquisition | Churn Reduction |
|
|
||||||
|------------------|----------------|----------------------|-----------------|
|
|
||||||
| CHF 500,000 | 1.0x | Baseline | 0% |
|
|
||||||
| CHF 750,000 | 1.25x | +25% vs baseline | 10% |
|
|
||||||
| CHF 1,000,000 | 1.5x | +50% vs baseline | 20% |
|
|
||||||
| CHF 1,500,000 | 1.75x | +75% vs baseline | 30% |
|
|
||||||
| CHF 2,000,000 | 2.0x | +100% vs baseline | 40% |
|
|
||||||
|
|
||||||
#### Dynamic Grace Period
|
|
||||||
Grace periods extend with larger investments, providing longer periods of 100% revenue retention:
|
|
||||||
|
|
||||||
- **Base Grace Period**: 6 months
|
|
||||||
- **Extension Formula**: +1 month per CHF 250,000 invested above CHF 500,000
|
|
||||||
- **Maximum Grace Period**: 50% of total investment timeframe
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- CHF 500,000 investment → 6 months grace period
|
|
||||||
- CHF 1,000,000 investment → 8 months grace period
|
|
||||||
- CHF 2,000,000 investment → 12 months grace period
|
|
||||||
|
|
||||||
#### Performance-Based Revenue Split
|
|
||||||
CSPs who exceed baseline performance expectations receive enhanced revenue sharing:
|
|
||||||
|
|
||||||
- **Performance Threshold**: 110% of baseline instance growth
|
|
||||||
- **Bonus Calculation**: Up to 15% reduction in Servala's revenue share
|
|
||||||
- **Bonus Formula**: `Bonus = min(15%, (Performance Ratio - 1.1) × 30%)`
|
|
||||||
- **Minimum Servala Share**: 10% (maximum bonus protection)
|
|
||||||
|
|
||||||
### Revenue Distribution Phases
|
|
||||||
|
|
||||||
#### Phase 1: Grace Period (Months 1-N)
|
|
||||||
- **CSP**: 100% of monthly revenue
|
|
||||||
- **Servala**: 0%
|
|
||||||
- **Duration**: Based on investment amount (6-12 months)
|
|
||||||
|
|
||||||
#### Phase 2: Standard Revenue Sharing (Post-Grace Period)
|
|
||||||
- **CSP**: 65-90% of monthly revenue (depending on negotiated split)
|
|
||||||
- **Servala**: 10-35% of monthly revenue
|
|
||||||
- **Performance bonuses**: Applied based on CSP sales results
|
|
||||||
|
|
||||||
#### Phase 3: Performance Enhancement (Ongoing)
|
|
||||||
- **Additional bonuses**: Available for CSPs exceeding 110% of baseline performance
|
|
||||||
- **Reduced Servala share**: Performance bonuses reduce Servala's percentage
|
|
||||||
- **Incentive alignment**: Rewards active sales participation by CSP
|
|
||||||
|
|
||||||
### Break-Even Analysis
|
|
||||||
Break-even for direct investment depends on multiple factors:
|
|
||||||
|
|
||||||
#### Conservative Scenario (2% monthly churn)
|
|
||||||
- **Typical break-even**: 18-24 months
|
|
||||||
- **Factors**: Lower customer acquisition, baseline performance
|
|
||||||
- **Risk level**: Moderate
|
|
||||||
|
|
||||||
#### Moderate Scenario (3% monthly churn)
|
|
||||||
- **Typical break-even**: 15-20 months
|
|
||||||
- **Factors**: Balanced growth and churn rates
|
|
||||||
- **Risk level**: Moderate-High
|
|
||||||
|
|
||||||
#### Aggressive Scenario (5% monthly churn)
|
|
||||||
- **Typical break-even**: 12-18 months (if performance targets met)
|
|
||||||
- **Factors**: High growth potential, requires active CSP involvement
|
|
||||||
- **Risk level**: Higher, but potentially higher returns
|
|
||||||
|
|
||||||
### Advantages for CSPs
|
|
||||||
- **Unlimited upside potential**: Returns scale with business success
|
|
||||||
- **Performance incentives**: Bonuses for exceeding expectations
|
|
||||||
- **Extended grace periods**: Larger investments get longer 100% revenue periods
|
|
||||||
- **Operational scaling**: Investment size directly improves business outcomes
|
|
||||||
- **Market expansion**: Direct involvement in growing managed services market
|
|
||||||
|
|
||||||
### Considerations
|
|
||||||
- **Performance dependency**: Returns vary based on business performance
|
|
||||||
- **Active participation beneficial**: Better results when CSP actively promotes services
|
|
||||||
- **Market risk**: Subject to cloud services market fluctuations
|
|
||||||
- **Longer break-even periods**: Typically requires 15-24 months to break even
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparative Analysis
|
|
||||||
|
|
||||||
### When to Choose Loan Model
|
|
||||||
|
|
||||||
**Ideal for CSPs who:**
|
|
||||||
- Prioritize predictable, guaranteed returns
|
|
||||||
- Have limited capacity for active sales involvement
|
|
||||||
- Prefer traditional lending relationships
|
|
||||||
- Want to minimize investment risk
|
|
||||||
- Need steady cash flow for other operations
|
|
||||||
|
|
||||||
**Financial Profile:**
|
|
||||||
- Risk tolerance: Low
|
|
||||||
- Return expectations: 3-7% annually
|
|
||||||
- Time horizon: 1-3 years
|
|
||||||
- Involvement level: Passive
|
|
||||||
|
|
||||||
### When to Choose Direct Investment Model
|
|
||||||
|
|
||||||
**Ideal for CSPs who:**
|
|
||||||
- Want to maximize return potential
|
|
||||||
- Can actively promote and sell managed services
|
|
||||||
- Are comfortable with performance-based returns
|
|
||||||
- Seek strategic partnership opportunities
|
|
||||||
- Have longer investment horizons
|
|
||||||
|
|
||||||
**Financial Profile:**
|
|
||||||
- Risk tolerance: Moderate to High
|
|
||||||
- Return expectations: 15-40% annually (performance dependent)
|
|
||||||
- Time horizon: 2-5 years
|
|
||||||
- Involvement level: Active
|
|
||||||
|
|
||||||
### ROI Comparison Examples
|
|
||||||
|
|
||||||
#### CHF 1,000,000 Investment Over 3 Years
|
|
||||||
|
|
||||||
**Loan Model (5% annual rate):**
|
|
||||||
- Monthly payment: ~CHF 30,000
|
|
||||||
- Total return: CHF 1,080,000
|
|
||||||
- Net profit: CHF 80,000
|
|
||||||
- ROI: 8% over 3 years
|
|
||||||
|
|
||||||
**Direct Investment Model (Moderate Scenario):**
|
|
||||||
- Year 1: CHF 120,000 (with 8-month grace period)
|
|
||||||
- Year 2: CHF 180,000 (with performance bonuses)
|
|
||||||
- Year 3: CHF 240,000 (mature customer base)
|
|
||||||
- Total return: CHF 540,000
|
|
||||||
- Net profit: CHF 540,000 - CHF 1,000,000 = break-even in month 22
|
|
||||||
- ROI: 35% over 3 years (if targets met)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risk Assessment
|
|
||||||
|
|
||||||
### Loan Model Risks
|
|
||||||
- **Interest rate risk**: Fixed rates may underperform market
|
|
||||||
- **Opportunity cost**: Missing higher-return investment opportunities
|
|
||||||
- **Inflation risk**: Fixed payments lose value over time
|
|
||||||
- **Credit risk**: Minimal, backed by Servala's business assets
|
|
||||||
|
|
||||||
**Risk Mitigation:**
|
|
||||||
- Due diligence on Servala's financial stability
|
|
||||||
- Legal documentation ensuring payment priority
|
|
||||||
- Regular financial monitoring and reporting
|
|
||||||
|
|
||||||
### Direct Investment Model Risks
|
|
||||||
- **Performance risk**: Returns depend on business success
|
|
||||||
- **Market risk**: Cloud services market volatility
|
|
||||||
- **Execution risk**: Depends on CSP's sales capabilities
|
|
||||||
- **Competition risk**: New competitors may impact growth
|
|
||||||
|
|
||||||
**Risk Mitigation:**
|
|
||||||
- Diversification across multiple growth scenarios
|
|
||||||
- Performance bonuses aligned with CSP incentives
|
|
||||||
- Regular monitoring and adjustment capabilities
|
|
||||||
- Grace periods provide initial return protection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Investment Process
|
|
||||||
|
|
||||||
### Step 1: Initial Assessment
|
|
||||||
1. **Investment capacity evaluation**: Determine available capital
|
|
||||||
2. **Risk tolerance assessment**: Choose appropriate model
|
|
||||||
3. **Strategic alignment review**: Evaluate fit with CSP goals
|
|
||||||
4. **Market analysis**: Understand local demand for managed services
|
|
||||||
|
|
||||||
### Step 2: Due Diligence
|
|
||||||
1. **Financial review**: Examine Servala's financial statements
|
|
||||||
2. **Technical assessment**: Understand platform capabilities
|
|
||||||
3. **Market validation**: Verify demand in target regions
|
|
||||||
4. **Reference checks**: Speak with existing partners
|
|
||||||
|
|
||||||
### Step 3: Terms Negotiation
|
|
||||||
1. **Investment amount**: Determine capital commitment
|
|
||||||
2. **Model selection**: Choose loan vs. direct investment
|
|
||||||
3. **Terms customization**: Negotiate rates, periods, splits
|
|
||||||
4. **Performance metrics**: Define success criteria and bonuses
|
|
||||||
|
|
||||||
### Step 4: Legal Documentation
|
|
||||||
1. **Investment agreement**: Comprehensive terms and conditions
|
|
||||||
2. **Operational guidelines**: Define roles and responsibilities
|
|
||||||
3. **Reporting requirements**: Establish monitoring and reporting
|
|
||||||
4. **Exit clauses**: Plan for investment conclusion
|
|
||||||
|
|
||||||
### Step 5: Implementation
|
|
||||||
1. **Capital deployment**: Transfer investment funds
|
|
||||||
2. **Systems integration**: Implement monitoring and reporting
|
|
||||||
3. **Sales enablement**: Train CSP teams (for direct investment)
|
|
||||||
4. **Performance tracking**: Begin monitoring key metrics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Performance Indicators (KPIs)
|
|
||||||
|
|
||||||
### Financial Metrics
|
|
||||||
- **Monthly Recurring Revenue (MRR)**: Total monthly income from services
|
|
||||||
- **Customer Acquisition Cost (CAC)**: Cost to acquire new customers
|
|
||||||
- **Customer Lifetime Value (CLV)**: Total revenue per customer
|
|
||||||
- **Monthly churn rate**: Percentage of customers lost per month
|
|
||||||
- **Net Revenue Retention**: Revenue growth from existing customers
|
|
||||||
|
|
||||||
### Investment-Specific Metrics
|
|
||||||
- **Break-even timeline**: Months to recover initial investment
|
|
||||||
- **Return on Investment (ROI)**: Total return percentage
|
|
||||||
- **Performance bonus earnings**: Additional income from exceeding targets
|
|
||||||
- **Grace period utilization**: Revenue during 100% retention period
|
|
||||||
|
|
||||||
### Operational Metrics
|
|
||||||
- **Instance deployment rate**: New services launched monthly
|
|
||||||
- **Customer satisfaction scores**: Service quality indicators
|
|
||||||
- **Support ticket resolution time**: Operational efficiency
|
|
||||||
- **Platform uptime**: Technical reliability metrics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frequently Asked Questions
|
|
||||||
|
|
||||||
### General Questions
|
|
||||||
|
|
||||||
**Q: Can I switch between investment models during the term?**
|
|
||||||
A: Model changes require mutual agreement and may involve restructuring fees. Generally, changes are evaluated at renewal periods.
|
|
||||||
|
|
||||||
**Q: What happens if Servala's business underperforms?**
|
|
||||||
A: Loan Model investors are protected through contractual guarantees. Direct Investment returns will reflect actual performance, but grace periods provide initial protection.
|
|
||||||
|
|
||||||
**Q: How are disputes resolved?**
|
|
||||||
A: Investment agreements include arbitration clauses and dispute resolution procedures. Regular communication helps prevent conflicts.
|
|
||||||
|
|
||||||
### Loan Model Questions
|
|
||||||
|
|
||||||
**Q: Are loan payments guaranteed?**
|
|
||||||
A: Yes, loan payments are contractually guaranteed and backed by Servala's business assets and cash flow.
|
|
||||||
|
|
||||||
**Q: Can I prepay or extend the loan term?**
|
|
||||||
A: Terms can be modified by mutual agreement. Prepayment may involve early payment discounts or penalties as specified in the agreement.
|
|
||||||
|
|
||||||
### Direct Investment Questions
|
|
||||||
|
|
||||||
**Q: How is performance measured for bonus calculations?**
|
|
||||||
A: Performance is measured against baseline growth scenarios using actual vs. projected customer instance growth over rolling 6-month periods.
|
|
||||||
|
|
||||||
**Q: What constitutes "active sales participation"?**
|
|
||||||
A: This includes promoting Servala services to existing customers, participating in joint sales activities, and meeting agreed-upon referral targets.
|
|
||||||
|
|
||||||
**Q: How often are performance bonuses calculated and paid?**
|
|
||||||
A: Performance bonuses are calculated monthly and applied to that month's revenue sharing. Payments follow the standard monthly distribution schedule.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contact and Next Steps
|
|
||||||
|
|
||||||
For detailed discussions about investment opportunities:
|
|
||||||
|
|
||||||
1. **Schedule consultation**: Review your specific requirements and objectives
|
|
||||||
2. **Receive custom proposal**: Get tailored investment terms and projections
|
|
||||||
3. **Complete due diligence**: Access detailed financial and operational data
|
|
||||||
4. **Finalize agreement**: Execute legal documentation and begin partnership
|
|
||||||
|
|
||||||
This investment represents an opportunity to participate in the rapidly growing managed cloud services market while supporting innovation in cloud infrastructure automation and management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This document is for informational purposes only and does not constitute financial advice. All investment decisions should be made after careful consideration of your individual circumstances and consultation with appropriate financial and legal advisors.*
|
|
||||||
|
|
||||||
**Document Version**: 1.0
|
|
||||||
**Last Updated**: December 2024
|
|
||||||
**Document Owner**: Servala Investment Relations Team
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""
|
|
||||||
RSS Feeds for the Servala website
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.contrib.syndication.views import Feed
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.feedgenerator import Rss201rev2Feed
|
|
||||||
from django.utils.html import strip_tags
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from .models import Article
|
|
||||||
|
|
||||||
|
|
||||||
class ArticleRSSFeed(Feed):
|
|
||||||
"""RSS feed for published articles"""
|
|
||||||
|
|
||||||
title = "Servala Articles"
|
|
||||||
link = "/articles/"
|
|
||||||
description = "Latest articles about cloud services, consulting partners, and technology insights from Servala"
|
|
||||||
feed_type = Rss201rev2Feed
|
|
||||||
|
|
||||||
def items(self):
|
|
||||||
"""Return the latest 20 published articles"""
|
|
||||||
return Article.objects.filter(is_published=True).order_by("-article_date")[:20]
|
|
||||||
|
|
||||||
def item_title(self, item):
|
|
||||||
"""Return the article title"""
|
|
||||||
return item.title
|
|
||||||
|
|
||||||
def item_description(self, item):
|
|
||||||
"""Return the article excerpt with 'Read more' link"""
|
|
||||||
base_url = "https://servala.com"
|
|
||||||
|
|
||||||
# Use the excerpt and add a proper HTML read more link
|
|
||||||
excerpt = strip_tags(item.excerpt)
|
|
||||||
article_url = f"{base_url}{item.get_absolute_url()}"
|
|
||||||
|
|
||||||
# Return HTML content for the RSS description
|
|
||||||
return f'{excerpt} <a href="{article_url}">Read more...</a>'
|
|
||||||
|
|
||||||
def item_link(self, item):
|
|
||||||
"""Return the link to the article detail page"""
|
|
||||||
return item.get_absolute_url()
|
|
||||||
|
|
||||||
def item_guid(self, item):
|
|
||||||
"""Return a unique identifier for the item"""
|
|
||||||
return f"article-{item.id}"
|
|
||||||
|
|
||||||
def item_pubdate(self, item):
|
|
||||||
"""Return the publication date"""
|
|
||||||
# Convert date to datetime for RSS compatibility
|
|
||||||
from datetime import datetime, time
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
# Combine the date with midnight time and make it timezone-aware
|
|
||||||
dt = datetime.combine(item.article_date, time.min)
|
|
||||||
return timezone.make_aware(dt, timezone.get_current_timezone())
|
|
||||||
|
|
||||||
def item_author_name(self, item):
|
|
||||||
"""Return the author name"""
|
|
||||||
return item.author.get_full_name() or item.author.username
|
|
||||||
|
|
||||||
def item_categories(self, item):
|
|
||||||
"""Return categories for the article"""
|
|
||||||
categories = []
|
|
||||||
|
|
||||||
# Add related entity as category
|
|
||||||
if item.related_service:
|
|
||||||
categories.append(f"Service: {item.related_service.name}")
|
|
||||||
if item.related_consulting_partner:
|
|
||||||
categories.append(f"Partner: {item.related_consulting_partner.name}")
|
|
||||||
if item.related_cloud_provider:
|
|
||||||
categories.append(f"Provider: {item.related_cloud_provider.name}")
|
|
||||||
|
|
||||||
# Add meta keywords as categories if available
|
|
||||||
if item.meta_keywords:
|
|
||||||
keywords = [keyword.strip() for keyword in item.meta_keywords.split(",")]
|
|
||||||
categories.extend(keywords)
|
|
||||||
|
|
||||||
return categories
|
|
||||||
|
|
@ -67,12 +67,3 @@
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
|
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure collapse starts properly hidden */
|
|
||||||
#managedServiceIncludes {
|
|
||||||
transition: all 0.35s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#managedServiceIncludes:not(.show) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
@ -1,949 +0,0 @@
|
||||||
.calculator-section {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group-custom {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
position: relative;
|
|
||||||
/* Ensure proper stacking context */
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group-custom label {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure input groups don't interfere with tooltips */
|
|
||||||
.input-group {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-container {
|
|
||||||
position: relative;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: #ddd;
|
|
||||||
outline: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #007bff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #007bff;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-card {
|
|
||||||
border: 2px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-card.active {
|
|
||||||
border-color: #007bff;
|
|
||||||
background-color: #f8f9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-card.disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #007bff;
|
|
||||||
line-height: 1.2;
|
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-label {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced chart sizing for new layout */
|
|
||||||
.chart-container canvas {
|
|
||||||
max-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Full-width chart containers */
|
|
||||||
.card-body canvas {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Primary chart gets extra height */
|
|
||||||
#instanceGrowthChart {
|
|
||||||
height: 500px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Secondary charts get good height */
|
|
||||||
#revenueChart, #cashFlowChart, #cspRevenueChart {
|
|
||||||
height: 400px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced layout styles for new design */
|
|
||||||
.sticky-top {
|
|
||||||
z-index: 1020;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
transition: box-shadow 0.15s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Compact header controls */
|
|
||||||
.form-range {
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group-sm .form-control,
|
|
||||||
.form-select-sm,
|
|
||||||
.btn-sm {
|
|
||||||
font-size: 0.825rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clean chart headers */
|
|
||||||
.card-header {
|
|
||||||
background: white !important;
|
|
||||||
border: none !important;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive chart heights */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#instanceGrowthChart {
|
|
||||||
height: 350px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#revenueChart, #cashFlowChart, #cspRevenueChart {
|
|
||||||
height: 300px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
#instanceGrowthChart {
|
|
||||||
height: 250px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#revenueChart, #cashFlowChart, #cspRevenueChart {
|
|
||||||
height: 200px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Manual collapse functionality */
|
|
||||||
.collapse {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse.show {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsing {
|
|
||||||
position: relative;
|
|
||||||
height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: height 0.35s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-buttons {
|
|
||||||
position: sticky;
|
|
||||||
top: 20px;
|
|
||||||
background: white;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-section {
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-header {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-header:hover {
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-content {
|
|
||||||
padding: 1rem;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-content.show {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phase-settings {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.currency-symbol {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
display: none;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-content {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-content h6 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-content p {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-content p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bootstrap tooltip styling improvements */
|
|
||||||
.tooltip {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
z-index: 9999 !important;
|
|
||||||
/* Ensure tooltips appear above all other elements */
|
|
||||||
pointer-events: none;
|
|
||||||
/* Prevent tooltip from interfering with mouse events */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip .tooltip-inner {
|
|
||||||
max-width: 250px;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
color: #fff;
|
|
||||||
background-color: #212529;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.15);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip .tooltip-arrow {
|
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.bs-tooltip-top .tooltip-arrow::before {
|
|
||||||
border-top-color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.bs-tooltip-bottom .tooltip-arrow::before {
|
|
||||||
border-bottom-color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.bs-tooltip-start .tooltip-arrow::before {
|
|
||||||
border-left-color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.bs-tooltip-end .tooltip-arrow::before {
|
|
||||||
border-right-color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced cursor for tooltip elements */
|
|
||||||
[data-bs-toggle="tooltip"] {
|
|
||||||
cursor: help;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-toggle="tooltip"]:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.chart-container {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.metric-value {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clean Dual Model Layout */
|
|
||||||
.model-comparison-indicator {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scenario selection enhancements */
|
|
||||||
.form-check {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-label {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-input:checked {
|
|
||||||
background-color: #0d6efd;
|
|
||||||
border-color: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Model result boxes */
|
|
||||||
.model-result-box {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-result-box:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced main configuration styling */
|
|
||||||
.main-config-section {
|
|
||||||
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields {
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .form-label {
|
|
||||||
color: #2c3e50;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .input-group-text {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-color: #dee2e6;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .form-control {
|
|
||||||
border-color: #dee2e6;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .form-control:focus {
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Investment amount input enhancements */
|
|
||||||
#investment-amount {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#investment-amount:focus {
|
|
||||||
text-align: left;
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#investment-amount::placeholder {
|
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
font-weight: normal;
|
|
||||||
text-align: left;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .form-select {
|
|
||||||
border-color: #dee2e6;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .form-select:focus {
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Results Panel Styling */
|
|
||||||
.results-panel {
|
|
||||||
position: sticky;
|
|
||||||
top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-panel .card {
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-panel .card-header {
|
|
||||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
|
||||||
border-bottom: none;
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-panel .card-body {
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item:hover {
|
|
||||||
background: rgba(0, 123, 255, 0.02);
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item:hover .result-icon {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-metrics {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item:hover .result-metrics {
|
|
||||||
border-color: #007bff;
|
|
||||||
background: rgba(248, 249, 250, 0.8) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced form styling */
|
|
||||||
.form-check-lg .form-check-input {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-lg .form-check-label {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced range sliders */
|
|
||||||
.form-range {
|
|
||||||
height: 8px;
|
|
||||||
background: linear-gradient(to right, #e9ecef 0%, #dee2e6 100%);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 1rem 0 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-webkit-slider-thumb {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
|
||||||
border: 3px solid #ffffff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-moz-range-thumb {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
|
||||||
border: 3px solid #ffffff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-moz-range-thumb:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button styling improvements */
|
|
||||||
.btn-lg {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-info:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-secondary:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Advanced controls styling */
|
|
||||||
.advanced-controls-section {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-top: 3px solid #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-controls-section .card {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-controls-section .card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-controls-section .card-header {
|
|
||||||
font-weight: 600;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slider styling improvements */
|
|
||||||
.form-range {
|
|
||||||
height: 6px;
|
|
||||||
background: #e9ecef;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-webkit-slider-thumb {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: #007bff;
|
|
||||||
border: 3px solid #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-moz-range-thumb {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: #007bff;
|
|
||||||
border: 3px solid #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Real-time results cards */
|
|
||||||
.results-card {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-card:hover {
|
|
||||||
border-color: #007bff;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Growth scenario checkboxes */
|
|
||||||
.form-check-input:checked {
|
|
||||||
background-color: #007bff;
|
|
||||||
border-color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-label {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-label:hover {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive improvements */
|
|
||||||
@media (max-width: 991px) {
|
|
||||||
.results-panel {
|
|
||||||
position: static;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-panel .card-body {
|
|
||||||
min-height: auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.main-config-fields {
|
|
||||||
padding: 1rem;
|
|
||||||
margin: 0 -15px;
|
|
||||||
border-radius: 0;
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .form-label {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-config-fields .form-control,
|
|
||||||
.main-config-fields .form-select {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-lg {
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-lg .form-check-input {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-lg .form-check-label {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-panel .card-header {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-panel .card-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-icon {
|
|
||||||
width: 35px !important;
|
|
||||||
height: 35px !important;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-controls-section .card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-controls-section .card-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.d-flex.gap-3.flex-wrap {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-lg {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-lg {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-panel .card-body {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-metrics .row {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-metrics .col-6 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Model comparison box */
|
|
||||||
.model-comparison-box {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-comparison-box:hover {
|
|
||||||
border-color: #adb5bd;
|
|
||||||
background-color: #f8f9fa !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Control section styling */
|
|
||||||
.controls-section {
|
|
||||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced form controls */
|
|
||||||
.form-range {
|
|
||||||
height: 4px;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-webkit-slider-thumb {
|
|
||||||
background: #0d6efd;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-range::-moz-range-thumb {
|
|
||||||
background: #0d6efd;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button group styling */
|
|
||||||
.btn-group-enhanced .btn {
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group-enhanced .btn:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.controls-section {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-result-box {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced table styling for financial analysis */
|
|
||||||
.table-hover tbody tr:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-success {
|
|
||||||
--bs-table-bg: rgba(40, 167, 69, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-warning {
|
|
||||||
--bs-table-bg: rgba(255, 193, 7, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tab styling improvements */
|
|
||||||
.nav-tabs .nav-link {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-top-left-radius: 0.375rem;
|
|
||||||
border-top-right-radius: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-tabs .nav-link.active {
|
|
||||||
color: #495057;
|
|
||||||
background-color: #fff;
|
|
||||||
border-color: #dee2e6 #dee2e6 #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-tabs .nav-link:hover:not(.active) {
|
|
||||||
border-color: #e9ecef #e9ecef #dee2e6;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table cell improvements */
|
|
||||||
.table td {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
border-top: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Better responsive tables */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.table-responsive {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th, .table td {
|
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Monthly Breakdown Filter Controls */
|
|
||||||
.breakdown-filters {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-filters .form-check {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-filters .form-check-input:checked {
|
|
||||||
background-color: #0d6efd;
|
|
||||||
border-color: #0d6efd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-filters .form-check-label {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive breakdown filters */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.breakdown-filters {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-filters .d-flex {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced sticky header for monthly breakdown table */
|
|
||||||
.table-responsive {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive .sticky-top {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
background-color: #212529 !important; /* Ensure dark background stays */
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive .sticky-top th {
|
|
||||||
background-color: #212529 !important;
|
|
||||||
border-color: #454d55 !important;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure sticky header works in scrollable containers */
|
|
||||||
.table-responsive[style*="max-height"] .sticky-top {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional styling for better visibility */
|
|
||||||
.table-dark th {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-dark th:first-child {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-dark th:nth-child(n+4) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 316 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
398
hub/services/static/js/jspdf.umd.min.js
vendored
398
hub/services/static/js/jspdf.umd.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -25,8 +25,6 @@ class DOMManager {
|
||||||
this.elements.addonsContainer = document.getElementById('addonsContainer');
|
this.elements.addonsContainer = document.getElementById('addonsContainer');
|
||||||
this.elements.addonPricingContainer = document.getElementById('addonPricingContainer');
|
this.elements.addonPricingContainer = document.getElementById('addonPricingContainer');
|
||||||
this.elements.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer');
|
this.elements.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer');
|
||||||
this.elements.managedServiceIncludes = document.getElementById('managedServiceIncludes');
|
|
||||||
this.elements.managedServiceToggleButton = document.querySelector('button[data-bs-target="#managedServiceIncludes"]');
|
|
||||||
|
|
||||||
// Result display elements
|
// Result display elements
|
||||||
this.elements.planMatchStatus = document.getElementById('planMatchStatus');
|
this.elements.planMatchStatus = document.getElementById('planMatchStatus');
|
||||||
|
|
@ -53,16 +51,12 @@ class DOMManager {
|
||||||
this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup');
|
this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get element by key with error handling
|
// Get element by key
|
||||||
get(key) {
|
get(key) {
|
||||||
const element = this.elements[key];
|
return this.elements[key];
|
||||||
if (!element && key !== 'addonsContainer') {
|
|
||||||
console.warn(`DOM element '${key}' not found`);
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if element exists and is valid
|
// Check if element exists
|
||||||
has(key) {
|
has(key) {
|
||||||
return this.elements[key] && this.elements[key] !== null;
|
return this.elements[key] && this.elements[key] !== null;
|
||||||
}
|
}
|
||||||
|
|
@ -145,36 +139,6 @@ class DOMManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set smart default values based on available plans
|
|
||||||
setSmartDefaults(pricingDataManager) {
|
|
||||||
const { cpuValues, memoryValues } = pricingDataManager.getAvailableSliderValues();
|
|
||||||
|
|
||||||
// Use the smallest available CPU value as default
|
|
||||||
if (cpuValues.length > 0 && this.elements.cpuRange) {
|
|
||||||
const defaultCpu = Math.min(...cpuValues);
|
|
||||||
this.elements.cpuRange.value = defaultCpu;
|
|
||||||
if (this.elements.cpuValue) this.elements.cpuValue.textContent = defaultCpu;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the smallest available memory value as default
|
|
||||||
if (memoryValues.length > 0 && this.elements.memoryRange) {
|
|
||||||
const defaultMemory = Math.min(...memoryValues);
|
|
||||||
this.elements.memoryRange.value = defaultMemory;
|
|
||||||
if (this.elements.memoryValue) this.elements.memoryValue.textContent = defaultMemory;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep existing defaults for storage and instances
|
|
||||||
if (this.elements.storageRange) {
|
|
||||||
this.elements.storageRange.value = '20';
|
|
||||||
if (this.elements.storageValue) this.elements.storageValue.textContent = '20';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.elements.instancesRange) {
|
|
||||||
this.elements.instancesRange.value = '1';
|
|
||||||
if (this.elements.instancesValue) this.elements.instancesValue.textContent = '1';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current selected service level
|
// Get current selected service level
|
||||||
getSelectedServiceLevel() {
|
getSelectedServiceLevel() {
|
||||||
return document.querySelector('input[name="serviceLevel"]:checked')?.value;
|
return document.querySelector('input[name="serviceLevel"]:checked')?.value;
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,7 @@ class OrderManager {
|
||||||
messageField.value = configMessage;
|
messageField.value = configMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and fill alternative message field if the first one doesn't exist
|
// Store configuration details in hidden field
|
||||||
if (!messageField) {
|
|
||||||
const altMessageField = document.querySelector('textarea[name="message"]');
|
|
||||||
if (altMessageField) {
|
|
||||||
altMessageField.value = configMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store configuration details in hidden field if it exists
|
|
||||||
const detailsField = document.querySelector('#order-form input[name="details"]');
|
const detailsField = document.querySelector('#order-form input[name="details"]');
|
||||||
if (detailsField) {
|
if (detailsField) {
|
||||||
detailsField.value = JSON.stringify({
|
detailsField.value = JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,7 @@ class PlanManager {
|
||||||
if (!planSelect) return;
|
if (!planSelect) return;
|
||||||
|
|
||||||
const serviceLevel = domManager.getSelectedServiceLevel();
|
const serviceLevel = domManager.getSelectedServiceLevel();
|
||||||
if (!serviceLevel) {
|
if (!serviceLevel) return;
|
||||||
// Clear dropdown if no service level is selected
|
|
||||||
planSelect.innerHTML = '<option value="">Select a service level first</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing options
|
// Clear existing options
|
||||||
planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>';
|
planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>';
|
||||||
|
|
@ -76,11 +72,6 @@ class PlanManager {
|
||||||
// Get plans for the selected service level
|
// Get plans for the selected service level
|
||||||
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
|
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
|
||||||
|
|
||||||
if (!availablePlans || availablePlans.length === 0) {
|
|
||||||
planSelect.innerHTML = '<option value="">No plans available for this service level</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add plans to dropdown
|
// Add plans to dropdown
|
||||||
availablePlans.forEach(plan => {
|
availablePlans.forEach(plan => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,17 @@
|
||||||
*/
|
*/
|
||||||
class PriceCalculator {
|
class PriceCalculator {
|
||||||
constructor() {
|
constructor() {
|
||||||
try {
|
// Initialize managers
|
||||||
// Initialize managers
|
this.domManager = new DOMManager();
|
||||||
this.domManager = new DOMManager();
|
this.currentOffering = this.extractOfferingFromURL();
|
||||||
this.currentOffering = this.extractOfferingFromURL();
|
this.pricingDataManager = new PricingDataManager(this.currentOffering);
|
||||||
|
this.planManager = new PlanManager(this.pricingDataManager);
|
||||||
|
this.addonManager = new AddonManager(this.pricingDataManager);
|
||||||
|
this.uiManager = new UIManager();
|
||||||
|
this.orderManager = new OrderManager();
|
||||||
|
|
||||||
if (!this.currentOffering) {
|
// Initialize the calculator
|
||||||
throw new Error('Unable to extract offering information from URL');
|
this.init();
|
||||||
}
|
|
||||||
|
|
||||||
this.pricingDataManager = new PricingDataManager(this.currentOffering);
|
|
||||||
this.planManager = new PlanManager(this.pricingDataManager);
|
|
||||||
this.addonManager = new AddonManager(this.pricingDataManager);
|
|
||||||
this.uiManager = new UIManager();
|
|
||||||
this.orderManager = new OrderManager();
|
|
||||||
|
|
||||||
// Initialize the calculator
|
|
||||||
this.init();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing PriceCalculator:', error);
|
|
||||||
this.showInitializationError(error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract offering info from URL
|
// Extract offering info from URL
|
||||||
|
|
@ -51,24 +41,11 @@ class PriceCalculator {
|
||||||
this.orderManager.setupOrderButton(this.domManager);
|
this.orderManager.setupOrderButton(this.domManager);
|
||||||
this.updateCalculator();
|
this.updateCalculator();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No current offering found');
|
console.warn('No current offering found, calculator not initialized');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing price calculator:', error);
|
console.error('Error initializing price calculator:', error);
|
||||||
this.showInitializationError(error.message);
|
this.uiManager.showError(this.domManager, 'Failed to load pricing information');
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show initialization error to user
|
|
||||||
showInitializationError(message) {
|
|
||||||
const planMatchStatus = this.domManager?.get('planMatchStatus');
|
|
||||||
if (planMatchStatus) {
|
|
||||||
planMatchStatus.innerHTML = `
|
|
||||||
<i class="bi bi-exclamation-triangle me-2 text-danger"></i>
|
|
||||||
<span class="text-danger">Failed to load pricing calculator: ${message}</span>
|
|
||||||
`;
|
|
||||||
planMatchStatus.className = 'alert alert-danger mb-3';
|
|
||||||
planMatchStatus.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,20 +54,14 @@ class PriceCalculator {
|
||||||
// Setup service levels based on available data
|
// Setup service levels based on available data
|
||||||
this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager);
|
this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager);
|
||||||
|
|
||||||
// Calculate and set slider maximums and ranges
|
// Calculate and set slider maximums
|
||||||
this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager);
|
this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager);
|
||||||
|
|
||||||
// Set smart default values based on available plans
|
|
||||||
this.domManager.setSmartDefaults(this.pricingDataManager);
|
|
||||||
|
|
||||||
// Populate plan dropdown
|
// Populate plan dropdown
|
||||||
this.planManager.populatePlanDropdown(this.domManager);
|
this.planManager.populatePlanDropdown(this.domManager);
|
||||||
|
|
||||||
// Initialize instances slider
|
// Initialize instances slider
|
||||||
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
|
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
|
||||||
|
|
||||||
// Setup service level event listeners after UI is created
|
|
||||||
this.setupServiceLevelEventListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup event listeners for calculator controls
|
// Setup event listeners for calculator controls
|
||||||
|
|
@ -105,21 +76,11 @@ class PriceCalculator {
|
||||||
// Slider event listeners
|
// Slider event listeners
|
||||||
cpuRange.addEventListener('input', () => {
|
cpuRange.addEventListener('input', () => {
|
||||||
this.domManager.get('cpuValue').textContent = cpuRange.value;
|
this.domManager.get('cpuValue').textContent = cpuRange.value;
|
||||||
// Only synchronize if in auto-select mode (no manual plan selection)
|
|
||||||
const planSelect = this.domManager.get('planSelect');
|
|
||||||
if (!planSelect?.value) {
|
|
||||||
this.synchronizeMemoryToMatchingPlan(parseFloat(cpuRange.value));
|
|
||||||
}
|
|
||||||
this.updatePricing();
|
this.updatePricing();
|
||||||
});
|
});
|
||||||
|
|
||||||
memoryRange.addEventListener('input', () => {
|
memoryRange.addEventListener('input', () => {
|
||||||
this.domManager.get('memoryValue').textContent = memoryRange.value;
|
this.domManager.get('memoryValue').textContent = memoryRange.value;
|
||||||
// Only synchronize if in auto-select mode (no manual plan selection)
|
|
||||||
const planSelect = this.domManager.get('planSelect');
|
|
||||||
if (!planSelect?.value) {
|
|
||||||
this.synchronizeCpuToMatchingPlan(parseFloat(memoryRange.value));
|
|
||||||
}
|
|
||||||
this.updatePricing();
|
this.updatePricing();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,6 +94,17 @@ class PriceCalculator {
|
||||||
this.updatePricing();
|
this.updatePricing();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Service level change listeners
|
||||||
|
const serviceLevelInputs = this.domManager.get('serviceLevelInputs');
|
||||||
|
serviceLevelInputs.forEach(input => {
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
|
||||||
|
this.planManager.populatePlanDropdown(this.domManager);
|
||||||
|
this.addonManager.updateAddons(this.domManager);
|
||||||
|
this.updatePricing();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Plan selection listener
|
// Plan selection listener
|
||||||
const planSelect = this.domManager.get('planSelect');
|
const planSelect = this.domManager.get('planSelect');
|
||||||
if (planSelect) {
|
if (planSelect) {
|
||||||
|
|
@ -152,8 +124,8 @@ class PriceCalculator {
|
||||||
// Update pricing with the selected plan
|
// Update pricing with the selected plan
|
||||||
this.updatePricingWithPlan(selectedPlan);
|
this.updatePricingWithPlan(selectedPlan);
|
||||||
} else {
|
} else {
|
||||||
// Auto-select mode - reset sliders to smart default values
|
// Auto-select mode - reset sliders to default values
|
||||||
this.domManager.setSmartDefaults(this.pricingDataManager);
|
this.domManager.resetSlidersToDefaults();
|
||||||
|
|
||||||
// Auto-select mode - fade sliders back in
|
// Auto-select mode - fade sliders back in
|
||||||
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
|
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
|
||||||
|
|
@ -171,84 +143,6 @@ class PriceCalculator {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup service level event listeners (called after UI setup)
|
|
||||||
setupServiceLevelEventListeners() {
|
|
||||||
// Service level change listener
|
|
||||||
const serviceLevelInputs = this.domManager.get('serviceLevelInputs');
|
|
||||||
|
|
||||||
if (serviceLevelInputs) {
|
|
||||||
serviceLevelInputs.forEach((input, index) => {
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
try {
|
|
||||||
// Check if a plan is currently selected before updating
|
|
||||||
const planSelect = this.domManager.get('planSelect');
|
|
||||||
const currentlySelectedPlan = planSelect?.value ? JSON.parse(planSelect.value) : null;
|
|
||||||
|
|
||||||
// Update instances slider for the new service level (functionality from UIManager)
|
|
||||||
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
|
|
||||||
|
|
||||||
// Update plan dropdown for new service level
|
|
||||||
this.planManager.populatePlanDropdown(this.domManager);
|
|
||||||
|
|
||||||
// Update addons for new service level first
|
|
||||||
this.addonManager.updateAddons(this.domManager);
|
|
||||||
|
|
||||||
// If a plan was previously selected, try to maintain selection
|
|
||||||
if (currentlySelectedPlan && planSelect) {
|
|
||||||
// Find the same plan in the new dropdown options
|
|
||||||
const options = planSelect.querySelectorAll('option');
|
|
||||||
let planFound = false;
|
|
||||||
let matchingPlan = null;
|
|
||||||
|
|
||||||
for (const option of options) {
|
|
||||||
if (option.value) {
|
|
||||||
try {
|
|
||||||
const optionPlan = JSON.parse(option.value);
|
|
||||||
|
|
||||||
// First, try to match by exact plan name
|
|
||||||
if (optionPlan.compute_plan === currentlySelectedPlan.compute_plan) {
|
|
||||||
matchingPlan = optionPlan;
|
|
||||||
planFound = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error parsing plan option:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planFound && matchingPlan) {
|
|
||||||
// Set the plan selection
|
|
||||||
planSelect.value = JSON.stringify(matchingPlan);
|
|
||||||
|
|
||||||
// Maintain the UI state for manually selected plan
|
|
||||||
this.planManager.updateSlidersForPlan(matchingPlan, this.domManager);
|
|
||||||
this.uiManager.fadeOutSliders(this.domManager, ['cpu', 'memory']);
|
|
||||||
|
|
||||||
// Update pricing with the selected plan
|
|
||||||
this.updatePricingWithPlan(matchingPlan);
|
|
||||||
} else {
|
|
||||||
planSelect.value = '';
|
|
||||||
// Reset sliders to smart defaults and fade them back in
|
|
||||||
this.domManager.setSmartDefaults(this.pricingDataManager);
|
|
||||||
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
|
|
||||||
// Update pricing in auto-select mode
|
|
||||||
this.updatePricing();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No plan was previously selected, just update pricing
|
|
||||||
this.updatePricing();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in service level change handler:', error);
|
|
||||||
// Fallback to basic functionality if there's an error
|
|
||||||
this.updatePricing();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update calculator (initial setup)
|
// Update calculator (initial setup)
|
||||||
updateCalculator() {
|
updateCalculator() {
|
||||||
this.addonManager.updateAddons(this.domManager);
|
this.addonManager.updateAddons(this.domManager);
|
||||||
|
|
@ -352,150 +246,6 @@ class PriceCalculator {
|
||||||
[...addons.mandatory, ...addons.optional]
|
[...addons.mandatory, ...addons.optional]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronize memory slider to match CPU value with best matching plan
|
|
||||||
synchronizeMemoryToMatchingPlan(targetCpu) {
|
|
||||||
const serviceLevel = this.domManager.getSelectedServiceLevel();
|
|
||||||
if (!serviceLevel) return;
|
|
||||||
|
|
||||||
// Get all available plans for the current service level
|
|
||||||
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
|
|
||||||
if (!availablePlans || availablePlans.length === 0) return;
|
|
||||||
|
|
||||||
// Snap CPU to nearest available value first
|
|
||||||
const { cpuValues } = this.pricingDataManager.getAvailableSliderValues();
|
|
||||||
const snappedCpu = this.findNearestValue(targetCpu, cpuValues);
|
|
||||||
|
|
||||||
// Update CPU slider to snapped value if different
|
|
||||||
if (snappedCpu !== targetCpu) {
|
|
||||||
const cpuRange = this.domManager.get('cpuRange');
|
|
||||||
const cpuValue = this.domManager.get('cpuValue');
|
|
||||||
if (cpuRange && cpuValue) {
|
|
||||||
cpuRange.value = snappedCpu;
|
|
||||||
cpuValue.textContent = snappedCpu;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the plan that best matches the snapped CPU requirement
|
|
||||||
let bestPlan = null;
|
|
||||||
let minDifference = Infinity;
|
|
||||||
|
|
||||||
availablePlans.forEach(plan => {
|
|
||||||
const planCpu = parseFloat(plan.vcpus);
|
|
||||||
// Look for plans that meet or exceed the CPU requirement
|
|
||||||
if (planCpu >= snappedCpu) {
|
|
||||||
const difference = planCpu - snappedCpu;
|
|
||||||
if (difference < minDifference) {
|
|
||||||
minDifference = difference;
|
|
||||||
bestPlan = plan;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no plan meets the CPU requirement, find the closest one below it
|
|
||||||
if (!bestPlan) {
|
|
||||||
availablePlans.forEach(plan => {
|
|
||||||
const planCpu = parseFloat(plan.vcpus);
|
|
||||||
const difference = Math.abs(planCpu - snappedCpu);
|
|
||||||
if (difference < minDifference) {
|
|
||||||
minDifference = difference;
|
|
||||||
bestPlan = plan;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update memory slider to match the found plan
|
|
||||||
if (bestPlan) {
|
|
||||||
const memoryRange = this.domManager.get('memoryRange');
|
|
||||||
const memoryValue = this.domManager.get('memoryValue');
|
|
||||||
|
|
||||||
if (memoryRange && memoryValue) {
|
|
||||||
memoryRange.value = bestPlan.ram;
|
|
||||||
memoryValue.textContent = bestPlan.ram;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronize CPU slider to match memory value with best matching plan
|
|
||||||
synchronizeCpuToMatchingPlan(targetMemory) {
|
|
||||||
const serviceLevel = this.domManager.getSelectedServiceLevel();
|
|
||||||
if (!serviceLevel) return;
|
|
||||||
|
|
||||||
// Get all available plans for the current service level
|
|
||||||
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
|
|
||||||
if (!availablePlans || availablePlans.length === 0) return;
|
|
||||||
|
|
||||||
// Snap memory to nearest available value first
|
|
||||||
const { memoryValues } = this.pricingDataManager.getAvailableSliderValues();
|
|
||||||
const snappedMemory = this.findNearestValue(targetMemory, memoryValues);
|
|
||||||
|
|
||||||
// Update memory slider to snapped value if different
|
|
||||||
if (snappedMemory !== targetMemory) {
|
|
||||||
const memoryRange = this.domManager.get('memoryRange');
|
|
||||||
const memoryValue = this.domManager.get('memoryValue');
|
|
||||||
if (memoryRange && memoryValue) {
|
|
||||||
memoryRange.value = snappedMemory;
|
|
||||||
memoryValue.textContent = snappedMemory;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the plan that best matches the snapped memory requirement
|
|
||||||
let bestPlan = null;
|
|
||||||
let minDifference = Infinity;
|
|
||||||
|
|
||||||
availablePlans.forEach(plan => {
|
|
||||||
const planMemory = parseFloat(plan.ram);
|
|
||||||
// Look for plans that meet or exceed the memory requirement
|
|
||||||
if (planMemory >= snappedMemory) {
|
|
||||||
const difference = planMemory - snappedMemory;
|
|
||||||
if (difference < minDifference) {
|
|
||||||
minDifference = difference;
|
|
||||||
bestPlan = plan;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no plan meets the memory requirement, find the closest one below it
|
|
||||||
if (!bestPlan) {
|
|
||||||
availablePlans.forEach(plan => {
|
|
||||||
const planMemory = parseFloat(plan.ram);
|
|
||||||
const difference = Math.abs(planMemory - snappedMemory);
|
|
||||||
if (difference < minDifference) {
|
|
||||||
minDifference = difference;
|
|
||||||
bestPlan = plan;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update CPU slider to match the found plan
|
|
||||||
if (bestPlan) {
|
|
||||||
const cpuRange = this.domManager.get('cpuRange');
|
|
||||||
const cpuValue = this.domManager.get('cpuValue');
|
|
||||||
|
|
||||||
if (cpuRange && cpuValue) {
|
|
||||||
cpuRange.value = bestPlan.vcpus;
|
|
||||||
cpuValue.textContent = bestPlan.vcpus;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the nearest value in an array to a target value
|
|
||||||
findNearestValue(target, availableValues) {
|
|
||||||
if (!availableValues || availableValues.length === 0) return target;
|
|
||||||
|
|
||||||
let nearest = availableValues[0];
|
|
||||||
let minDifference = Math.abs(target - nearest);
|
|
||||||
|
|
||||||
for (let i = 1; i < availableValues.length; i++) {
|
|
||||||
const difference = Math.abs(target - availableValues[i]);
|
|
||||||
if (difference < minDifference) {
|
|
||||||
minDifference = difference;
|
|
||||||
nearest = availableValues[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nearest;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in other modules
|
// Export for use in other modules
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ class PricingDataManager {
|
||||||
// Load pricing data from API endpoint
|
// Load pricing data from API endpoint
|
||||||
async loadPricingData() {
|
async loadPricingData() {
|
||||||
try {
|
try {
|
||||||
const url = `/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`;
|
const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`);
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load pricing data: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to load pricing data: ${response.status} ${response.statusText}`);
|
||||||
|
|
@ -22,17 +21,8 @@ class PricingDataManager {
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data || typeof data !== 'object') {
|
|
||||||
throw new Error('Invalid pricing data received from server');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pricingData = data.pricing || data;
|
this.pricingData = data.pricing || data;
|
||||||
|
|
||||||
// Validate that we have usable pricing data
|
|
||||||
if (!this.pricingData || Object.keys(this.pricingData).length === 0) {
|
|
||||||
throw new Error('No pricing data available for this offering');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract addons data from the plans - addons are embedded in each plan
|
// Extract addons data from the plans - addons are embedded in each plan
|
||||||
this.extractAddonsData();
|
this.extractAddonsData();
|
||||||
|
|
||||||
|
|
@ -150,31 +140,6 @@ class PricingDataManager {
|
||||||
return { maxCpus, maxMemory };
|
return { maxCpus, maxMemory };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all unique CPU and memory values from plans
|
|
||||||
getAvailableSliderValues() {
|
|
||||||
if (!this.pricingData) return { cpuValues: [], memoryValues: [] };
|
|
||||||
|
|
||||||
const cpuSet = new Set();
|
|
||||||
const memorySet = new Set();
|
|
||||||
|
|
||||||
// Collect all unique CPU and memory values across all plans
|
|
||||||
Object.keys(this.pricingData).forEach(groupName => {
|
|
||||||
const group = this.pricingData[groupName];
|
|
||||||
Object.keys(group).forEach(serviceLevel => {
|
|
||||||
group[serviceLevel].forEach(plan => {
|
|
||||||
cpuSet.add(parseFloat(plan.vcpus));
|
|
||||||
memorySet.add(parseFloat(plan.ram));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert to sorted arrays
|
|
||||||
const cpuValues = Array.from(cpuSet).sort((a, b) => a - b);
|
|
||||||
const memoryValues = Array.from(memorySet).sort((a, b) => a - b);
|
|
||||||
|
|
||||||
return { cpuValues, memoryValues };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all plans for a specific service level
|
// Get all plans for a specific service level
|
||||||
getPlansForServiceLevel(serviceLevel) {
|
getPlansForServiceLevel(serviceLevel) {
|
||||||
if (!this.pricingData || !serviceLevel) return [];
|
if (!this.pricingData || !serviceLevel) return [];
|
||||||
|
|
|
||||||
|
|
@ -85,29 +85,19 @@ class UIManager {
|
||||||
|
|
||||||
// Update addon pricing display in the results panel
|
// Update addon pricing display in the results panel
|
||||||
updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) {
|
updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) {
|
||||||
// Get references to the managed service includes elements
|
// Update mandatory addons in the managed service includes container
|
||||||
const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer');
|
const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer');
|
||||||
|
|
||||||
if (managedServiceIncludesContainer) {
|
if (managedServiceIncludesContainer) {
|
||||||
// Clear existing content
|
// Clear existing content
|
||||||
managedServiceIncludesContainer.innerHTML = '';
|
managedServiceIncludesContainer.innerHTML = '';
|
||||||
|
|
||||||
// Always add "Compute" as the first item
|
|
||||||
const computeRow = document.createElement('div');
|
|
||||||
computeRow.className = 'd-flex justify-content-between small text-muted mb-1';
|
|
||||||
computeRow.innerHTML = `
|
|
||||||
<span>Compute (vCPUs & Memory)</span>
|
|
||||||
<span>Included</span>
|
|
||||||
`;
|
|
||||||
managedServiceIncludesContainer.appendChild(computeRow);
|
|
||||||
|
|
||||||
// Add mandatory addons to the managed service includes section
|
// Add mandatory addons to the managed service includes section
|
||||||
if (mandatoryAddons && mandatoryAddons.length > 0) {
|
if (mandatoryAddons && mandatoryAddons.length > 0) {
|
||||||
mandatoryAddons.forEach(addon => {
|
mandatoryAddons.forEach(addon => {
|
||||||
const addonRow = document.createElement('div');
|
const addonRow = document.createElement('div');
|
||||||
addonRow.className = 'd-flex justify-content-between small text-muted mb-1';
|
addonRow.className = 'd-flex justify-content-between small text-muted mb-1';
|
||||||
addonRow.innerHTML = `
|
addonRow.innerHTML = `
|
||||||
<span>Add-on: ${addon.name}</span>
|
<span><i class="bi bi-check-circle text-success me-1"></i>${addon.name}</span>
|
||||||
<span>CHF ${addon.price}</span>
|
<span>CHF ${addon.price}</span>
|
||||||
`;
|
`;
|
||||||
managedServiceIncludesContainer.appendChild(addonRow);
|
managedServiceIncludesContainer.appendChild(addonRow);
|
||||||
|
|
@ -126,10 +116,10 @@ class UIManager {
|
||||||
if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
|
if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
|
||||||
selectedOptionalAddons.forEach(addon => {
|
selectedOptionalAddons.forEach(addon => {
|
||||||
const addonRow = document.createElement('div');
|
const addonRow = document.createElement('div');
|
||||||
addonRow.className = 'd-flex justify-content-between align-items-center mb-2';
|
addonRow.className = 'd-flex justify-content-between mb-2';
|
||||||
addonRow.innerHTML = `
|
addonRow.innerHTML = `
|
||||||
<span class="text-nowrap flex-shrink-1" style="min-width: 0;">Add-on: ${addon.name}</span>
|
<span>Add-on: ${addon.name}</span>
|
||||||
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF ${addon.price}</span>
|
<span class="fw-bold">CHF ${addon.price}</span>
|
||||||
`;
|
`;
|
||||||
addonPricingContainer.appendChild(addonRow);
|
addonPricingContainer.appendChild(addonRow);
|
||||||
});
|
});
|
||||||
|
|
@ -214,29 +204,6 @@ class UIManager {
|
||||||
|
|
||||||
// Update the serviceLevelInputs reference
|
// Update the serviceLevelInputs reference
|
||||||
domManager.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
|
domManager.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
|
||||||
|
|
||||||
// Note: Event listeners are now handled in price-calculator.js setupEventListeners method
|
|
||||||
// to properly preserve plan selection when service level changes
|
|
||||||
// this.setupServiceLevelEventListeners(domManager, pricingDataManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup event listeners for service level inputs
|
|
||||||
setupServiceLevelEventListeners(domManager, pricingDataManager) {
|
|
||||||
const serviceLevelInputs = domManager.get('serviceLevelInputs');
|
|
||||||
if (!serviceLevelInputs) return;
|
|
||||||
|
|
||||||
// Get the main price calculator instance from window
|
|
||||||
const priceCalculator = window.priceCalculator;
|
|
||||||
if (!priceCalculator) return;
|
|
||||||
|
|
||||||
serviceLevelInputs.forEach(input => {
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
this.updateInstancesSlider(domManager, pricingDataManager);
|
|
||||||
priceCalculator.planManager.populatePlanDropdown(domManager);
|
|
||||||
priceCalculator.addonManager.updateAddons(domManager);
|
|
||||||
priceCalculator.updatePricing();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update slider maximums based on pricing data
|
// Update slider maximums based on pricing data
|
||||||
|
|
@ -246,51 +213,23 @@ class UIManager {
|
||||||
|
|
||||||
if (!cpuRange || !memoryRange) return;
|
if (!cpuRange || !memoryRange) return;
|
||||||
|
|
||||||
const { cpuValues, memoryValues } = pricingDataManager.getAvailableSliderValues();
|
const { maxCpus, maxMemory } = pricingDataManager.getSliderMaximums();
|
||||||
|
|
||||||
// Set CPU slider range based on available plan values
|
// Set slider maximums with some padding
|
||||||
if (cpuValues.length > 0) {
|
if (maxCpus > 0) {
|
||||||
cpuRange.min = Math.min(...cpuValues);
|
cpuRange.min = "0.25";
|
||||||
cpuRange.max = Math.max(...cpuValues);
|
cpuRange.max = Math.ceil(maxCpus);
|
||||||
// Calculate step size - use the smallest difference between consecutive values
|
|
||||||
const cpuStep = this.calculateOptimalStep(cpuValues);
|
|
||||||
cpuRange.step = cpuStep;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Memory slider range based on available plan values
|
if (maxMemory > 0) {
|
||||||
if (memoryValues.length > 0) {
|
memoryRange.min = "0.25";
|
||||||
memoryRange.min = Math.min(...memoryValues);
|
memoryRange.max = Math.ceil(maxMemory);
|
||||||
memoryRange.max = Math.max(...memoryValues);
|
|
||||||
// Calculate step size - use the smallest difference between consecutive values
|
|
||||||
const memoryStep = this.calculateOptimalStep(memoryValues);
|
|
||||||
memoryRange.step = memoryStep;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update display values after changing min/max
|
// Update display values after changing min/max
|
||||||
domManager.updateSliderDisplayValues();
|
domManager.updateSliderDisplayValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate optimal step size for slider based on available values
|
|
||||||
calculateOptimalStep(values) {
|
|
||||||
if (values.length <= 1) return 0.25; // Default step
|
|
||||||
|
|
||||||
// Find the smallest difference between consecutive values
|
|
||||||
let minDiff = Infinity;
|
|
||||||
for (let i = 1; i < values.length; i++) {
|
|
||||||
const diff = values[i] - values[i - 1];
|
|
||||||
if (diff > 0 && diff < minDiff) {
|
|
||||||
minDiff = diff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the minimum difference as step, but ensure it's reasonable
|
|
||||||
// Round to common step values (0.25, 0.5, 1, etc.)
|
|
||||||
if (minDiff <= 0.25) return 0.25;
|
|
||||||
if (minDiff <= 0.5) return 0.5;
|
|
||||||
if (minDiff <= 1) return 1;
|
|
||||||
return Math.ceil(minDiff);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update instances slider based on service level and replica info
|
// Update instances slider based on service level and replica info
|
||||||
updateInstancesSlider(domManager, pricingDataManager) {
|
updateInstancesSlider(domManager, pricingDataManager) {
|
||||||
const instancesRange = domManager.get('instancesRange');
|
const instancesRange = domManager.get('instancesRange');
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
/**
|
|
||||||
* ROI Calculator - Modular Version
|
|
||||||
* This file loads all modules and provides global function wrappers for backward compatibility
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Global function wrappers for backward compatibility with existing HTML
|
|
||||||
function updateCalculations() {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateCalculations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportToPDF() {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.exportToPDF();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportToCSV() {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.exportToCSV();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInvestmentAmountInput(input) {
|
|
||||||
InputUtils.handleInvestmentAmountInput(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateInvestmentAmount(value) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateInvestmentAmount(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRevenuePerInstance(value) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateRevenuePerInstance(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServalaShare(value) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateServalaShare(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateGracePeriod(value) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateGracePeriod(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLoanRate(value) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateLoanRate(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateScenarioChurn(scenarioKey, churnRate) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateScenarioChurn(scenarioKey, churnRate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAdvancedParameters() {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.resetAdvancedParameters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleScenario(scenarioKey) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.toggleScenario(scenarioKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCollapsible(elementId) {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.toggleCollapsible(elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCalculator() {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.resetCalculator();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleInvestmentModel() {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.toggleInvestmentModel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
if (window.ROICalculatorApp) {
|
|
||||||
window.ROICalculatorApp.logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# ROI Calculator Modules
|
|
||||||
|
|
||||||
This directory contains the modular ROI Calculator implementation, split into focused, maintainable modules.
|
|
||||||
|
|
||||||
## Module Structure
|
|
||||||
|
|
||||||
### Core Modules
|
|
||||||
|
|
||||||
- **`calculator-core.js`** - Main ROICalculator class with calculation logic
|
|
||||||
- **`chart-manager.js`** - Chart.js integration and chart rendering
|
|
||||||
- **`ui-manager.js`** - DOM updates, table rendering, and metric display
|
|
||||||
- **`export-manager.js`** - PDF and CSV export functionality
|
|
||||||
- **`input-utils.js`** - Input validation, parsing, and formatting utilities
|
|
||||||
- **`roi-calculator-app.js`** - Main application coordinator class
|
|
||||||
|
|
||||||
### Integration
|
|
||||||
|
|
||||||
- **`../roi-calculator-modular.js`** - Global function wrappers for backward compatibility
|
|
||||||
|
|
||||||
## Key Improvements
|
|
||||||
|
|
||||||
1. **Modular Architecture**: Each module has a single responsibility
|
|
||||||
2. **Error Handling**: Comprehensive try-catch blocks with graceful fallbacks
|
|
||||||
3. **No Global Variables**: App instance contained in window.ROICalculatorApp
|
|
||||||
4. **Type Safety**: Input validation and null checks throughout
|
|
||||||
5. **Separation of Concerns**: Calculation, UI, charts, and exports are separated
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
All modules are automatically loaded via the HTML template. The ROICalculatorApp class coordinates all modules and provides the same public API as the original monolithic version.
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
All existing HTML onclick handlers and function calls continue to work through the global wrapper functions in `roi-calculator-modular.js`.
|
|
||||||
|
|
@ -1,452 +0,0 @@
|
||||||
/**
|
|
||||||
* Core ROI Calculator Class
|
|
||||||
* Handles calculation logic and data management
|
|
||||||
*/
|
|
||||||
class ROICalculator {
|
|
||||||
constructor() {
|
|
||||||
this.scenarios = {
|
|
||||||
conservative: {
|
|
||||||
name: 'Conservative',
|
|
||||||
enabled: true,
|
|
||||||
churnRate: 0.025, // Lower churn with sticky managed services
|
|
||||||
phases: [
|
|
||||||
{ months: 6, newInstancesPerMonth: 15 }, // 5 new clients × 3 instances each
|
|
||||||
{ months: 6, newInstancesPerMonth: 24 }, // 6 new clients × 4 instances (growth + expansion)
|
|
||||||
{ months: 12, newInstancesPerMonth: 35 }, // 7 new clients × 5 instances (mature usage)
|
|
||||||
{ months: 12, newInstancesPerMonth: 40 } // 8 new clients × 5 instances
|
|
||||||
]
|
|
||||||
},
|
|
||||||
moderate: {
|
|
||||||
name: 'Moderate',
|
|
||||||
enabled: true,
|
|
||||||
churnRate: 0.03, // Balanced churn with growth
|
|
||||||
phases: [
|
|
||||||
{ months: 6, newInstancesPerMonth: 25 }, // 8 new clients × 3 instances each
|
|
||||||
{ months: 6, newInstancesPerMonth: 45 }, // 10 new clients × 4.5 instances (expansion)
|
|
||||||
{ months: 12, newInstancesPerMonth: 70 }, // 12 new clients × 6 instances (diverse services)
|
|
||||||
{ months: 12, newInstancesPerMonth: 90 } // 15 new clients × 6 instances
|
|
||||||
]
|
|
||||||
},
|
|
||||||
aggressive: {
|
|
||||||
name: 'Aggressive',
|
|
||||||
enabled: true,
|
|
||||||
churnRate: 0.035, // Slightly higher churn with rapid growth
|
|
||||||
phases: [
|
|
||||||
{ months: 6, newInstancesPerMonth: 40 }, // 12 new clients × 3.5 instances
|
|
||||||
{ months: 6, newInstancesPerMonth: 80 }, // 16 new clients × 5 instances (viral growth)
|
|
||||||
{ months: 12, newInstancesPerMonth: 120 }, // 20 new clients × 6 instances (full adoption)
|
|
||||||
{ months: 12, newInstancesPerMonth: 150 } // 25 new clients × 6 instances
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.charts = {};
|
|
||||||
this.monthlyData = {};
|
|
||||||
this.results = {};
|
|
||||||
|
|
||||||
// Note: Charts and initial calculation will be handled by the app coordinator
|
|
||||||
}
|
|
||||||
|
|
||||||
getInputValues() {
|
|
||||||
try {
|
|
||||||
// Get investment model with fallback
|
|
||||||
const investmentModelElement = document.querySelector('input[name="investment-model"]:checked');
|
|
||||||
const investmentModel = investmentModelElement ? investmentModelElement.value : 'direct';
|
|
||||||
|
|
||||||
// Get investment amount with validation
|
|
||||||
const investmentAmountElement = document.getElementById('investment-amount');
|
|
||||||
const investmentAmountValue = investmentAmountElement ? investmentAmountElement.getAttribute('data-value') : '500000';
|
|
||||||
const investmentAmount = parseFloat(investmentAmountValue) || 500000;
|
|
||||||
|
|
||||||
// Get timeframe with validation
|
|
||||||
const timeframeElement = document.getElementById('timeframe');
|
|
||||||
const timeframe = timeframeElement ? parseInt(timeframeElement.value) || 3 : 3;
|
|
||||||
|
|
||||||
// Get loan interest rate with validation
|
|
||||||
const loanRateElement = document.getElementById('loan-interest-rate');
|
|
||||||
const loanInterestRate = loanRateElement ? (parseFloat(loanRateElement.value) || 5.0) / 100 : 0.05;
|
|
||||||
|
|
||||||
// Get revenue per instance with validation
|
|
||||||
const revenueElement = document.getElementById('revenue-per-instance');
|
|
||||||
const revenuePerInstance = revenueElement ? parseFloat(revenueElement.value) || 50 : 50;
|
|
||||||
|
|
||||||
// Get servala share with validation
|
|
||||||
const shareElement = document.getElementById('servala-share');
|
|
||||||
const servalaShare = shareElement ? (parseFloat(shareElement.value) || 25) / 100 : 0.25;
|
|
||||||
|
|
||||||
// Get grace period with validation
|
|
||||||
const graceElement = document.getElementById('grace-period');
|
|
||||||
const gracePeriod = graceElement ? parseInt(graceElement.value) || 6 : 6;
|
|
||||||
|
|
||||||
// Get core service revenue with validation
|
|
||||||
const coreRevenueElement = document.getElementById('core-service-revenue');
|
|
||||||
const coreServiceRevenue = coreRevenueElement ? parseFloat(coreRevenueElement.value) || 0 : 0;
|
|
||||||
|
|
||||||
// Get currency with validation
|
|
||||||
const currencyElement = document.getElementById('currency');
|
|
||||||
const currency = currencyElement ? currencyElement.value || 'CHF' : 'CHF';
|
|
||||||
|
|
||||||
return {
|
|
||||||
investmentAmount,
|
|
||||||
timeframe,
|
|
||||||
investmentModel,
|
|
||||||
loanInterestRate,
|
|
||||||
revenuePerInstance,
|
|
||||||
coreServiceRevenue,
|
|
||||||
servalaShare,
|
|
||||||
gracePeriod,
|
|
||||||
currency
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting input values:', error);
|
|
||||||
// Return safe default values
|
|
||||||
return {
|
|
||||||
investmentAmount: 500000,
|
|
||||||
timeframe: 3,
|
|
||||||
investmentModel: 'direct',
|
|
||||||
loanInterestRate: 0.05,
|
|
||||||
revenuePerInstance: 50,
|
|
||||||
coreServiceRevenue: 0,
|
|
||||||
servalaShare: 0.25,
|
|
||||||
currency: 'CHF',
|
|
||||||
gracePeriod: 6
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
calculateScenario(scenarioKey, inputs) {
|
|
||||||
try {
|
|
||||||
const scenario = this.scenarios[scenarioKey];
|
|
||||||
if (!scenario.enabled) return null;
|
|
||||||
|
|
||||||
// Calculate loan payment if using loan model
|
|
||||||
let monthlyLoanPayment = 0;
|
|
||||||
if (inputs.investmentModel === 'loan') {
|
|
||||||
const monthlyRate = inputs.loanInterestRate / 12;
|
|
||||||
const numPayments = inputs.timeframe * 12;
|
|
||||||
// Calculate fixed monthly payment using amortization formula
|
|
||||||
if (monthlyRate > 0) {
|
|
||||||
monthlyLoanPayment = inputs.investmentAmount *
|
|
||||||
(monthlyRate * Math.pow(1 + monthlyRate, numPayments)) /
|
|
||||||
(Math.pow(1 + monthlyRate, numPayments) - 1);
|
|
||||||
} else {
|
|
||||||
monthlyLoanPayment = inputs.investmentAmount / numPayments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Market-realistic investment scaling factor (only for direct investment)
|
|
||||||
// Conservative scaling based on industry standards and economies of scale
|
|
||||||
const baseInvestment = 500000;
|
|
||||||
let investmentScaleFactor;
|
|
||||||
let churnReductionFactor;
|
|
||||||
let revenueMultiplier;
|
|
||||||
let acceleratedBreakEvenFactor;
|
|
||||||
|
|
||||||
if (inputs.investmentModel === 'loan') {
|
|
||||||
investmentScaleFactor = 1.0;
|
|
||||||
churnReductionFactor = 1.0;
|
|
||||||
revenueMultiplier = 1.0;
|
|
||||||
acceleratedBreakEvenFactor = 1.0;
|
|
||||||
} else {
|
|
||||||
// Conservative linear scaling with market-realistic caps
|
|
||||||
if (inputs.investmentAmount <= baseInvestment) {
|
|
||||||
investmentScaleFactor = inputs.investmentAmount / baseInvestment;
|
|
||||||
} else if (inputs.investmentAmount <= 1000000) {
|
|
||||||
// 500k to 1M: Moderate linear scaling (1.0x to 1.5x)
|
|
||||||
const ratio = inputs.investmentAmount / baseInvestment;
|
|
||||||
investmentScaleFactor = 1.0 + ((ratio - 1.0) * 0.5);
|
|
||||||
} else if (inputs.investmentAmount <= 1500000) {
|
|
||||||
// 1M to 1.5M: Conservative scaling (1.5x to 1.8x)
|
|
||||||
const progress = (inputs.investmentAmount - 1000000) / 500000;
|
|
||||||
investmentScaleFactor = 1.5 + (progress * 0.3);
|
|
||||||
} else {
|
|
||||||
// 1.5M to 2M: Maximum realistic scaling (1.8x to 2.0x)
|
|
||||||
const progress = (inputs.investmentAmount - 1500000) / 500000;
|
|
||||||
investmentScaleFactor = 1.8 + (progress * 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Market-sustainable revenue premiums (10-20% max)
|
|
||||||
if (inputs.investmentAmount >= 2000000) {
|
|
||||||
revenueMultiplier = 1.2; // 20% premium - maximum sustainable
|
|
||||||
} else if (inputs.investmentAmount >= 1500000) {
|
|
||||||
revenueMultiplier = 1.15; // 15% premium
|
|
||||||
} else if (inputs.investmentAmount >= 1000000) {
|
|
||||||
revenueMultiplier = 1.1; // 10% premium
|
|
||||||
} else {
|
|
||||||
revenueMultiplier = 1.0; // No premium
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modest break-even improvements (not dramatic acceleration)
|
|
||||||
if (inputs.investmentAmount >= 1500000) {
|
|
||||||
acceleratedBreakEvenFactor = 1.15; // 15% faster break-even
|
|
||||||
} else if (inputs.investmentAmount >= 1000000) {
|
|
||||||
acceleratedBreakEvenFactor = 1.1; // 10% faster break-even
|
|
||||||
} else {
|
|
||||||
acceleratedBreakEvenFactor = 1.0; // Standard break-even
|
|
||||||
}
|
|
||||||
|
|
||||||
// Realistic churn reduction based on customer success investments
|
|
||||||
const churnReductionRatio = Math.min((inputs.investmentAmount - baseInvestment) / 1500000, 1.0);
|
|
||||||
churnReductionFactor = Math.max(0.75, 1 - (churnReductionRatio * 0.25)); // Up to 25% churn reduction
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate adjusted churn rate with investment-based reduction
|
|
||||||
const adjustedChurnRate = scenario.churnRate * churnReductionFactor;
|
|
||||||
|
|
||||||
const totalMonths = inputs.timeframe * 12;
|
|
||||||
const monthlyData = [];
|
|
||||||
let currentInstances = 0;
|
|
||||||
let cumulativeCSPRevenue = 0;
|
|
||||||
let cumulativeServalaRevenue = 0;
|
|
||||||
let breakEvenMonth = null;
|
|
||||||
|
|
||||||
// Calculate commercially viable grace period based on investment size
|
|
||||||
const baseGracePeriod = inputs.gracePeriod;
|
|
||||||
let gracePeriodBonus;
|
|
||||||
if (inputs.investmentAmount >= 1500000) {
|
|
||||||
gracePeriodBonus = 3; // 3 months extra for large investments
|
|
||||||
} else if (inputs.investmentAmount >= 1000000) {
|
|
||||||
gracePeriodBonus = 2; // 2 months extra for medium investments
|
|
||||||
} else {
|
|
||||||
gracePeriodBonus = Math.floor((inputs.investmentAmount - baseInvestment) / 500000); // More conservative bonus calculation
|
|
||||||
}
|
|
||||||
const effectiveGracePeriod = inputs.investmentModel === 'loan' ? 0 :
|
|
||||||
Math.min(baseGracePeriod + gracePeriodBonus, 6); // Maximum 6 months grace period
|
|
||||||
|
|
||||||
// Track baseline performance for performance bonuses (direct investment only)
|
|
||||||
let baselineInstances = 0; // Will track expected instances without performance scaling
|
|
||||||
|
|
||||||
// Track phase progression
|
|
||||||
let currentPhase = 0;
|
|
||||||
let monthsInCurrentPhase = 0;
|
|
||||||
|
|
||||||
for (let month = 1; month <= totalMonths; month++) {
|
|
||||||
// Determine current phase
|
|
||||||
if (monthsInCurrentPhase >= scenario.phases[currentPhase].months && currentPhase < scenario.phases.length - 1) {
|
|
||||||
currentPhase++;
|
|
||||||
monthsInCurrentPhase = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate new instances for this month with investment scaling
|
|
||||||
const baseNewInstances = scenario.phases[currentPhase].newInstancesPerMonth;
|
|
||||||
const newInstances = Math.floor(baseNewInstances * investmentScaleFactor);
|
|
||||||
|
|
||||||
// Track baseline instances (without investment scaling) for performance comparison
|
|
||||||
const baselineNewInstances = baseNewInstances;
|
|
||||||
const baselineChurnedInstances = Math.floor(baselineInstances * scenario.churnRate);
|
|
||||||
baselineInstances = baselineInstances + baselineNewInstances - baselineChurnedInstances;
|
|
||||||
|
|
||||||
// Calculate churn using the pre-calculated adjusted churn rate
|
|
||||||
const churnedInstances = Math.floor(currentInstances * adjustedChurnRate);
|
|
||||||
|
|
||||||
// Update total instances
|
|
||||||
currentInstances = currentInstances + newInstances - churnedInstances;
|
|
||||||
|
|
||||||
// Calculate revenue based on investment model
|
|
||||||
let cspRevenue, servalaRevenue, monthlyRevenue, performanceBonus = 0, adjustedServalaShare = inputs.servalaShare;
|
|
||||||
|
|
||||||
if (inputs.investmentModel === 'loan') {
|
|
||||||
// Loan model: CSP receives fixed monthly loan payment (predictable returns)
|
|
||||||
cspRevenue = monthlyLoanPayment;
|
|
||||||
servalaRevenue = 0;
|
|
||||||
monthlyRevenue = monthlyLoanPayment;
|
|
||||||
} else {
|
|
||||||
// Direct investment model: Revenue based on instances with performance incentives
|
|
||||||
// Service revenue (shared with Servala) + Core service revenue (100% to CSP)
|
|
||||||
const baseServiceRevenue = currentInstances * inputs.revenuePerInstance;
|
|
||||||
const baseCoreRevenue = currentInstances * inputs.coreServiceRevenue;
|
|
||||||
|
|
||||||
// Apply revenue multiplier for large investments (economies of scale)
|
|
||||||
const serviceRevenue = baseServiceRevenue * revenueMultiplier;
|
|
||||||
const coreRevenue = baseCoreRevenue * revenueMultiplier;
|
|
||||||
monthlyRevenue = serviceRevenue;
|
|
||||||
|
|
||||||
// Calculate realistic performance bonus based on investment size
|
|
||||||
let maxPerformanceBonus;
|
|
||||||
if (inputs.investmentAmount >= 2000000) {
|
|
||||||
maxPerformanceBonus = 0.15; // 15% max bonus for largest investments
|
|
||||||
} else if (inputs.investmentAmount >= 1500000) {
|
|
||||||
maxPerformanceBonus = 0.12; // 12% max bonus for large investments
|
|
||||||
} else if (inputs.investmentAmount >= 1000000) {
|
|
||||||
maxPerformanceBonus = 0.10; // 10% max bonus for medium investments
|
|
||||||
} else {
|
|
||||||
maxPerformanceBonus = 0.08; // 8% max bonus for base investments
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate performance bonus if CSP exceeds baseline expectations
|
|
||||||
if (baselineInstances > 0 && month > 6) { // Start performance tracking after 6 months
|
|
||||||
const performanceRatio = currentInstances / Math.max(baselineInstances, 1);
|
|
||||||
if (performanceRatio > 1.1) { // 10% threshold for performance bonus
|
|
||||||
performanceBonus = Math.max(0, Math.min(maxPerformanceBonus, (performanceRatio - 1.1) * 0.5));
|
|
||||||
adjustedServalaShare = Math.max(0.10, inputs.servalaShare - performanceBonus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine revenue split based on dynamic grace period
|
|
||||||
if (month <= effectiveGracePeriod) {
|
|
||||||
// During grace period: CSP keeps all service revenue + core revenue
|
|
||||||
cspRevenue = serviceRevenue + coreRevenue;
|
|
||||||
servalaRevenue = 0;
|
|
||||||
} else {
|
|
||||||
// After grace period: CSP keeps share of service revenue + all core revenue
|
|
||||||
cspRevenue = (serviceRevenue * (1 - adjustedServalaShare)) + coreRevenue;
|
|
||||||
servalaRevenue = serviceRevenue * adjustedServalaShare;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cumulative revenue
|
|
||||||
cumulativeCSPRevenue += cspRevenue;
|
|
||||||
cumulativeServalaRevenue += servalaRevenue;
|
|
||||||
|
|
||||||
// Enhanced break-even calculation with acceleration for large investments
|
|
||||||
let netPosition; // CSP's net financial position
|
|
||||||
if (inputs.investmentModel === 'loan') {
|
|
||||||
// For loan model: net position is cumulative payments received minus loan principal outstanding
|
|
||||||
const principalPaid = inputs.investmentAmount * (month / totalMonths); // Simplified principal tracking
|
|
||||||
netPosition = cumulativeCSPRevenue - (inputs.investmentAmount - principalPaid);
|
|
||||||
} else {
|
|
||||||
// For direct investment: accelerated break-even for large investments
|
|
||||||
// Large investments benefit from faster effective cost recovery
|
|
||||||
const adjustedInvestment = inputs.investmentAmount / acceleratedBreakEvenFactor;
|
|
||||||
netPosition = cumulativeCSPRevenue - adjustedInvestment;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (breakEvenMonth === null && netPosition >= 0) {
|
|
||||||
breakEvenMonth = month;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate revenue components for data tracking
|
|
||||||
let serviceRevenueForData, coreRevenueForData, totalRevenueForData;
|
|
||||||
if (inputs.investmentModel === 'loan') {
|
|
||||||
serviceRevenueForData = monthlyLoanPayment;
|
|
||||||
coreRevenueForData = 0;
|
|
||||||
totalRevenueForData = monthlyLoanPayment;
|
|
||||||
} else {
|
|
||||||
serviceRevenueForData = currentInstances * inputs.revenuePerInstance;
|
|
||||||
coreRevenueForData = currentInstances * inputs.coreServiceRevenue;
|
|
||||||
totalRevenueForData = serviceRevenueForData + coreRevenueForData;
|
|
||||||
}
|
|
||||||
|
|
||||||
monthlyData.push({
|
|
||||||
month,
|
|
||||||
scenario: scenario.name,
|
|
||||||
newInstances,
|
|
||||||
churnedInstances,
|
|
||||||
totalInstances: currentInstances,
|
|
||||||
baselineInstances,
|
|
||||||
serviceRevenue: serviceRevenueForData,
|
|
||||||
coreRevenue: coreRevenueForData,
|
|
||||||
totalRevenue: totalRevenueForData,
|
|
||||||
monthlyRevenue: serviceRevenueForData, // Keep for backward compatibility
|
|
||||||
cspRevenue,
|
|
||||||
servalaRevenue,
|
|
||||||
cumulativeCSPRevenue,
|
|
||||||
cumulativeServalaRevenue,
|
|
||||||
netPosition,
|
|
||||||
performanceBonus,
|
|
||||||
adjustedServalaShare,
|
|
||||||
effectiveGracePeriod,
|
|
||||||
investmentScaleFactor: investmentScaleFactor,
|
|
||||||
adjustedChurnRate: adjustedChurnRate,
|
|
||||||
roiPercent: inputs.investmentModel === 'loan' ?
|
|
||||||
((cumulativeCSPRevenue - inputs.investmentAmount) / inputs.investmentAmount * 100) :
|
|
||||||
(netPosition / inputs.investmentAmount * 100)
|
|
||||||
});
|
|
||||||
|
|
||||||
monthsInCurrentPhase++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate final metrics with enhanced business intelligence
|
|
||||||
const totalRevenue = cumulativeCSPRevenue + cumulativeServalaRevenue;
|
|
||||||
let finalNetPosition, roi;
|
|
||||||
if (inputs.investmentModel === 'loan') {
|
|
||||||
finalNetPosition = cumulativeCSPRevenue - inputs.investmentAmount;
|
|
||||||
roi = (finalNetPosition / inputs.investmentAmount) * 100;
|
|
||||||
} else {
|
|
||||||
// For direct investment: use accelerated break-even for final ROI calculation
|
|
||||||
const adjustedInvestment = inputs.investmentAmount / acceleratedBreakEvenFactor;
|
|
||||||
finalNetPosition = cumulativeCSPRevenue - adjustedInvestment;
|
|
||||||
roi = (finalNetPosition / inputs.investmentAmount) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate average performance bonus over the investment period
|
|
||||||
const performanceBonusMonths = monthlyData.filter(m => m.performanceBonus > 0);
|
|
||||||
const avgPerformanceBonus = performanceBonusMonths.length > 0 ?
|
|
||||||
performanceBonusMonths.reduce((sum, m) => sum + m.performanceBonus, 0) / performanceBonusMonths.length : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
scenario: scenario.name,
|
|
||||||
investmentModel: inputs.investmentModel,
|
|
||||||
finalInstances: currentInstances,
|
|
||||||
baselineFinalInstances: baselineInstances,
|
|
||||||
totalRevenue,
|
|
||||||
cspRevenue: cumulativeCSPRevenue,
|
|
||||||
servalaRevenue: cumulativeServalaRevenue,
|
|
||||||
netPosition: finalNetPosition,
|
|
||||||
roi,
|
|
||||||
breakEvenMonth,
|
|
||||||
effectiveGracePeriod,
|
|
||||||
avgPerformanceBonus,
|
|
||||||
monthlyData,
|
|
||||||
investmentScaleFactor: investmentScaleFactor,
|
|
||||||
revenueMultiplier: revenueMultiplier,
|
|
||||||
acceleratedBreakEvenFactor: acceleratedBreakEvenFactor,
|
|
||||||
maxPerformanceBonus: inputs.investmentModel === 'direct' ?
|
|
||||||
(inputs.investmentAmount >= 2000000 ? 15 :
|
|
||||||
inputs.investmentAmount >= 1500000 ? 12 :
|
|
||||||
inputs.investmentAmount >= 1000000 ? 10 : 8) : 0,
|
|
||||||
adjustedChurnRate: adjustedChurnRate * 100,
|
|
||||||
performanceMultiplier: baselineInstances > 0 ? (currentInstances / baselineInstances) : 1.0
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error calculating scenario ${scenarioKey}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCalculations() {
|
|
||||||
try {
|
|
||||||
const inputs = this.getInputValues();
|
|
||||||
this.results = {};
|
|
||||||
this.monthlyData = {};
|
|
||||||
|
|
||||||
// Show loading spinner
|
|
||||||
const loadingSpinner = document.getElementById('loading-spinner');
|
|
||||||
|
|
||||||
if (loadingSpinner) loadingSpinner.style.display = 'block';
|
|
||||||
|
|
||||||
// Calculate results for each enabled scenario with both investment models
|
|
||||||
Object.keys(this.scenarios).forEach(scenarioKey => {
|
|
||||||
// Calculate for Direct investment model
|
|
||||||
const directInputs = { ...inputs, investmentModel: 'direct' };
|
|
||||||
const directResult = this.calculateScenario(scenarioKey, directInputs);
|
|
||||||
if (directResult) {
|
|
||||||
this.results[scenarioKey + '_direct'] = directResult;
|
|
||||||
this.monthlyData[scenarioKey + '_direct'] = directResult.monthlyData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate for Loan investment model
|
|
||||||
const loanInputs = { ...inputs, investmentModel: 'loan' };
|
|
||||||
const loanResult = this.calculateScenario(scenarioKey, loanInputs);
|
|
||||||
if (loanResult) {
|
|
||||||
this.results[scenarioKey + '_loan'] = loanResult;
|
|
||||||
this.monthlyData[scenarioKey + '_loan'] = loanResult.monthlyData;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update UI immediately
|
|
||||||
this.updateSummaryMetrics();
|
|
||||||
this.updateCharts();
|
|
||||||
this.updateComparisonTable();
|
|
||||||
this.updateMonthlyBreakdown();
|
|
||||||
|
|
||||||
// Hide loading spinner
|
|
||||||
if (loadingSpinner) loadingSpinner.style.display = 'none';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating calculations:', error);
|
|
||||||
// Hide loading spinner on error
|
|
||||||
const loadingSpinner = document.getElementById('loading-spinner');
|
|
||||||
if (loadingSpinner) loadingSpinner.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,452 +0,0 @@
|
||||||
/**
|
|
||||||
* Chart Management Module
|
|
||||||
* Handles Chart.js initialization and updates
|
|
||||||
*/
|
|
||||||
class ChartManager {
|
|
||||||
constructor(calculator) {
|
|
||||||
this.calculator = calculator;
|
|
||||||
this.charts = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeCharts() {
|
|
||||||
// Check if Chart.js is available
|
|
||||||
if (typeof Chart === 'undefined') {
|
|
||||||
console.error('Chart.js library not loaded. Charts will not be available.');
|
|
||||||
this.showChartError('Chart.js library failed to load. Please refresh the page.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ROI Progression Chart (replaces Instance Growth Chart)
|
|
||||||
const roiCanvas = document.getElementById('instanceGrowthChart');
|
|
||||||
if (!roiCanvas) {
|
|
||||||
console.error('ROI progression chart canvas not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const roiCtx = roiCanvas.getContext('2d');
|
|
||||||
this.charts.roiProgression = new Chart(roiCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: { labels: [], datasets: [] },
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { position: 'top' },
|
|
||||||
title: { display: true, text: 'ROI Progression Over Time - Direct Investment Only' }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
title: { display: true, text: 'ROI (%)' },
|
|
||||||
grid: {
|
|
||||||
color: function(context) {
|
|
||||||
return context.tick.value === 0 ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.1)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
title: { display: true, text: 'Month' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Net Position Chart (replaces simple Revenue Chart)
|
|
||||||
const netPositionCanvas = document.getElementById('revenueChart');
|
|
||||||
if (!netPositionCanvas) {
|
|
||||||
console.error('Net position chart canvas not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const netPositionCtx = netPositionCanvas.getContext('2d');
|
|
||||||
this.charts.netPosition = new Chart(netPositionCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: { labels: [], datasets: [] },
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { position: 'top' },
|
|
||||||
title: { display: true, text: 'Net Financial Position - Direct Investment Only' }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
title: { display: true, text: 'Net Position (CHF)' },
|
|
||||||
grid: {
|
|
||||||
color: function(context) {
|
|
||||||
return context.tick.value === 0 ? 'rgba(0,0,0,0.8)' : 'rgba(0,0,0,0.1)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
title: { display: true, text: 'Month' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// CSP Revenue Breakdown Chart
|
|
||||||
const cspRevenueCanvas = document.getElementById('cspRevenueChart');
|
|
||||||
if (!cspRevenueCanvas) {
|
|
||||||
console.error('CSP revenue breakdown chart canvas not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cspRevenueCtx = cspRevenueCanvas.getContext('2d');
|
|
||||||
this.charts.cspRevenue = new Chart(cspRevenueCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: { labels: [], datasets: [] },
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
layout: {
|
|
||||||
padding: {
|
|
||||||
left: 10,
|
|
||||||
right: 200, // Add space for side legend
|
|
||||||
top: 10,
|
|
||||||
bottom: 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'right',
|
|
||||||
align: 'start',
|
|
||||||
labels: {
|
|
||||||
boxWidth: 12,
|
|
||||||
padding: 15,
|
|
||||||
font: {
|
|
||||||
size: 11
|
|
||||||
},
|
|
||||||
usePointStyle: true,
|
|
||||||
generateLabels: function(chart) {
|
|
||||||
const original = Chart.defaults.plugins.legend.labels.generateLabels;
|
|
||||||
const labels = original.call(this, chart);
|
|
||||||
|
|
||||||
// Group labels by scenario for better organization
|
|
||||||
return labels.map(label => {
|
|
||||||
// Shorten label text for better fit
|
|
||||||
if (label.text.includes('Service Revenue')) {
|
|
||||||
label.text = label.text.replace(' - Service Revenue', ' - Service');
|
|
||||||
}
|
|
||||||
if (label.text.includes('Core Service Revenue')) {
|
|
||||||
label.text = label.text.replace(' - Core Service Revenue', ' - Core');
|
|
||||||
}
|
|
||||||
if (label.text.includes('CSP Total')) {
|
|
||||||
label.text = label.text.replace(' - CSP Total', ' - Total');
|
|
||||||
}
|
|
||||||
if (label.text.includes('Servala Revenue')) {
|
|
||||||
label.text = label.text.replace(' - Servala Revenue', ' - Servala');
|
|
||||||
}
|
|
||||||
return label;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'CSP Revenue Growth - Direct Investment Only',
|
|
||||||
font: {
|
|
||||||
size: 14,
|
|
||||||
weight: 'bold'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
title: { display: true, text: 'Revenue Amount' },
|
|
||||||
stacked: false,
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0,0,0,0.1)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
title: { display: true, text: 'Month' },
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0,0,0,0.05)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
point: {
|
|
||||||
radius: 3,
|
|
||||||
hoverRadius: 6
|
|
||||||
},
|
|
||||||
line: {
|
|
||||||
tension: 0.1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Performance Comparison Chart (for cashFlowChart canvas)
|
|
||||||
const performanceCanvas = document.getElementById('cashFlowChart');
|
|
||||||
if (!performanceCanvas) {
|
|
||||||
console.error('Performance comparison chart canvas not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const performanceCtx = performanceCanvas.getContext('2d');
|
|
||||||
this.charts.performance = new Chart(performanceCtx, {
|
|
||||||
type: 'bar',
|
|
||||||
data: { labels: [], datasets: [] },
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { position: 'top' },
|
|
||||||
title: { display: true, text: 'Model Performance Comparison' }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
title: { display: true, text: 'ROI (%)' }
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
title: { display: true, text: 'Growth Scenario' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing charts:', error);
|
|
||||||
this.showChartError('Failed to initialize charts. Please refresh the page.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showChartError(message) {
|
|
||||||
// Show error message in place of charts
|
|
||||||
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'cspRevenueChart'];
|
|
||||||
chartContainers.forEach(containerId => {
|
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
if (container) {
|
|
||||||
container.style.display = 'flex';
|
|
||||||
container.style.justifyContent = 'center';
|
|
||||||
container.style.alignItems = 'center';
|
|
||||||
container.style.minHeight = '300px';
|
|
||||||
container.style.backgroundColor = '#f8f9fa';
|
|
||||||
container.style.border = '1px solid #dee2e6';
|
|
||||||
container.style.borderRadius = '4px';
|
|
||||||
container.innerHTML = `<div class="text-muted text-center"><i class="bi bi-exclamation-triangle"></i><br>${message}</div>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCharts() {
|
|
||||||
try {
|
|
||||||
const scenarios = Object.keys(this.calculator.results);
|
|
||||||
if (scenarios.length === 0 || !this.charts.roiProgression) return;
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
conservative: '#28a745',
|
|
||||||
moderate: '#ffc107',
|
|
||||||
aggressive: '#dc3545'
|
|
||||||
};
|
|
||||||
|
|
||||||
const modelColors = {
|
|
||||||
direct: { border: '', background: '80' },
|
|
||||||
loan: { border: '', background: '40' }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get month labels
|
|
||||||
const maxMonths = Math.max(...scenarios.map(s => this.calculator.monthlyData[s].length));
|
|
||||||
const monthLabels = Array.from({ length: maxMonths }, (_, i) => `M${i + 1}`);
|
|
||||||
|
|
||||||
// Update ROI Progression Chart - Direct Investment Only
|
|
||||||
this.charts.roiProgression.data.labels = monthLabels;
|
|
||||||
this.charts.roiProgression.data.datasets = scenarios.filter(s =>
|
|
||||||
this.calculator.results[s] && s.includes('_direct')
|
|
||||||
).map(scenario => {
|
|
||||||
const scenarioBase = scenario.replace('_direct', '');
|
|
||||||
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: `${scenarioName}`,
|
|
||||||
data: this.calculator.monthlyData[scenario].map(d => d.roiPercent),
|
|
||||||
borderColor: colors[scenarioBase],
|
|
||||||
backgroundColor: colors[scenarioBase] + '30',
|
|
||||||
borderWidth: 3,
|
|
||||||
tension: 0.4,
|
|
||||||
pointBackgroundColor: this.calculator.monthlyData[scenario].map(d =>
|
|
||||||
d.roiPercent >= 0 ? colors[scenarioBase] : '#dc3545'
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.charts.roiProgression.update();
|
|
||||||
|
|
||||||
// Update Net Position Chart (Break-Even Analysis) - Direct Investment Only
|
|
||||||
this.charts.netPosition.data.labels = monthLabels;
|
|
||||||
this.charts.netPosition.data.datasets = scenarios.filter(s =>
|
|
||||||
this.calculator.results[s] && s.includes('_direct')
|
|
||||||
).map(scenario => {
|
|
||||||
const scenarioBase = scenario.replace('_direct', '');
|
|
||||||
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: `${scenarioName} Net Position`,
|
|
||||||
data: this.calculator.monthlyData[scenario].map(d => d.netPosition),
|
|
||||||
borderColor: colors[scenarioBase],
|
|
||||||
backgroundColor: colors[scenarioBase] + '30',
|
|
||||||
borderWidth: 3,
|
|
||||||
tension: 0.4,
|
|
||||||
fill: {
|
|
||||||
target: 'origin',
|
|
||||||
above: colors[scenarioBase] + '20',
|
|
||||||
below: '#dc354510'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.charts.netPosition.update();
|
|
||||||
|
|
||||||
// Update Model Comparison Chart - Direct comparison of both models
|
|
||||||
const inputs = this.calculator.getInputValues();
|
|
||||||
|
|
||||||
// Get unique scenario names (without model suffix)
|
|
||||||
const baseScenarios = ['conservative', 'moderate', 'aggressive'].filter(s =>
|
|
||||||
this.calculator.scenarios[s].enabled
|
|
||||||
);
|
|
||||||
const comparisonLabels = baseScenarios.map(s => this.calculator.scenarios[s].name);
|
|
||||||
|
|
||||||
// Get net profit data for both models
|
|
||||||
// Update CSP Revenue Breakdown Chart (Direct Investment Only)
|
|
||||||
this.charts.cspRevenue.data.labels = monthLabels;
|
|
||||||
this.charts.cspRevenue.data.datasets = [];
|
|
||||||
|
|
||||||
// Filter to only direct investment scenarios
|
|
||||||
const directScenarios = scenarios.filter(s =>
|
|
||||||
this.calculator.results[s] && s.includes('_direct')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Define revenue types and their styling
|
|
||||||
const revenueTypes = [
|
|
||||||
{
|
|
||||||
key: 'serviceRevenue',
|
|
||||||
label: 'Service Revenue',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderDash: [],
|
|
||||||
opacity: '40'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'coreRevenue',
|
|
||||||
label: 'Core Service Revenue',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderDash: [3, 3],
|
|
||||||
opacity: '60'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cspRevenue',
|
|
||||||
label: 'CSP Total',
|
|
||||||
borderWidth: 3,
|
|
||||||
borderDash: [],
|
|
||||||
opacity: 'FF'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'servalaRevenue',
|
|
||||||
label: 'Servala Revenue',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderDash: [5, 5],
|
|
||||||
opacity: '80',
|
|
||||||
color: '#6c757d'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add datasets organized by revenue type for better legend grouping
|
|
||||||
directScenarios.forEach(scenario => {
|
|
||||||
const scenarioBase = scenario.replace('_direct', '');
|
|
||||||
const scenarioName = this.calculator.scenarios[scenarioBase]?.name || scenarioBase;
|
|
||||||
const monthlyData = this.calculator.monthlyData[scenario];
|
|
||||||
const scenarioColor = colors[scenarioBase] || '#007bff';
|
|
||||||
|
|
||||||
revenueTypes.forEach(type => {
|
|
||||||
// Skip Servala revenue if it's zero (no revenue sharing)
|
|
||||||
if (type.key === 'servalaRevenue') {
|
|
||||||
const hasServalaRevenue = monthlyData.some(d => (d.servalaRevenue || 0) > 0);
|
|
||||||
if (!hasServalaRevenue) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip core revenue if it's zero
|
|
||||||
if (type.key === 'coreRevenue') {
|
|
||||||
const hasCoreRevenue = monthlyData.some(d => (d.coreRevenue || 0) > 0);
|
|
||||||
if (!hasCoreRevenue) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataValues = monthlyData.map(d => {
|
|
||||||
if (type.key === 'serviceRevenue') return d.serviceRevenue || d.monthlyRevenue || 0;
|
|
||||||
return d[type.key] || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.charts.cspRevenue.data.datasets.push({
|
|
||||||
label: `${scenarioName} - ${type.label}`,
|
|
||||||
data: dataValues,
|
|
||||||
borderColor: type.color || scenarioColor,
|
|
||||||
backgroundColor: (type.color || scenarioColor) + type.opacity,
|
|
||||||
borderWidth: type.borderWidth,
|
|
||||||
borderDash: type.borderDash,
|
|
||||||
fill: false,
|
|
||||||
tension: 0.1,
|
|
||||||
pointBackgroundColor: type.color || scenarioColor,
|
|
||||||
pointBorderColor: '#fff',
|
|
||||||
pointBorderWidth: 1
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.charts.cspRevenue.update();
|
|
||||||
|
|
||||||
// Update Performance Comparison Chart (ROI comparison for both models)
|
|
||||||
this.charts.performance.data.labels = comparisonLabels;
|
|
||||||
|
|
||||||
// Get ROI data for both models
|
|
||||||
const directROIData = baseScenarios.map(scenario => {
|
|
||||||
const result = this.calculator.results[scenario + '_direct'];
|
|
||||||
return result ? result.roi : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const loanROIData = baseScenarios.map(scenario => {
|
|
||||||
const result = this.calculator.results[scenario + '_loan'];
|
|
||||||
return result ? result.roi : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.charts.performance.data.datasets = [
|
|
||||||
{
|
|
||||||
label: 'Direct Investment ROI',
|
|
||||||
data: directROIData,
|
|
||||||
backgroundColor: baseScenarios.map(scenario => colors[scenario] + '80'),
|
|
||||||
borderColor: baseScenarios.map(scenario => colors[scenario]),
|
|
||||||
borderWidth: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: `Loan Model ROI (${(inputs.loanInterestRate * 100).toFixed(1)}% fixed)`,
|
|
||||||
data: loanROIData,
|
|
||||||
backgroundColor: '#ffc10780',
|
|
||||||
borderColor: '#e0a800',
|
|
||||||
borderWidth: 2
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
this.charts.performance.update();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating charts:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to calculate loan payment
|
|
||||||
calculateLoanPayment(principal, annualRate, years) {
|
|
||||||
try {
|
|
||||||
const monthlyRate = annualRate / 12;
|
|
||||||
const numberOfPayments = years * 12;
|
|
||||||
|
|
||||||
if (monthlyRate === 0) {
|
|
||||||
return principal / numberOfPayments;
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthlyPayment = principal * (monthlyRate * Math.pow(1 + monthlyRate, numberOfPayments)) /
|
|
||||||
(Math.pow(1 + monthlyRate, numberOfPayments) - 1);
|
|
||||||
|
|
||||||
return monthlyPayment;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating loan payment:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,599 +0,0 @@
|
||||||
/**
|
|
||||||
* Export Management Module
|
|
||||||
* Handles PDF and CSV export functionality
|
|
||||||
*/
|
|
||||||
class ExportManager {
|
|
||||||
constructor(calculator, uiManager) {
|
|
||||||
this.calculator = calculator;
|
|
||||||
this.uiManager = uiManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportToPDF() {
|
|
||||||
// Check if jsPDF is available
|
|
||||||
if (typeof window.jspdf === 'undefined') {
|
|
||||||
alert('PDF export library is loading. Please try again in a moment.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show model selection dialog
|
|
||||||
const selectedModels = await this.showModelSelectionDialog();
|
|
||||||
if (!selectedModels) {
|
|
||||||
return; // User cancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { jsPDF } = window.jspdf;
|
|
||||||
const doc = new jsPDF('p', 'mm', 'a4');
|
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
|
||||||
const pageHeight = doc.internal.pageSize.getHeight();
|
|
||||||
const margin = 20;
|
|
||||||
|
|
||||||
// Store selected models for use throughout the export
|
|
||||||
this.selectedModels = selectedModels;
|
|
||||||
|
|
||||||
// Color scheme
|
|
||||||
const colors = {
|
|
||||||
primary: [0, 123, 255],
|
|
||||||
success: [40, 167, 69],
|
|
||||||
warning: [255, 193, 7],
|
|
||||||
danger: [220, 53, 69],
|
|
||||||
dark: [33, 37, 41],
|
|
||||||
muted: [108, 117, 125]
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentPage = 1;
|
|
||||||
let yPos = 30;
|
|
||||||
|
|
||||||
// Helper function to add new page with header/footer
|
|
||||||
const addPage = () => {
|
|
||||||
doc.addPage();
|
|
||||||
currentPage++;
|
|
||||||
yPos = 30;
|
|
||||||
this.addPageHeader(doc, margin, colors);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to check if we need a new page
|
|
||||||
const checkPageBreak = (requiredSpace) => {
|
|
||||||
if (yPos + requiredSpace > pageHeight - 30) {
|
|
||||||
addPage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Title Page
|
|
||||||
this.createTitlePage(doc, pageWidth, pageHeight, margin, colors);
|
|
||||||
|
|
||||||
// Executive Summary Page
|
|
||||||
addPage();
|
|
||||||
yPos = this.createExecutiveSummary(doc, yPos, margin, colors);
|
|
||||||
|
|
||||||
// Investment Parameters Page
|
|
||||||
checkPageBreak(60);
|
|
||||||
yPos = this.createParametersSection(doc, yPos, margin, colors);
|
|
||||||
|
|
||||||
// Model Comparison Page
|
|
||||||
checkPageBreak(80);
|
|
||||||
yPos = this.createModelComparison(doc, yPos, margin, colors);
|
|
||||||
|
|
||||||
// Charts Pages
|
|
||||||
await this.addChartsToPDF(doc, colors, margin, addPage, checkPageBreak);
|
|
||||||
|
|
||||||
// Detailed Results Table
|
|
||||||
this.createDetailedResults(doc, colors, margin, addPage);
|
|
||||||
|
|
||||||
// Add footers to all pages
|
|
||||||
this.addFootersToAllPages(doc, currentPage, colors);
|
|
||||||
|
|
||||||
// Save the PDF
|
|
||||||
const filename = `servala-roi-investment-analysis-${new Date().toISOString().split('T')[0]}.pdf`;
|
|
||||||
doc.save(filename);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PDF Export Error:', error);
|
|
||||||
alert('An error occurred while generating the PDF. Please try again or export as CSV instead.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createTitlePage(doc, pageWidth, pageHeight, margin, colors) {
|
|
||||||
// Background design element
|
|
||||||
doc.setFillColor(...colors.primary);
|
|
||||||
doc.rect(0, 0, pageWidth, 60, 'F');
|
|
||||||
|
|
||||||
// White title text
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.setFontSize(28);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Investment Analysis Report', pageWidth/2, 35, { align: 'center' });
|
|
||||||
|
|
||||||
// Subtitle
|
|
||||||
doc.setFontSize(14);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text('CSP Partnership ROI Calculator', pageWidth/2, 45, { align: 'center' });
|
|
||||||
|
|
||||||
// Company section
|
|
||||||
doc.setTextColor(...colors.dark);
|
|
||||||
doc.setFontSize(20);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Servala Partnership Opportunity', pageWidth/2, 90, { align: 'center' });
|
|
||||||
|
|
||||||
// Investment summary box
|
|
||||||
const inputs = this.calculator.getInputValues();
|
|
||||||
const boxY = 110;
|
|
||||||
const boxHeight = 40;
|
|
||||||
|
|
||||||
doc.setFillColor(248, 249, 250);
|
|
||||||
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'F');
|
|
||||||
doc.setDrawColor(...colors.muted);
|
|
||||||
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'S');
|
|
||||||
|
|
||||||
doc.setFontSize(16);
|
|
||||||
doc.setTextColor(...colors.primary);
|
|
||||||
doc.text('Investment Overview', pageWidth/2, boxY + 12, { align: 'center' });
|
|
||||||
|
|
||||||
doc.setFontSize(12);
|
|
||||||
doc.setTextColor(...colors.dark);
|
|
||||||
doc.text(`Investment Amount: ${this.formatCurrency(inputs.investmentAmount)}`, pageWidth/2, boxY + 22, { align: 'center' });
|
|
||||||
doc.text(`Analysis Period: ${inputs.timeframe} years`, pageWidth/2, boxY + 32, { align: 'center' });
|
|
||||||
|
|
||||||
// Generated date
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setTextColor(...colors.muted);
|
|
||||||
const currentDate = new Date().toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
doc.text(`Generated on: ${currentDate}`, pageWidth/2, pageHeight - 30, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
addPageHeader(doc, margin, colors) {
|
|
||||||
doc.setFillColor(...colors.primary);
|
|
||||||
doc.rect(0, 0, doc.internal.pageSize.getWidth(), 15, 'F');
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Servala CSP Investment Analysis', margin, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
createExecutiveSummary(doc, yPos, margin, colors) {
|
|
||||||
doc.setFontSize(18);
|
|
||||||
doc.setTextColor(...colors.primary);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Executive Summary', margin, yPos);
|
|
||||||
yPos += 15;
|
|
||||||
|
|
||||||
const enabledResults = Object.values(this.calculator.results);
|
|
||||||
const directResults = enabledResults.filter(r => r.investmentModel === 'direct' && this.selectedModels.direct);
|
|
||||||
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan' && this.selectedModels.loan);
|
|
||||||
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.setTextColor(...colors.dark);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
|
|
||||||
// Investment models comparison
|
|
||||||
if (directResults.length > 0 && loanResults.length > 0) {
|
|
||||||
const avgDirectROI = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
|
|
||||||
const avgLoanROI = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
|
|
||||||
const avgDirectNetPos = directResults.reduce((sum, r) => sum + r.netPosition, 0) / directResults.length;
|
|
||||||
const avgLoanNetPos = loanResults.reduce((sum, r) => sum + r.netPosition, 0) / loanResults.length;
|
|
||||||
|
|
||||||
doc.text('This analysis compares two investment models across multiple growth scenarios:', margin, yPos);
|
|
||||||
yPos += 10;
|
|
||||||
|
|
||||||
// Direct Investment Summary
|
|
||||||
doc.setFillColor(40, 167, 69, 0.1); // Success color with opacity
|
|
||||||
doc.roundedRect(margin, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
|
|
||||||
|
|
||||||
doc.setTextColor(...colors.success);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Direct Investment Model', margin + 5, yPos + 8);
|
|
||||||
|
|
||||||
doc.setTextColor(...colors.dark);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgDirectROI)}`, margin + 5, yPos + 16);
|
|
||||||
doc.text(`Average Net Profit: ${this.formatCurrency(avgDirectNetPos)}`, margin + 5, yPos + 24);
|
|
||||||
doc.text('Performance-based with bonuses', margin + 5, yPos + 32);
|
|
||||||
|
|
||||||
// Loan Model Summary
|
|
||||||
const loanBoxX = margin + (doc.internal.pageSize.getWidth() - 3*margin)/2 + 10;
|
|
||||||
doc.setFillColor(255, 193, 7, 0.1); // Warning color with opacity
|
|
||||||
doc.roundedRect(loanBoxX, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
|
|
||||||
|
|
||||||
doc.setTextColor(...colors.warning);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setFontSize(11);
|
|
||||||
doc.text('Loan Model', loanBoxX + 5, yPos + 8);
|
|
||||||
|
|
||||||
doc.setTextColor(...colors.dark);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgLoanROI)}`, loanBoxX + 5, yPos + 16);
|
|
||||||
doc.text(`Average Net Profit: ${this.formatCurrency(avgLoanNetPos)}`, loanBoxX + 5, yPos + 24);
|
|
||||||
doc.text('Fixed returns, guaranteed', loanBoxX + 5, yPos + 32);
|
|
||||||
|
|
||||||
yPos += 45;
|
|
||||||
}
|
|
||||||
|
|
||||||
return yPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
createParametersSection(doc, yPos, margin, colors) {
|
|
||||||
doc.setFontSize(16);
|
|
||||||
doc.setTextColor(...colors.primary);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Investment Parameters', margin, yPos);
|
|
||||||
yPos += 15;
|
|
||||||
|
|
||||||
const inputs = this.calculator.getInputValues();
|
|
||||||
|
|
||||||
// Create parameter table
|
|
||||||
const params = [
|
|
||||||
['Investment Amount', this.formatCurrency(inputs.investmentAmount)],
|
|
||||||
['Investment Timeframe', `${inputs.timeframe} years`],
|
|
||||||
['Service Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance)} / month`],
|
|
||||||
['Core Service Revenue per Instance', `${this.formatCurrency(inputs.coreServiceRevenue)} / month`],
|
|
||||||
['Total Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance + inputs.coreServiceRevenue)} / month`],
|
|
||||||
['Loan Interest Rate', `${(inputs.loanInterestRate * 100).toFixed(1)}%`],
|
|
||||||
['Direct Investment Share', `${(inputs.servalaShare * 100).toFixed(0)}% to Servala`],
|
|
||||||
['Grace Period', `${inputs.gracePeriod} months`]
|
|
||||||
];
|
|
||||||
|
|
||||||
doc.setFontSize(11);
|
|
||||||
params.forEach(([label, value]) => {
|
|
||||||
doc.setTextColor(...colors.dark);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
doc.text(label + ':', margin + 5, yPos);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text(value, margin + 80, yPos);
|
|
||||||
yPos += 8;
|
|
||||||
});
|
|
||||||
|
|
||||||
return yPos + 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
createModelComparison(doc, yPos, margin, colors) {
|
|
||||||
doc.setFontSize(16);
|
|
||||||
doc.setTextColor(...colors.primary);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Investment Model Comparison', margin, yPos);
|
|
||||||
yPos += 15;
|
|
||||||
|
|
||||||
// Create comparison table
|
|
||||||
const scenarios = ['Conservative', 'Moderate', 'Aggressive'];
|
|
||||||
const tableData = [];
|
|
||||||
|
|
||||||
scenarios.forEach(scenarioName => {
|
|
||||||
const directResult = Object.values(this.calculator.results)
|
|
||||||
.find(r => r.scenario === scenarioName && r.investmentModel === 'direct');
|
|
||||||
const loanResult = Object.values(this.calculator.results)
|
|
||||||
.find(r => r.scenario === scenarioName && r.investmentModel === 'loan');
|
|
||||||
|
|
||||||
if (directResult && loanResult) {
|
|
||||||
tableData.push([
|
|
||||||
scenarioName,
|
|
||||||
this.formatCurrency(directResult.netPosition),
|
|
||||||
this.uiManager.formatPercentage(directResult.roi),
|
|
||||||
this.formatCurrency(loanResult.netPosition),
|
|
||||||
this.uiManager.formatPercentage(loanResult.roi)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Table headers
|
|
||||||
const headers = ['Scenario', 'Direct Net Profit', 'Direct ROI', 'Loan Net Profit', 'Loan ROI'];
|
|
||||||
const colWidths = [30, 35, 25, 35, 25];
|
|
||||||
|
|
||||||
// Draw table
|
|
||||||
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
|
|
||||||
|
|
||||||
return yPos + (tableData.length + 2) * 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addChartsToPDF(doc, colors, margin, addPage, checkPageBreak) {
|
|
||||||
// Add charts by capturing them as images
|
|
||||||
const chartIds = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'modelComparisonChart'];
|
|
||||||
const chartTitles = [
|
|
||||||
'ROI Progression Over Time',
|
|
||||||
'Net Financial Position',
|
|
||||||
'Performance Comparison',
|
|
||||||
'Investment Model Comparison'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (let i = 0; i < chartIds.length; i++) {
|
|
||||||
checkPageBreak(120);
|
|
||||||
|
|
||||||
const canvas = document.getElementById(chartIds[i]);
|
|
||||||
if (canvas) {
|
|
||||||
// Add chart title
|
|
||||||
doc.setFontSize(14);
|
|
||||||
doc.setTextColor(...colors.primary);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text(chartTitles[i], margin, doc.internal.pageSize.getHeight() - 250);
|
|
||||||
|
|
||||||
// Capture chart as image
|
|
||||||
const imgData = canvas.toDataURL('image/png', 1.0);
|
|
||||||
doc.addImage(imgData, 'PNG', margin, doc.internal.pageSize.getHeight() - 240,
|
|
||||||
doc.internal.pageSize.getWidth() - 2*margin, 100);
|
|
||||||
|
|
||||||
if (i < chartIds.length - 1) addPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createDetailedResults(doc, colors, margin, addPage) {
|
|
||||||
addPage();
|
|
||||||
let yPos = 30;
|
|
||||||
|
|
||||||
doc.setFontSize(16);
|
|
||||||
doc.setTextColor(...colors.primary);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('Detailed Financial Results', margin, yPos);
|
|
||||||
yPos += 15;
|
|
||||||
|
|
||||||
// Create detailed results table
|
|
||||||
const headers = ['Scenario', 'Model', 'Net Profit', 'ROI', 'Break-even'];
|
|
||||||
const colWidths = [35, 25, 35, 25, 30];
|
|
||||||
const tableData = [];
|
|
||||||
|
|
||||||
Object.values(this.calculator.results).forEach(result => {
|
|
||||||
tableData.push([
|
|
||||||
result.scenario,
|
|
||||||
result.investmentModel === 'direct' ? 'Direct' : 'Loan',
|
|
||||||
this.formatCurrency(result.netPosition),
|
|
||||||
this.uiManager.formatPercentage(result.roi),
|
|
||||||
result.breakEvenMonth ? `${result.breakEvenMonth} months` : 'N/A'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawTable(doc, x, y, headers, data, colWidths, colors) {
|
|
||||||
const rowHeight = 8;
|
|
||||||
const startX = x;
|
|
||||||
let currentY = y;
|
|
||||||
|
|
||||||
// Draw headers
|
|
||||||
doc.setFillColor(...colors.primary);
|
|
||||||
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
|
|
||||||
|
|
||||||
doc.setTextColor(255, 255, 255);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.setFontSize(10);
|
|
||||||
|
|
||||||
let currentX = startX;
|
|
||||||
headers.forEach((header, i) => {
|
|
||||||
doc.text(header, currentX + 2, currentY - 1);
|
|
||||||
currentX += colWidths[i];
|
|
||||||
});
|
|
||||||
|
|
||||||
currentY += rowHeight;
|
|
||||||
|
|
||||||
// Draw data rows
|
|
||||||
doc.setTextColor(...colors.dark);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
|
|
||||||
data.forEach((row, rowIndex) => {
|
|
||||||
if (rowIndex % 2 === 1) {
|
|
||||||
doc.setFillColor(248, 249, 250);
|
|
||||||
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
|
|
||||||
}
|
|
||||||
|
|
||||||
currentX = startX;
|
|
||||||
row.forEach((cell, i) => {
|
|
||||||
doc.text(String(cell), currentX + 2, currentY - 1);
|
|
||||||
currentX += colWidths[i];
|
|
||||||
});
|
|
||||||
|
|
||||||
currentY += rowHeight;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw table border
|
|
||||||
doc.setDrawColor(...colors.muted);
|
|
||||||
doc.rect(startX, y - 6, colWidths.reduce((sum, w) => sum + w, 0), (data.length + 1) * rowHeight, 'S');
|
|
||||||
}
|
|
||||||
|
|
||||||
addFootersToAllPages(doc, totalPages, colors) {
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
|
||||||
doc.setPage(i);
|
|
||||||
const pageHeight = doc.internal.pageSize.getHeight();
|
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
|
||||||
|
|
||||||
doc.setFontSize(8);
|
|
||||||
doc.setTextColor(...colors.muted);
|
|
||||||
doc.text(`Page ${i} of ${totalPages}`, pageWidth - 30, pageHeight - 10);
|
|
||||||
doc.text('Generated by Servala CSP ROI Calculator', 20, pageHeight - 10);
|
|
||||||
|
|
||||||
// Add a line above footer
|
|
||||||
doc.setDrawColor(...colors.muted);
|
|
||||||
doc.line(20, pageHeight - 15, pageWidth - 20, pageHeight - 15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async showModelSelectionDialog() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
// Create modal dialog
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 10000;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const dialog = document.createElement('div');
|
|
||||||
dialog.style.cssText = `
|
|
||||||
background: white;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<h5 style="margin-bottom: 1rem; color: #007bff;">Select Investment Models for PDF</h5>
|
|
||||||
<p style="margin-bottom: 1.5rem; color: #6c757d;">Choose which models to include in your PDF report:</p>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
|
|
||||||
<input type="checkbox" id="pdf-direct-model" checked style="margin-right: 0.5rem;">
|
|
||||||
<span style="color: #28a745; font-weight: bold;">Direct Investment Model</span>
|
|
||||||
</label>
|
|
||||||
<small style="color: #6c757d; margin-left: 1.5rem;">Performance-based revenue sharing with scaling bonuses</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 2rem;">
|
|
||||||
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
|
|
||||||
<input type="checkbox" id="pdf-loan-model" checked style="margin-right: 0.5rem;">
|
|
||||||
<span style="color: #ffc107; font-weight: bold;">Loan Model</span>
|
|
||||||
</label>
|
|
||||||
<small style="color: #6c757d; margin-left: 1.5rem;">Fixed interest lending with guaranteed returns</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
|
||||||
<button id="pdf-cancel-btn" style="padding: 0.5rem 1rem; border: 1px solid #ccc; background: white; border-radius: 4px; cursor: pointer;">Cancel</button>
|
|
||||||
<button id="pdf-export-btn" style="padding: 0.5rem 1rem; border: none; background: #007bff; color: white; border-radius: 4px; cursor: pointer;">Export PDF</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
modal.appendChild(dialog);
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
// Handle button clicks
|
|
||||||
document.getElementById('pdf-cancel-btn').addEventListener('click', () => {
|
|
||||||
document.body.removeChild(modal);
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pdf-export-btn').addEventListener('click', () => {
|
|
||||||
const directSelected = document.getElementById('pdf-direct-model').checked;
|
|
||||||
const loanSelected = document.getElementById('pdf-loan-model').checked;
|
|
||||||
|
|
||||||
if (!directSelected && !loanSelected) {
|
|
||||||
alert('Please select at least one investment model.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.removeChild(modal);
|
|
||||||
resolve({
|
|
||||||
direct: directSelected,
|
|
||||||
loan: loanSelected
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close on background click
|
|
||||||
modal.addEventListener('click', (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
document.body.removeChild(modal);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
formatCurrency(amount) {
|
|
||||||
try {
|
|
||||||
// Get current currency from the page
|
|
||||||
const currencyElement = document.getElementById('currency');
|
|
||||||
const currency = currencyElement ? currencyElement.value : 'CHF';
|
|
||||||
|
|
||||||
// Determine locale based on currency
|
|
||||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
|
||||||
|
|
||||||
// Consistent currency formatting: currency in front, no decimals for whole numbers
|
|
||||||
return new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0
|
|
||||||
}).format(amount);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error formatting currency:', error);
|
|
||||||
return `CHF ${Math.round(amount).toLocaleString()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exportToCSV() {
|
|
||||||
try {
|
|
||||||
// Create comprehensive CSV with summary and detailed data
|
|
||||||
let csvContent = 'CSP ROI Calculator Export\n';
|
|
||||||
csvContent += `Generated on: ${new Date().toLocaleDateString()}\n\n`;
|
|
||||||
|
|
||||||
// Add input parameters
|
|
||||||
csvContent += 'INPUT PARAMETERS\n';
|
|
||||||
const inputs = this.calculator.getInputValues();
|
|
||||||
csvContent += `Currency,${inputs.currency}\n`;
|
|
||||||
csvContent += `Investment Amount,${inputs.investmentAmount}\n`;
|
|
||||||
csvContent += `Timeframe (years),${inputs.timeframe}\n`;
|
|
||||||
csvContent += `Investment Model,${inputs.investmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'}\n`;
|
|
||||||
if (inputs.investmentModel === 'loan') {
|
|
||||||
csvContent += `Loan Interest Rate (%),${(inputs.loanInterestRate * 100).toFixed(1)}\n`;
|
|
||||||
}
|
|
||||||
csvContent += `Service Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance}\n`;
|
|
||||||
csvContent += `Core Service Revenue per Instance (${inputs.currency}),${inputs.coreServiceRevenue}\n`;
|
|
||||||
csvContent += `Total Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance + inputs.coreServiceRevenue}\n`;
|
|
||||||
if (inputs.investmentModel === 'direct') {
|
|
||||||
csvContent += `Servala Share (%),${(inputs.servalaShare * 100).toFixed(0)}\n`;
|
|
||||||
csvContent += `Grace Period (months),${inputs.gracePeriod}\n`;
|
|
||||||
}
|
|
||||||
csvContent += '\n';
|
|
||||||
|
|
||||||
// Add scenario summary
|
|
||||||
csvContent += 'SCENARIO SUMMARY\n';
|
|
||||||
csvContent += `Scenario,Investment Model,Final Instances,Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),ROI (%),Break-even (months)\n`;
|
|
||||||
|
|
||||||
Object.values(this.calculator.results).forEach(result => {
|
|
||||||
const modelText = result.investmentModel === 'loan' ? 'Loan' : 'Direct';
|
|
||||||
csvContent += `${result.scenario},${modelText},${result.finalInstances},${result.totalRevenue.toFixed(2)},${result.cspRevenue.toFixed(2)},${result.servalaRevenue.toFixed(2)},${result.roi.toFixed(2)},${result.breakEvenMonth || 'N/A'}\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
csvContent += '\n';
|
|
||||||
|
|
||||||
// Add detailed monthly data
|
|
||||||
csvContent += 'MONTHLY BREAKDOWN\n';
|
|
||||||
csvContent += `Month,Scenario,New Instances,Churned Instances,Total Instances,Service Revenue (${inputs.currency}),Core Revenue (${inputs.currency}),Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),Cumulative CSP Revenue (${inputs.currency}),Cumulative Servala Revenue (${inputs.currency})\n`;
|
|
||||||
|
|
||||||
// Combine all monthly data
|
|
||||||
const allData = [];
|
|
||||||
Object.keys(this.calculator.monthlyData).forEach(scenario => {
|
|
||||||
this.calculator.monthlyData[scenario].forEach(monthData => {
|
|
||||||
allData.push(monthData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
allData.sort((a, b) => a.month - b.month || a.scenario.localeCompare(b.scenario));
|
|
||||||
|
|
||||||
allData.forEach(data => {
|
|
||||||
csvContent += `${data.month},${data.scenario},${data.newInstances},${data.churnedInstances},${data.totalInstances},${(data.serviceRevenue || data.monthlyRevenue || 0).toFixed(2)},${(data.coreRevenue || 0).toFixed(2)},${(data.totalRevenue || data.monthlyRevenue || 0).toFixed(2)},${data.cspRevenue.toFixed(2)},${data.servalaRevenue.toFixed(2)},${data.cumulativeCSPRevenue.toFixed(2)},${data.cumulativeServalaRevenue.toFixed(2)}\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create and download file
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const link = document.createElement('a');
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
link.setAttribute('href', url);
|
|
||||||
|
|
||||||
const filename = `servala-csp-roi-data-${new Date().toISOString().split('T')[0]}.csv`;
|
|
||||||
link.setAttribute('download', filename);
|
|
||||||
link.style.visibility = 'hidden';
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('CSV Export Error:', error);
|
|
||||||
alert('An error occurred while generating the CSV file. Please try again.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
/**
|
|
||||||
* Input Utilities Module
|
|
||||||
* Handles input formatting, validation, and parsing
|
|
||||||
*/
|
|
||||||
class InputUtils {
|
|
||||||
static formatNumberWithCommas(num, currency = null) {
|
|
||||||
try {
|
|
||||||
// Get current currency if not provided
|
|
||||||
if (!currency) {
|
|
||||||
const currencyElement = document.getElementById('currency');
|
|
||||||
currency = currencyElement ? currencyElement.value : 'CHF';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use appropriate locale for number formatting
|
|
||||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
|
||||||
return parseInt(num).toLocaleString(locale);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error formatting number with commas:', error);
|
|
||||||
return String(num);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static parseFormattedNumber(str) {
|
|
||||||
if (typeof str !== 'string') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Remove all non-numeric characters except decimal points and commas
|
|
||||||
const cleaned = str.replace(/[^\d,.-]/g, '');
|
|
||||||
|
|
||||||
// Handle empty string
|
|
||||||
if (!cleaned) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove commas and parse as float
|
|
||||||
const result = parseFloat(cleaned.replace(/,/g, ''));
|
|
||||||
|
|
||||||
// Return 0 for invalid numbers or NaN
|
|
||||||
return isNaN(result) ? 0 : result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing formatted number:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static handleInvestmentAmountInput(input) {
|
|
||||||
if (!input || typeof input.value !== 'string') {
|
|
||||||
console.error('Invalid input element provided to handleInvestmentAmountInput');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Allow only digits, no immediate formatting during typing
|
|
||||||
let value = input.value.replace(/[^\d]/g, '');
|
|
||||||
|
|
||||||
// Handle empty input
|
|
||||||
if (!value) {
|
|
||||||
input.setAttribute('data-value', '0');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the numeric value
|
|
||||||
let numericValue = parseInt(value) || 0;
|
|
||||||
|
|
||||||
// Update the data attribute with the raw numeric value (no limits during typing)
|
|
||||||
input.setAttribute('data-value', numericValue.toString());
|
|
||||||
|
|
||||||
// Update the input value (keep it clean, no commas during typing)
|
|
||||||
input.value = value;
|
|
||||||
|
|
||||||
// Update the slider if it exists (with limits)
|
|
||||||
const slider = document.getElementById('investment-slider');
|
|
||||||
if (slider) {
|
|
||||||
const minValue = parseInt(slider.min) || 100000;
|
|
||||||
const maxValue = parseInt(slider.max) || 2000000;
|
|
||||||
const sliderValue = Math.max(minValue, Math.min(maxValue, numericValue));
|
|
||||||
slider.value = sliderValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger calculations with debouncing
|
|
||||||
if (window.ROICalculatorApp && window.ROICalculatorApp.calculator) {
|
|
||||||
clearTimeout(input._calculationTimeout);
|
|
||||||
input._calculationTimeout = setTimeout(() => {
|
|
||||||
window.ROICalculatorApp.calculator.updateCalculations();
|
|
||||||
}, 300); // 300ms delay to avoid excessive calculations during typing
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling investment amount input:', error);
|
|
||||||
// Set a safe default value
|
|
||||||
input.setAttribute('data-value', '500000');
|
|
||||||
input.value = '500000';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static handleInvestmentAmountBlur(input) {
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the numeric value
|
|
||||||
let numericValue = parseInt(input.getAttribute('data-value')) || 500000;
|
|
||||||
|
|
||||||
// Enforce min/max limits on blur
|
|
||||||
const minValue = 100000;
|
|
||||||
const maxValue = 2000000;
|
|
||||||
|
|
||||||
if (numericValue < minValue) {
|
|
||||||
numericValue = minValue;
|
|
||||||
} else if (numericValue > maxValue) {
|
|
||||||
numericValue = maxValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the data attribute with the corrected value
|
|
||||||
input.setAttribute('data-value', numericValue.toString());
|
|
||||||
|
|
||||||
// Format and display the value with commas on blur
|
|
||||||
input.value = InputUtils.formatNumberWithCommas(numericValue);
|
|
||||||
|
|
||||||
// Update the slider
|
|
||||||
const slider = document.getElementById('investment-slider');
|
|
||||||
if (slider) {
|
|
||||||
slider.value = numericValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger immediate calculation on blur
|
|
||||||
if (window.ROICalculatorApp && window.ROICalculatorApp.calculator) {
|
|
||||||
clearTimeout(input._calculationTimeout);
|
|
||||||
window.ROICalculatorApp.calculator.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling investment amount blur:', error);
|
|
||||||
// Set a safe default value
|
|
||||||
input.setAttribute('data-value', '500000');
|
|
||||||
input.value = '500,000';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static handleInvestmentAmountFocus(input) {
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Remove commas when focusing for easier editing
|
|
||||||
const numericValue = input.getAttribute('data-value') || '500000';
|
|
||||||
input.value = numericValue;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling investment amount focus:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getCSRFToken() {
|
|
||||||
try {
|
|
||||||
// Try to get CSRF token from meta tag first
|
|
||||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
|
||||||
if (metaTag) {
|
|
||||||
return metaTag.getAttribute('content');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to cookie method
|
|
||||||
const cookies = document.cookie.split(';');
|
|
||||||
for (let cookie of cookies) {
|
|
||||||
const [name, value] = cookie.trim().split('=');
|
|
||||||
if (name === 'csrftoken') {
|
|
||||||
return decodeURIComponent(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no CSRF token found, return empty string (will cause server error, but won't break JS)
|
|
||||||
console.error('CSRF token not found');
|
|
||||||
return '';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting CSRF token:', error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,634 +0,0 @@
|
||||||
/**
|
|
||||||
* ROI Calculator Application
|
|
||||||
* Main application class that coordinates all modules
|
|
||||||
*/
|
|
||||||
class ROICalculatorApp {
|
|
||||||
constructor() {
|
|
||||||
this.calculator = null;
|
|
||||||
this.chartManager = null;
|
|
||||||
this.uiManager = null;
|
|
||||||
this.exportManager = null;
|
|
||||||
this.isInitialized = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
if (this.isInitialized) {
|
|
||||||
console.warn('ROI Calculator already initialized');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create the main calculator instance
|
|
||||||
this.calculator = new ROICalculator();
|
|
||||||
|
|
||||||
// Create UI and chart managers
|
|
||||||
this.uiManager = new UIManager(this.calculator);
|
|
||||||
this.chartManager = new ChartManager(this.calculator);
|
|
||||||
this.exportManager = new ExportManager(this.calculator, this.uiManager);
|
|
||||||
|
|
||||||
// Replace the methods in calculator with manager methods
|
|
||||||
this.calculator.updateSummaryMetrics = () => this.uiManager.updateSummaryMetrics();
|
|
||||||
this.calculator.updateCharts = () => this.chartManager.updateCharts();
|
|
||||||
this.calculator.updateComparisonTable = () => this.uiManager.updateComparisonTable();
|
|
||||||
this.calculator.updateMonthlyBreakdown = () => this.uiManager.updateMonthlyBreakdown();
|
|
||||||
this.calculator.initializeCharts = () => this.chartManager.initializeCharts();
|
|
||||||
this.calculator.formatCurrency = (amount) => this.uiManager.formatCurrency(amount);
|
|
||||||
this.calculator.formatCurrencyDetailed = (amount) => this.uiManager.formatCurrencyDetailed(amount);
|
|
||||||
this.calculator.formatPercentage = (value) => this.uiManager.formatPercentage(value);
|
|
||||||
|
|
||||||
// Re-initialize charts with the chart manager
|
|
||||||
this.chartManager.initializeCharts();
|
|
||||||
this.calculator.charts = this.chartManager.charts;
|
|
||||||
|
|
||||||
// Initialize tooltips
|
|
||||||
this.initializeTooltips();
|
|
||||||
|
|
||||||
// Check export libraries
|
|
||||||
this.checkExportLibraries();
|
|
||||||
|
|
||||||
// Run initial calculation
|
|
||||||
this.calculator.updateCalculations();
|
|
||||||
|
|
||||||
this.isInitialized = true;
|
|
||||||
console.log('ROI Calculator initialized successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize ROI Calculator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize tooltips with Bootstrap (loaded directly via CDN)
|
|
||||||
initializeTooltips() {
|
|
||||||
// Wait for Bootstrap to be available
|
|
||||||
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
|
||||||
try {
|
|
||||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
|
||||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
|
||||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to initialize Bootstrap tooltips:', error);
|
|
||||||
this.initializeNativeTooltips();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Retry after a short delay for deferred scripts
|
|
||||||
setTimeout(() => this.initializeTooltips(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeNativeTooltips() {
|
|
||||||
try {
|
|
||||||
var tooltipElements = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
|
||||||
tooltipElements.forEach(function (element) {
|
|
||||||
// Ensure the title attribute is set for native tooltips
|
|
||||||
var tooltipContent = element.getAttribute('title');
|
|
||||||
if (!tooltipContent) {
|
|
||||||
// Get tooltip content from data-bs-original-title or title attribute
|
|
||||||
tooltipContent = element.getAttribute('data-bs-original-title');
|
|
||||||
if (tooltipContent) {
|
|
||||||
element.setAttribute('title', tooltipContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing native tooltips:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if export libraries are loaded
|
|
||||||
checkExportLibraries() {
|
|
||||||
try {
|
|
||||||
const pdfButton = document.querySelector('button[onclick="exportToPDF()"]');
|
|
||||||
|
|
||||||
if (typeof window.jspdf === 'undefined') {
|
|
||||||
if (pdfButton) {
|
|
||||||
pdfButton.disabled = true;
|
|
||||||
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Loading PDF...';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof window.jspdf !== 'undefined' && pdfButton) {
|
|
||||||
pdfButton.disabled = false;
|
|
||||||
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Export PDF Report';
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking export libraries:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public API methods for global function calls
|
|
||||||
updateCalculations() {
|
|
||||||
if (this.calculator) {
|
|
||||||
this.calculator.updateCalculations();
|
|
||||||
this.updateInvestmentBenefits();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInvestmentBenefits() {
|
|
||||||
try {
|
|
||||||
const inputs = this.calculator.getInputValues();
|
|
||||||
const investmentAmount = inputs.investmentAmount;
|
|
||||||
const baseInvestment = 500000;
|
|
||||||
|
|
||||||
// Calculate market-realistic instance scaling factor
|
|
||||||
let scalingFactor;
|
|
||||||
if (investmentAmount <= baseInvestment) {
|
|
||||||
scalingFactor = investmentAmount / baseInvestment;
|
|
||||||
} else if (investmentAmount <= 1000000) {
|
|
||||||
const ratio = investmentAmount / baseInvestment;
|
|
||||||
scalingFactor = 1.0 + ((ratio - 1.0) * 0.5); // Linear scaling to 1.5x
|
|
||||||
} else if (investmentAmount <= 1500000) {
|
|
||||||
const progress = (investmentAmount - 1000000) / 500000;
|
|
||||||
scalingFactor = 1.5 + (progress * 0.3); // Up to 1.8x
|
|
||||||
} else {
|
|
||||||
const progress = (investmentAmount - 1500000) / 500000;
|
|
||||||
scalingFactor = 1.8 + (progress * 0.2); // Max 2.0x
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate sustainable revenue premium
|
|
||||||
let revenuePremium;
|
|
||||||
if (investmentAmount >= 2000000) {
|
|
||||||
revenuePremium = 20; // Maximum 20% premium
|
|
||||||
} else if (investmentAmount >= 1500000) {
|
|
||||||
revenuePremium = 15; // 15% premium
|
|
||||||
} else if (investmentAmount >= 1000000) {
|
|
||||||
revenuePremium = 10; // 10% premium
|
|
||||||
} else {
|
|
||||||
revenuePremium = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate commercially viable grace period
|
|
||||||
const baseGracePeriod = inputs.gracePeriod;
|
|
||||||
let gracePeriodBonus;
|
|
||||||
if (investmentAmount >= 1500000) {
|
|
||||||
gracePeriodBonus = 3; // 3 months max bonus
|
|
||||||
} else if (investmentAmount >= 1000000) {
|
|
||||||
gracePeriodBonus = 2; // 2 months bonus
|
|
||||||
} else {
|
|
||||||
gracePeriodBonus = Math.floor((investmentAmount - baseInvestment) / 500000);
|
|
||||||
}
|
|
||||||
const totalGracePeriod = Math.min(Math.max(0, baseGracePeriod + gracePeriodBonus), 6); // Max 6 months
|
|
||||||
|
|
||||||
// Calculate realistic max performance bonus
|
|
||||||
let maxBonus;
|
|
||||||
if (investmentAmount >= 2000000) {
|
|
||||||
maxBonus = 15; // 15% max bonus
|
|
||||||
} else if (investmentAmount >= 1500000) {
|
|
||||||
maxBonus = 12; // 12% max bonus
|
|
||||||
} else if (investmentAmount >= 1000000) {
|
|
||||||
maxBonus = 10; // 10% max bonus
|
|
||||||
} else {
|
|
||||||
maxBonus = 8; // 8% max bonus
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI elements
|
|
||||||
const instanceScaling = document.getElementById('instance-scaling');
|
|
||||||
if (instanceScaling) {
|
|
||||||
instanceScaling.textContent = scalingFactor.toFixed(1) + 'x';
|
|
||||||
instanceScaling.className = scalingFactor >= 2.0 ? 'benefit-value text-success fw-bold' :
|
|
||||||
scalingFactor >= 1.5 ? 'benefit-value text-primary fw-bold' : 'benefit-value text-secondary fw-bold';
|
|
||||||
}
|
|
||||||
|
|
||||||
const revenuePremiumEl = document.getElementById('revenue-premium');
|
|
||||||
if (revenuePremiumEl) {
|
|
||||||
revenuePremiumEl.textContent = '+' + revenuePremium + '%';
|
|
||||||
revenuePremiumEl.className = revenuePremium >= 40 ? 'benefit-value text-success fw-bold' :
|
|
||||||
revenuePremium >= 20 ? 'benefit-value text-warning fw-bold' : 'benefit-value text-secondary fw-bold';
|
|
||||||
}
|
|
||||||
|
|
||||||
const gracePeriodEl = document.getElementById('grace-period-display');
|
|
||||||
if (gracePeriodEl) {
|
|
||||||
gracePeriodEl.textContent = totalGracePeriod + ' months';
|
|
||||||
gracePeriodEl.className = totalGracePeriod >= 12 ? 'benefit-value text-success fw-bold' :
|
|
||||||
totalGracePeriod >= 9 ? 'benefit-value text-info fw-bold' : 'benefit-value text-secondary fw-bold';
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxBonusEl = document.getElementById('max-bonus');
|
|
||||||
if (maxBonusEl) {
|
|
||||||
maxBonusEl.textContent = maxBonus + '%';
|
|
||||||
maxBonusEl.className = maxBonus >= 30 ? 'benefit-value text-success fw-bold' :
|
|
||||||
maxBonus >= 20 ? 'benefit-value text-warning fw-bold' : 'benefit-value text-secondary fw-bold';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating investment benefits:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exportToPDF() {
|
|
||||||
if (this.exportManager) {
|
|
||||||
this.exportManager.exportToPDF();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exportToCSV() {
|
|
||||||
if (this.exportManager) {
|
|
||||||
this.exportManager.exportToCSV();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInvestmentAmount(value) {
|
|
||||||
try {
|
|
||||||
const input = document.getElementById('investment-amount');
|
|
||||||
if (input) {
|
|
||||||
input.setAttribute('data-value', value);
|
|
||||||
input.value = InputUtils.formatNumberWithCommas(value);
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating investment amount:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRevenuePerInstance(value) {
|
|
||||||
try {
|
|
||||||
const element = document.getElementById('revenue-per-instance');
|
|
||||||
if (element) {
|
|
||||||
element.value = value;
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating revenue per instance:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateServalaShare(value) {
|
|
||||||
try {
|
|
||||||
const element = document.getElementById('servala-share');
|
|
||||||
if (element) {
|
|
||||||
element.value = value;
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating servala share:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateGracePeriod(value) {
|
|
||||||
try {
|
|
||||||
const element = document.getElementById('grace-period');
|
|
||||||
if (element) {
|
|
||||||
element.value = value;
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating grace period:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateLoanRate(value) {
|
|
||||||
try {
|
|
||||||
const element = document.getElementById('loan-interest-rate');
|
|
||||||
if (element) {
|
|
||||||
element.value = value;
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating loan rate:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCoreServiceRevenue(value) {
|
|
||||||
try {
|
|
||||||
const element = document.getElementById('core-service-revenue');
|
|
||||||
if (element) {
|
|
||||||
element.value = value;
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating core service revenue:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCurrency(value) {
|
|
||||||
try {
|
|
||||||
const currencyElement = document.getElementById('currency');
|
|
||||||
if (currencyElement) {
|
|
||||||
currencyElement.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all currency-related UI labels
|
|
||||||
this.updateCurrencyLabels(value);
|
|
||||||
|
|
||||||
// Update calculations to reflect new currency formatting
|
|
||||||
this.updateCalculations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating currency:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCurrencyLabels(currency) {
|
|
||||||
try {
|
|
||||||
// Update investment amount prefix
|
|
||||||
const investmentPrefix = document.getElementById('investment-currency-prefix');
|
|
||||||
if (investmentPrefix) {
|
|
||||||
investmentPrefix.textContent = currency;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update investment min/max labels
|
|
||||||
const investmentMinLabel = document.getElementById('investment-min-label');
|
|
||||||
if (investmentMinLabel) {
|
|
||||||
investmentMinLabel.textContent = `${currency} 100K`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const investmentMaxLabel = document.getElementById('investment-max-label');
|
|
||||||
if (investmentMaxLabel) {
|
|
||||||
investmentMaxLabel.textContent = `${currency} 2M`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update revenue per instance suffix
|
|
||||||
const revenueSuffix = document.getElementById('revenue-currency-suffix');
|
|
||||||
if (revenueSuffix) {
|
|
||||||
revenueSuffix.textContent = `${currency}/month`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update core service revenue suffix (it's a direct span, not ID-based)
|
|
||||||
const coreRevenueInput = document.getElementById('core-service-revenue');
|
|
||||||
if (coreRevenueInput) {
|
|
||||||
const coreRevenueSpan = coreRevenueInput.parentElement.querySelector('.input-group-text');
|
|
||||||
if (coreRevenueSpan) {
|
|
||||||
coreRevenueSpan.textContent = `${currency}/month`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all other currency labels throughout the interface
|
|
||||||
const currencyLabels = document.querySelectorAll('.currency-label');
|
|
||||||
currencyLabels.forEach(label => {
|
|
||||||
label.textContent = currency;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update range slider labels with currency
|
|
||||||
const revenueLabel = document.querySelector('label[for="revenue-per-instance"]');
|
|
||||||
if (revenueLabel) {
|
|
||||||
revenueLabel.innerHTML = revenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
const coreRevenueLabel = document.querySelector('label[for="core-service-revenue"]');
|
|
||||||
if (coreRevenueLabel) {
|
|
||||||
coreRevenueLabel.innerHTML = coreRevenueLabel.innerHTML.replace(/(CHF|EUR)/, currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update investment amount field display if it has a value
|
|
||||||
const investmentInput = document.getElementById('investment-amount');
|
|
||||||
if (investmentInput && investmentInput.getAttribute('data-value')) {
|
|
||||||
const currentValue = investmentInput.getAttribute('data-value');
|
|
||||||
investmentInput.value = InputUtils.formatNumberWithCommas(currentValue, currency);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating currency labels:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateScenarioChurn(scenarioKey, churnRate) {
|
|
||||||
try {
|
|
||||||
if (this.calculator && this.calculator.scenarios[scenarioKey]) {
|
|
||||||
this.calculator.scenarios[scenarioKey].churnRate = parseFloat(churnRate) / 100;
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating scenario churn:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
|
|
||||||
try {
|
|
||||||
if (this.calculator && this.calculator.scenarios[scenarioKey] && this.calculator.scenarios[scenarioKey].phases[phaseIndex]) {
|
|
||||||
this.calculator.scenarios[scenarioKey].phases[phaseIndex].newInstancesPerMonth = parseInt(newInstancesPerMonth);
|
|
||||||
this.updateCalculations();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating scenario phase:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdvancedParameters() {
|
|
||||||
if (!confirm('Reset all advanced parameters to default values?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.calculator) return;
|
|
||||||
|
|
||||||
// Reset Conservative
|
|
||||||
this.calculator.scenarios.conservative.churnRate = 0.025;
|
|
||||||
this.calculator.scenarios.conservative.phases = [
|
|
||||||
{ months: 6, newInstancesPerMonth: 15 },
|
|
||||||
{ months: 6, newInstancesPerMonth: 24 },
|
|
||||||
{ months: 12, newInstancesPerMonth: 35 },
|
|
||||||
{ months: 12, newInstancesPerMonth: 40 }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Reset Moderate
|
|
||||||
this.calculator.scenarios.moderate.churnRate = 0.03;
|
|
||||||
this.calculator.scenarios.moderate.phases = [
|
|
||||||
{ months: 6, newInstancesPerMonth: 25 },
|
|
||||||
{ months: 6, newInstancesPerMonth: 45 },
|
|
||||||
{ months: 12, newInstancesPerMonth: 70 },
|
|
||||||
{ months: 12, newInstancesPerMonth: 90 }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Reset Aggressive
|
|
||||||
this.calculator.scenarios.aggressive.churnRate = 0.035;
|
|
||||||
this.calculator.scenarios.aggressive.phases = [
|
|
||||||
{ months: 6, newInstancesPerMonth: 40 },
|
|
||||||
{ months: 6, newInstancesPerMonth: 80 },
|
|
||||||
{ months: 12, newInstancesPerMonth: 120 },
|
|
||||||
{ months: 12, newInstancesPerMonth: 150 }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Update UI inputs
|
|
||||||
const inputMappings = [
|
|
||||||
['conservative-churn', '2.5'],
|
|
||||||
['conservative-phase-0', '15'],
|
|
||||||
['conservative-phase-1', '24'],
|
|
||||||
['conservative-phase-2', '35'],
|
|
||||||
['conservative-phase-3', '40'],
|
|
||||||
['moderate-churn', '3.0'],
|
|
||||||
['moderate-phase-0', '25'],
|
|
||||||
['moderate-phase-1', '45'],
|
|
||||||
['moderate-phase-2', '70'],
|
|
||||||
['moderate-phase-3', '90'],
|
|
||||||
['aggressive-churn', '3.5'],
|
|
||||||
['aggressive-phase-0', '40'],
|
|
||||||
['aggressive-phase-1', '80'],
|
|
||||||
['aggressive-phase-2', '120'],
|
|
||||||
['aggressive-phase-3', '150']
|
|
||||||
];
|
|
||||||
|
|
||||||
inputMappings.forEach(([id, value]) => {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (element) {
|
|
||||||
element.value = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateCalculations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting advanced parameters:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleScenario(scenarioKey) {
|
|
||||||
try {
|
|
||||||
const checkbox = document.getElementById(scenarioKey + '-enabled');
|
|
||||||
if (!checkbox || !this.calculator) return;
|
|
||||||
|
|
||||||
const enabled = checkbox.checked;
|
|
||||||
this.calculator.scenarios[scenarioKey].enabled = enabled;
|
|
||||||
|
|
||||||
const card = document.getElementById(scenarioKey + '-card');
|
|
||||||
if (card) {
|
|
||||||
if (enabled) {
|
|
||||||
card.classList.add('active');
|
|
||||||
card.classList.remove('disabled');
|
|
||||||
} else {
|
|
||||||
card.classList.remove('active');
|
|
||||||
card.classList.add('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateCalculations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling scenario:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleCollapsible(elementId) {
|
|
||||||
try {
|
|
||||||
const content = document.getElementById(elementId);
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const header = content.previousElementSibling;
|
|
||||||
if (!header) return;
|
|
||||||
|
|
||||||
const chevron = header.querySelector('.bi-chevron-down, .bi-chevron-up');
|
|
||||||
|
|
||||||
if (content.classList.contains('show')) {
|
|
||||||
content.classList.remove('show');
|
|
||||||
if (chevron) {
|
|
||||||
chevron.classList.remove('bi-chevron-up');
|
|
||||||
chevron.classList.add('bi-chevron-down');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content.classList.add('show');
|
|
||||||
if (chevron) {
|
|
||||||
chevron.classList.remove('bi-chevron-down');
|
|
||||||
chevron.classList.add('bi-chevron-up');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling collapsible:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetCalculator() {
|
|
||||||
if (!confirm('Are you sure you want to reset all parameters to default values?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Reset input values
|
|
||||||
const investmentInput = document.getElementById('investment-amount');
|
|
||||||
if (investmentInput) {
|
|
||||||
investmentInput.setAttribute('data-value', '500000');
|
|
||||||
investmentInput.value = '500,000';
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetMappings = [
|
|
||||||
['investment-slider', 500000],
|
|
||||||
['timeframe', 3],
|
|
||||||
['loan-interest-rate', 5.0],
|
|
||||||
['loan-rate-slider', 5.0],
|
|
||||||
['revenue-per-instance', 50],
|
|
||||||
['revenue-slider', 50],
|
|
||||||
['servala-share', 25],
|
|
||||||
['share-slider', 25],
|
|
||||||
['grace-period', 6],
|
|
||||||
['grace-slider', 6]
|
|
||||||
];
|
|
||||||
|
|
||||||
resetMappings.forEach(([id, value]) => {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (element) {
|
|
||||||
element.value = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Both models are now calculated simultaneously
|
|
||||||
|
|
||||||
// Reset scenarios
|
|
||||||
['conservative', 'moderate', 'aggressive'].forEach(scenario => {
|
|
||||||
const checkbox = document.getElementById(scenario + '-enabled');
|
|
||||||
const card = document.getElementById(scenario + '-card');
|
|
||||||
|
|
||||||
if (checkbox) checkbox.checked = true;
|
|
||||||
if (this.calculator) this.calculator.scenarios[scenario].enabled = true;
|
|
||||||
if (card) {
|
|
||||||
card.classList.add('active');
|
|
||||||
card.classList.remove('disabled');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset advanced parameters
|
|
||||||
this.resetAdvancedParameters();
|
|
||||||
|
|
||||||
// Both models are now calculated simultaneously, no toggle needed
|
|
||||||
|
|
||||||
// Recalculate
|
|
||||||
this.updateCalculations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting calculator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// toggleInvestmentModel removed - both models are now calculated simultaneously
|
|
||||||
|
|
||||||
updateMonthlyBreakdownFilters() {
|
|
||||||
try {
|
|
||||||
if (this.uiManager) {
|
|
||||||
this.uiManager.updateMonthlyBreakdown();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating monthly breakdown filters:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logout() {
|
|
||||||
if (!confirm('Are you sure you want to logout?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create a form to submit logout request
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = window.location.pathname;
|
|
||||||
|
|
||||||
// Add CSRF token from page meta tag or cookie
|
|
||||||
const csrfInput = document.createElement('input');
|
|
||||||
csrfInput.type = 'hidden';
|
|
||||||
csrfInput.name = 'csrfmiddlewaretoken';
|
|
||||||
csrfInput.value = InputUtils.getCSRFToken();
|
|
||||||
form.appendChild(csrfInput);
|
|
||||||
|
|
||||||
// Add logout parameter
|
|
||||||
const logoutInput = document.createElement('input');
|
|
||||||
logoutInput.type = 'hidden';
|
|
||||||
logoutInput.name = 'logout';
|
|
||||||
logoutInput.value = 'true';
|
|
||||||
form.appendChild(logoutInput);
|
|
||||||
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during logout:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the application when DOM is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
window.ROICalculatorApp = new ROICalculatorApp();
|
|
||||||
window.ROICalculatorApp.initialize();
|
|
||||||
});
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
/**
|
|
||||||
* UI Management Module
|
|
||||||
* Handles DOM updates, table rendering, and metric display
|
|
||||||
*/
|
|
||||||
class UIManager {
|
|
||||||
constructor(calculator) {
|
|
||||||
this.calculator = calculator;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSummaryMetrics() {
|
|
||||||
try {
|
|
||||||
const enabledResults = Object.values(this.calculator.results);
|
|
||||||
if (enabledResults.length === 0) {
|
|
||||||
this.setElementText('net-position-direct', 'CHF 0');
|
|
||||||
this.setElementText('net-position-loan', 'CHF 0');
|
|
||||||
this.setElementText('roi-percentage-direct', '0%');
|
|
||||||
this.setElementText('roi-percentage-loan', '0%');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate direct and loan results
|
|
||||||
const directResults = enabledResults.filter(r => r.investmentModel === 'direct');
|
|
||||||
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan');
|
|
||||||
|
|
||||||
// Calculate averages for direct investment
|
|
||||||
if (directResults.length > 0) {
|
|
||||||
const avgNetPositionDirect = directResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / directResults.length;
|
|
||||||
const avgROIDirect = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
|
|
||||||
|
|
||||||
this.setElementText('net-position-direct', this.formatCurrency(avgNetPositionDirect));
|
|
||||||
this.setElementText('roi-percentage-direct', this.formatPercentage(avgROIDirect));
|
|
||||||
|
|
||||||
// Update styling for direct metrics
|
|
||||||
const netPositionDirectElement = document.getElementById('net-position-direct');
|
|
||||||
if (netPositionDirectElement) {
|
|
||||||
if (avgNetPositionDirect > 0) {
|
|
||||||
netPositionDirectElement.className = 'fw-bold text-success';
|
|
||||||
} else if (avgNetPositionDirect < 0) {
|
|
||||||
netPositionDirectElement.className = 'fw-bold text-danger';
|
|
||||||
} else {
|
|
||||||
netPositionDirectElement.className = 'fw-bold';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate averages for loan investment
|
|
||||||
if (loanResults.length > 0) {
|
|
||||||
const avgNetPositionLoan = loanResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / loanResults.length;
|
|
||||||
const avgROILoan = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
|
|
||||||
|
|
||||||
this.setElementText('net-position-loan', this.formatCurrency(avgNetPositionLoan));
|
|
||||||
this.setElementText('roi-percentage-loan', this.formatPercentage(avgROILoan));
|
|
||||||
|
|
||||||
// Update styling for loan metrics
|
|
||||||
const netPositionLoanElement = document.getElementById('net-position-loan');
|
|
||||||
if (netPositionLoanElement) {
|
|
||||||
if (avgNetPositionLoan > 0) {
|
|
||||||
netPositionLoanElement.className = 'fw-bold text-success';
|
|
||||||
} else if (avgNetPositionLoan < 0) {
|
|
||||||
netPositionLoanElement.className = 'fw-bold text-danger';
|
|
||||||
} else {
|
|
||||||
netPositionLoanElement.className = 'fw-bold';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating summary metrics:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateComparisonTable() {
|
|
||||||
try {
|
|
||||||
const tbody = document.getElementById('comparison-tbody');
|
|
||||||
if (!tbody) {
|
|
||||||
console.error('Comparison table body not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
// Sort results by scenario first, then by model
|
|
||||||
const sortedResults = Object.values(this.calculator.results).sort((a, b) => {
|
|
||||||
const scenarioCompare = a.scenario.localeCompare(b.scenario);
|
|
||||||
if (scenarioCompare !== 0) return scenarioCompare;
|
|
||||||
return a.investmentModel.localeCompare(b.investmentModel);
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedResults.forEach(result => {
|
|
||||||
const isDirect = result.investmentModel === 'direct';
|
|
||||||
const scenarioColor = this.getScenarioColor(result.scenario);
|
|
||||||
|
|
||||||
// Model badge with better styling
|
|
||||||
const modelBadge = isDirect ?
|
|
||||||
'<span class="fw-bold text-success">Direct</span>' :
|
|
||||||
'<span class="fw-bold text-warning">Loan</span>';
|
|
||||||
|
|
||||||
// Key features based on model
|
|
||||||
const keyFeatures = isDirect ?
|
|
||||||
`Grace period: ${result.effectiveGracePeriod || 6} months<br>` +
|
|
||||||
`Performance multiplier: ${result.performanceMultiplier ? result.performanceMultiplier.toFixed(2) : '1.0'}x` :
|
|
||||||
`Fixed ${this.formatPercentage(this.calculator.getInputValues().loanInterestRate * 100)} rate<br>` +
|
|
||||||
'Predictable returns';
|
|
||||||
|
|
||||||
const row = tbody.insertRow();
|
|
||||||
row.innerHTML = `
|
|
||||||
<td><span class="fw-bold" style="color: ${scenarioColor}">${result.scenario}</span></td>
|
|
||||||
<td>${modelBadge}</td>
|
|
||||||
<td>${result.finalInstances ? result.finalInstances.toLocaleString() : 'N/A'}</td>
|
|
||||||
<td class="fw-bold ${result.netPosition >= 0 ? 'text-success' : 'text-danger'}">
|
|
||||||
${this.formatCurrencyDetailed(result.netPosition || 0)}
|
|
||||||
</td>
|
|
||||||
<td class="fw-bold ${result.roi >= 15 ? 'text-success' : result.roi >= 5 ? 'text-warning' : result.roi < 0 ? 'text-danger' : ''}">
|
|
||||||
${this.formatPercentage(result.roi || 0)}
|
|
||||||
</td>
|
|
||||||
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : '<span class="text-muted">No break-even</span>'}</td>
|
|
||||||
<td><small class="text-muted">${keyFeatures}</small></td>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating comparison table:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMonthlyBreakdown() {
|
|
||||||
try {
|
|
||||||
const tbody = document.getElementById('monthly-tbody');
|
|
||||||
if (!tbody) {
|
|
||||||
console.error('Monthly breakdown table body not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
// Get filter settings
|
|
||||||
const filters = this.getMonthlyBreakdownFilters();
|
|
||||||
|
|
||||||
// Combine all monthly data and sort by month, then scenario, then model
|
|
||||||
const allData = [];
|
|
||||||
Object.keys(this.calculator.monthlyData).forEach(resultKey => {
|
|
||||||
this.calculator.monthlyData[resultKey].forEach(monthData => {
|
|
||||||
const model = resultKey.includes('_loan') ? 'loan' : 'direct';
|
|
||||||
const scenario = resultKey.replace('_direct', '').replace('_loan', '');
|
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
if (!filters.models[model] || !filters.scenarios[scenario.toLowerCase()]) {
|
|
||||||
return; // Skip this entry if filtered out
|
|
||||||
}
|
|
||||||
|
|
||||||
allData.push({
|
|
||||||
...monthData,
|
|
||||||
model: model,
|
|
||||||
scenarioKey: scenario
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by month first, then scenario, then model (direct first)
|
|
||||||
allData.sort((a, b) => {
|
|
||||||
const monthCompare = a.month - b.month;
|
|
||||||
if (monthCompare !== 0) return monthCompare;
|
|
||||||
|
|
||||||
const scenarioCompare = a.scenarioKey.localeCompare(b.scenarioKey);
|
|
||||||
if (scenarioCompare !== 0) return scenarioCompare;
|
|
||||||
|
|
||||||
return a.model === 'direct' ? -1 : 1; // Direct first
|
|
||||||
});
|
|
||||||
|
|
||||||
allData.forEach(data => {
|
|
||||||
const row = tbody.insertRow();
|
|
||||||
const scenarioColor = this.getScenarioColor(data.scenario);
|
|
||||||
const isDirect = data.model === 'direct';
|
|
||||||
|
|
||||||
// Model styling
|
|
||||||
const modelBadge = isDirect ?
|
|
||||||
'<span class="text-success fw-bold">Direct</span>' :
|
|
||||||
'<span class="text-warning fw-bold">Loan</span>';
|
|
||||||
|
|
||||||
// Net position styling
|
|
||||||
const netPositionClass = (data.netPosition || 0) >= 0 ? 'text-success' : 'text-danger';
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<td class="text-center"><strong>${data.month}</strong></td>
|
|
||||||
<td><span style="color: ${scenarioColor}" class="fw-bold">${data.scenario}</span></td>
|
|
||||||
<td>${modelBadge}</td>
|
|
||||||
<td class="text-end">${data.totalInstances ? data.totalInstances.toLocaleString() : '0'}</td>
|
|
||||||
<td class="text-end">${this.formatCurrencyDetailed(data.serviceRevenue || data.monthlyRevenue || 0)}</td>
|
|
||||||
<td class="text-end">${this.formatCurrencyDetailed(data.coreRevenue || 0)}</td>
|
|
||||||
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.totalRevenue || data.monthlyRevenue || 0)}</td>
|
|
||||||
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.cspRevenue || 0)}</td>
|
|
||||||
<td class="text-end text-muted">${this.formatCurrencyDetailed(data.servalaRevenue || 0)}</td>
|
|
||||||
<td class="text-end fw-bold ${netPositionClass}">${this.formatCurrencyDetailed(data.netPosition || 0)}</td>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating monthly breakdown:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setElementText(elementId, text) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
element.textContent = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatCurrency(amount, currency = null) {
|
|
||||||
try {
|
|
||||||
// Get current currency if not provided
|
|
||||||
if (!currency) {
|
|
||||||
const currencyElement = document.getElementById('currency');
|
|
||||||
currency = currencyElement ? currencyElement.value : 'CHF';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine locale based on currency
|
|
||||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
|
||||||
|
|
||||||
// Use compact notation for large numbers in metric cards
|
|
||||||
if (amount >= 1000000) {
|
|
||||||
return new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
notation: 'compact',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 1
|
|
||||||
}).format(amount);
|
|
||||||
} else {
|
|
||||||
return new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error formatting currency:', error);
|
|
||||||
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatCurrencyDetailed(amount, currency = null) {
|
|
||||||
try {
|
|
||||||
// Get current currency if not provided
|
|
||||||
if (!currency) {
|
|
||||||
const currencyElement = document.getElementById('currency');
|
|
||||||
currency = currencyElement ? currencyElement.value : 'CHF';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine locale based on currency
|
|
||||||
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
|
|
||||||
|
|
||||||
// Use full formatting for detailed views (tables, exports)
|
|
||||||
return new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0
|
|
||||||
}).format(amount);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error formatting detailed currency:', error);
|
|
||||||
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatPercentage(value) {
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat('de-CH', {
|
|
||||||
style: 'percent',
|
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1
|
|
||||||
}).format(value / 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error formatting percentage:', error);
|
|
||||||
return `${value.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
getScenarioColor(scenarioName) {
|
|
||||||
switch(scenarioName.toLowerCase()) {
|
|
||||||
case 'conservative': return '#28a745';
|
|
||||||
case 'moderate': return '#ffc107';
|
|
||||||
case 'aggressive': return '#dc3545';
|
|
||||||
default: return '#6c757d';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getMonthlyBreakdownFilters() {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
models: {
|
|
||||||
direct: document.getElementById('breakdown-direct-enabled')?.checked ?? true,
|
|
||||||
loan: document.getElementById('breakdown-loan-enabled')?.checked ?? true
|
|
||||||
},
|
|
||||||
scenarios: {
|
|
||||||
conservative: document.getElementById('breakdown-conservative-enabled')?.checked ?? true,
|
|
||||||
moderate: document.getElementById('breakdown-moderate-enabled')?.checked ?? true,
|
|
||||||
aggressive: document.getElementById('breakdown-aggressive-enabled')?.checked ?? true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting monthly breakdown filters:', error);
|
|
||||||
// Return default filters if there's an error
|
|
||||||
return {
|
|
||||||
models: { direct: true, loan: true },
|
|
||||||
scenarios: { conservative: true, moderate: true, aggressive: true }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
{% social_meta_tags %}
|
{% social_meta_tags %}
|
||||||
{% json_ld_structured_data %}
|
{% json_ld_structured_data %}
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/bootstrap-icons.min.css" %}'>
|
<link rel="stylesheet" href='{% static "css/bootstrap-icons.min.css" %}'>
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/servala-main.css" %}'>
|
<link rel="stylesheet" type="text/css" href='{% static "css/servala-main.css" %}'>
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
|
|
||||||
|
|
@ -25,7 +25,6 @@
|
||||||
<script defer src="{% static "js/htmx204.min.js" %}"></script>
|
<script defer src="{% static "js/htmx204.min.js" %}"></script>
|
||||||
<script defer src="{% static "js/alpine-collapse.min.js" %}"></script>
|
<script defer src="{% static "js/alpine-collapse.min.js" %}"></script>
|
||||||
<script defer src="{% static "js/servala-main.js" %}"></script>
|
<script defer src="{% static "js/servala-main.js" %}"></script>
|
||||||
<script defer src="{% static "js/bootstrap.bundle.min.js" %}"></script>
|
|
||||||
<script defer src="{% static "js/servala-addons.js" %}"></script>
|
<script defer src="{% static "js/servala-addons.js" %}"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -35,49 +34,15 @@
|
||||||
<div class="bg-primary text-white py-2 text-center">
|
<div class="bg-primary text-white py-2 text-center">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="https://www.vshn.ch/en/blog/vshn-launches-servala-open-cloud-native-service-hub/" class="text-white text-decoration-none" target="_blank">
|
<a href="https://www.vshn.ch/en/blog/vshn-launches-servala-open-cloud-native-service-hub/" class="text-white text-decoration-none" target="_blank">
|
||||||
VSHN launches Servala – The Sovereign App Store
|
VSHN launches Servala – Open Cloud Native Service Hub
|
||||||
</a>
|
</a>
|
||||||
<i class="bi bi-box-arrow-up-right"></i>
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="site-header position-relative"
|
<header x-data="{sideNav: false, atTop: true}" class="site-header position-relative">
|
||||||
x-data="{
|
<div class="header-nav" :class="{ 'header-nav--top': atTop, 'header-nav--fixed': !atTop }"
|
||||||
isMenuOpen: false,
|
x-on:scroll.window="atTop = (window.pageYOffset > 200) ? false : true;">
|
||||||
isAtTop: true,
|
|
||||||
toggleMenu() {
|
|
||||||
this.isMenuOpen = !this.isMenuOpen;
|
|
||||||
},
|
|
||||||
closeMenu() {
|
|
||||||
if (window.innerWidth < 992 && this.isMenuOpen) {
|
|
||||||
this.isMenuOpen = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleScroll() {
|
|
||||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
||||||
this.isAtTop = scrollTop <= 200;
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
x-init="
|
|
||||||
let scrollThrottle = false;
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
if (!scrollThrottle) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
handleScroll();
|
|
||||||
scrollThrottle = false;
|
|
||||||
});
|
|
||||||
scrollThrottle = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (window.innerWidth >= 992 && isMenuOpen) {
|
|
||||||
isMenuOpen = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
">
|
|
||||||
<div class="header-nav"
|
|
||||||
:class="isAtTop ? 'header-nav--top' : 'header-nav--fixed'"
|
|
||||||
id="headerNav">
|
|
||||||
<div class="container-xl mx-auto px-3 px-lg-0 position-relative">
|
<div class="container-xl mx-auto px-3 px-lg-0 position-relative">
|
||||||
<div class="nav__wrapper d-flex justify-content-between align-items-center">
|
<div class="nav__wrapper d-flex justify-content-between align-items-center">
|
||||||
<div class="nav__brand logo">
|
<div class="nav__brand logo">
|
||||||
|
|
@ -85,40 +50,31 @@
|
||||||
<img src="{% static "img/header-logo.png" %}" alt="Servala Logo" width="191" height="43">
|
<img src="{% static "img/header-logo.png" %}" alt="Servala Logo" width="191" height="43">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav__menu"
|
<div x-cloak class="nav__menu" :class="sideNav ? 'nav__menu-active' : 'nav__menu-hidden'">
|
||||||
:class="isMenuOpen ? 'nav__menu-active' : 'nav__menu-hidden'"
|
|
||||||
id="navMenu">
|
|
||||||
<nav class="navbar d-lg-flex justify-content-lg-end align-items-lg-center">
|
<nav class="navbar d-lg-flex justify-content-lg-end align-items-lg-center">
|
||||||
<ul class="navbar__menu menu mr-lg-27">
|
<ul class="navbar__menu menu mr-lg-27">
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}" @click="closeMenu()">Home</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}">Home</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}" @click="closeMenu()">Services</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}">Services</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}" @click="closeMenu()">Cloud Provider</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}">Cloud Provider</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}" @click="closeMenu()">Partner</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}">Partner</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:article_list' %}" @click="closeMenu()">Articles</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:article_list' %}">Articles</a></li>
|
||||||
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:about' %}" @click="closeMenu()">About</a></li>
|
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:about' %}">About</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="menu-cta mb-0">
|
<ul class="menu-cta mb-0">
|
||||||
<li class="mr-17"><a class="btn btn-outline-light btn-outline-primary" href="{% url 'services:contact' %}" role="button" @click="closeMenu()">Contact</a></li>
|
<li class="mr-17"><a class="btn btn-outline-light btn-outline-primary" href="{% url 'services:contact' %}" role="button">Contact</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav__toggle">
|
<div class="nav__toggle">
|
||||||
<button @click="toggleMenu()"
|
<button @click="sideNav = !sideNav" name="menu" class="nav__button" role="button">
|
||||||
name="menu"
|
|
||||||
class="nav__button"
|
|
||||||
role="button"
|
|
||||||
:aria-expanded="isMenuOpen.toString()"
|
|
||||||
aria-controls="navMenu">
|
|
||||||
<svg class="nav__button-svg" width="22" height="24">
|
<svg class="nav__button-svg" width="22" height="24">
|
||||||
<line class="button-svg__line"
|
<line class="button-svg__line" :class="{ 'svg-line-top': sideNav === true }" id="top" x1="0" x2="22"
|
||||||
:class="isMenuOpen ? 'svg-line-top' : ''"
|
y1="6" y2="6"></line>
|
||||||
x1="0" x2="22" y1="6" y2="6"></line>
|
<line class="button-svg__line" :class="{ 'svg-line-center': sideNav === true }" id="middle" x1="0"
|
||||||
<line class="button-svg__line"
|
x2="22" y1="12" y2="12">
|
||||||
:class="isMenuOpen ? 'svg-line-center' : ''"
|
</line>
|
||||||
x1="0" x2="22" y1="12" y2="12"></line>
|
<line class="button-svg__line" :class="{ 'svg-line-bottom': sideNav === true }" id="bottom" x1="0"
|
||||||
<line class="button-svg__line"
|
x2="22" y1="18" y2="18"></line>
|
||||||
:class="isMenuOpen ? 'svg-line-bottom' : ''"
|
|
||||||
x1="0" x2="22" y1="18" y2="18"></line>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,7 +136,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="fs-14 fw-semibold">
|
<div class="fs-14 fw-semibold">
|
||||||
<p>Unlock the Power of Sovereign Managed Applications</p>
|
<p>Unlock the Power of Cloud Native Applications</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center space-x-20">
|
<div class="d-flex align-items-center space-x-20">
|
||||||
<a href="https://www.linkedin.com/company/servala/">
|
<a href="https://www.linkedin.com/company/servala/">
|
||||||
|
|
|
||||||
|
|
@ -1,749 +0,0 @@
|
||||||
{% extends 'base.html' %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}CSP ROI Calculator{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script src="{% static "js/chart.umd.min.js" %}"></script>
|
|
||||||
<script src="{% static "js/jspdf.umd.min.js" %}"></script>
|
|
||||||
<!-- ROI Calculator Modules -->
|
|
||||||
<script src="{% static "js/roi-calculator/input-utils.js" %}"></script>
|
|
||||||
<script src="{% static "js/roi-calculator/calculator-core.js" %}"></script>
|
|
||||||
<script src="{% static "js/roi-calculator/chart-manager.js" %}"></script>
|
|
||||||
<script src="{% static "js/roi-calculator/ui-manager.js" %}"></script>
|
|
||||||
<script src="{% static "js/roi-calculator/export-manager.js" %}"></script>
|
|
||||||
<script src="{% static "js/roi-calculator/roi-calculator-app.js" %}"></script>
|
|
||||||
<script>
|
|
||||||
// Global function wrappers for HTML onclick handlers
|
|
||||||
function updateCalculations() { window.ROICalculatorApp?.updateCalculations(); }
|
|
||||||
function exportToPDF() { window.ROICalculatorApp?.exportToPDF(); }
|
|
||||||
function exportToCSV() { window.ROICalculatorApp?.exportToCSV(); }
|
|
||||||
function handleInvestmentAmountInput(input) { InputUtils.handleInvestmentAmountInput(input); }
|
|
||||||
function handleInvestmentAmountFocus(input) { InputUtils.handleInvestmentAmountFocus(input); }
|
|
||||||
function handleInvestmentAmountBlur(input) { InputUtils.handleInvestmentAmountBlur(input); }
|
|
||||||
function updateInvestmentAmount(value) { window.ROICalculatorApp?.updateInvestmentAmount(value); }
|
|
||||||
function updateRevenuePerInstance(value) { window.ROICalculatorApp?.updateRevenuePerInstance(value); }
|
|
||||||
function updateServalaShare(value) { window.ROICalculatorApp?.updateServalaShare(value); }
|
|
||||||
function updateGracePeriod(value) { window.ROICalculatorApp?.updateGracePeriod(value); }
|
|
||||||
function updateLoanRate(value) { window.ROICalculatorApp?.updateLoanRate(value); }
|
|
||||||
function updateCoreServiceRevenue(value) { window.ROICalculatorApp?.updateCoreServiceRevenue(value); }
|
|
||||||
function updateCurrency() {
|
|
||||||
const currencyElement = document.getElementById('currency');
|
|
||||||
const value = currencyElement ? currencyElement.value : 'CHF';
|
|
||||||
window.ROICalculatorApp?.updateCurrency(value);
|
|
||||||
}
|
|
||||||
function updateScenarioChurn(scenarioKey, churnRate) { window.ROICalculatorApp?.updateScenarioChurn(scenarioKey, churnRate); }
|
|
||||||
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) { window.ROICalculatorApp?.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth); }
|
|
||||||
function resetAdvancedParameters() { window.ROICalculatorApp?.resetAdvancedParameters(); }
|
|
||||||
function toggleScenario(scenarioKey) { window.ROICalculatorApp?.toggleScenario(scenarioKey); }
|
|
||||||
function updateMonthlyBreakdownFilters() { window.ROICalculatorApp?.updateMonthlyBreakdownFilters(); }
|
|
||||||
function toggleCollapsible(elementId) { window.ROICalculatorApp?.toggleCollapsible(elementId); }
|
|
||||||
function resetCalculator() { window.ROICalculatorApp?.resetCalculator(); }
|
|
||||||
// toggleInvestmentModel function removed - both models calculated simultaneously
|
|
||||||
function logout() { window.ROICalculatorApp?.logout(); }
|
|
||||||
|
|
||||||
// Manual toggle functions for collapse elements
|
|
||||||
function toggleAdvancedControls() {
|
|
||||||
const element = document.getElementById('advancedControls');
|
|
||||||
const button = document.getElementById('advancedToggleBtn');
|
|
||||||
|
|
||||||
console.log('Toggling advanced controls, current classes:', element.className);
|
|
||||||
|
|
||||||
if (element.style.display === 'none' || element.style.display === '') {
|
|
||||||
element.style.display = 'block';
|
|
||||||
button.innerHTML = '<i class="bi bi-gear"></i> Less';
|
|
||||||
console.log('Showing advanced controls');
|
|
||||||
} else {
|
|
||||||
element.style.display = 'none';
|
|
||||||
button.innerHTML = '<i class="bi bi-gear"></i> More';
|
|
||||||
console.log('Hiding advanced controls');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDataCollapse() {
|
|
||||||
const element = document.getElementById('dataCollapse');
|
|
||||||
const button = document.getElementById('dataToggleBtn');
|
|
||||||
|
|
||||||
console.log('Toggling data collapse, current style:', element.style.display);
|
|
||||||
|
|
||||||
if (element.style.display === 'none' || element.style.display === '') {
|
|
||||||
element.style.display = 'block';
|
|
||||||
button.classList.remove('collapsed');
|
|
||||||
button.setAttribute('aria-expanded', 'true');
|
|
||||||
console.log('Showing data collapse');
|
|
||||||
} else {
|
|
||||||
element.style.display = 'none';
|
|
||||||
button.classList.add('collapsed');
|
|
||||||
button.setAttribute('aria-expanded', 'false');
|
|
||||||
console.log('Hiding data collapse');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize collapse states
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Ensure both sections start collapsed
|
|
||||||
const advancedControls = document.getElementById('advancedControls');
|
|
||||||
const dataCollapse = document.getElementById('dataCollapse');
|
|
||||||
|
|
||||||
if (advancedControls) {
|
|
||||||
advancedControls.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (dataCollapse) {
|
|
||||||
dataCollapse.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Collapse elements initialized as hidden');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid p-0" style="min-height: 100vh;">
|
|
||||||
<!-- Minimal Header with Controls -->
|
|
||||||
<div class="bg-light border-bottom sticky-top" style="z-index: 1030;">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<!-- Title Row -->
|
|
||||||
<div class="row py-2 border-bottom">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h4 class="mb-0">CSP ROI Calculator</h4>
|
|
||||||
<small class="text-muted">Real-time investment analysis</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 text-end">
|
|
||||||
<a href="{% url 'services:roi_calculator_help' %}" class="btn btn-sm btn-outline me-1" target="_blank">
|
|
||||||
<i class="bi bi-question-circle"></i> Help
|
|
||||||
</a>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary me-1" onclick="exportToPDF()">
|
|
||||||
<i class="bi bi-file-pdf"></i> PDF
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-success me-1" onclick="exportToCSV()">
|
|
||||||
<i class="bi bi-file-csv"></i> CSV
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline me-1" onclick="resetCalculator()">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i> Reset
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="logout()">
|
|
||||||
<i class="bi bi-box-arrow-right"></i> Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Configuration Section -->
|
|
||||||
<div class="py-4">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Left Column: Configuration Fields -->
|
|
||||||
<div class="col-lg-8 col-xl-7">
|
|
||||||
<div class="main-config-fields">
|
|
||||||
<!-- Investment Amount -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="form-label fw-semibold mb-2">Initial Investment</label>
|
|
||||||
<div class="input-group input-group-lg">
|
|
||||||
<span class="input-group-text" id="investment-currency-prefix">CHF</span>
|
|
||||||
<input type="text" class="form-control" id="investment-amount"
|
|
||||||
data-value="500000" value="500,000"
|
|
||||||
oninput="handleInvestmentAmountInput(this)"
|
|
||||||
onfocus="handleInvestmentAmountFocus(this)"
|
|
||||||
onblur="handleInvestmentAmountBlur(this)"
|
|
||||||
placeholder="Enter amount (100,000 - 2,000,000)">
|
|
||||||
</div>
|
|
||||||
<input type="range" class="form-range mt-3" id="investment-slider"
|
|
||||||
min="100000" max="2000000" step="50000" value="500000"
|
|
||||||
onchange="updateInvestmentAmount(this.value)">
|
|
||||||
<div class="d-flex justify-content-between mt-1">
|
|
||||||
<small class="text-muted" id="investment-min-label">CHF 100K</small>
|
|
||||||
<small class="text-muted" id="investment-max-label">CHF 2M</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Currency, Analysis Period & Service Revenue Row -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Currency</label>
|
|
||||||
<select class="form-select form-select-lg" id="currency" onchange="updateCurrency()">
|
|
||||||
<option value="CHF" selected>CHF (Swiss Franc)</option>
|
|
||||||
<option value="EUR">EUR (Euro)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Analysis Period</label>
|
|
||||||
<select class="form-select form-select-lg" id="timeframe" onchange="updateCalculations()">
|
|
||||||
<option value="1">1 Year</option>
|
|
||||||
<option value="2">2 Years</option>
|
|
||||||
<option value="3" selected>3 Years</option>
|
|
||||||
<option value="4">4 Years</option>
|
|
||||||
<option value="5">5 Years</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label fw-semibold mb-2">Service Revenue per Instance</label>
|
|
||||||
<div class="input-group input-group-lg">
|
|
||||||
<input type="number" class="form-control" id="revenue-per-instance"
|
|
||||||
min="20" max="200" step="5" value="50" onchange="updateCalculations()">
|
|
||||||
<span class="input-group-text" id="revenue-currency-suffix">CHF/month</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" class="form-range mt-3" id="revenue-slider"
|
|
||||||
min="20" max="200" step="5" value="50"
|
|
||||||
onchange="updateRevenuePerInstance(this.value)">
|
|
||||||
<div class="d-flex justify-content-between mt-1">
|
|
||||||
<small class="text-muted" id="revenue-min-label">CHF 20</small>
|
|
||||||
<small class="text-muted" id="revenue-max-label">CHF 200</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Growth Scenarios -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="form-label fw-semibold mb-3">Growth Scenarios</label>
|
|
||||||
<div class="d-flex gap-4 flex-wrap">
|
|
||||||
<div class="form-check form-check-lg">
|
|
||||||
<input class="form-check-input" type="checkbox" id="conservative-enabled" checked onchange="toggleScenario('conservative')">
|
|
||||||
<label class="form-check-label fw-medium" for="conservative-enabled" data-bs-toggle="tooltip" title="Conservative: 2% churn, steady growth">
|
|
||||||
<span class="text-success fs-4">●</span> Conservative
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-lg">
|
|
||||||
<input class="form-check-input" type="checkbox" id="moderate-enabled" checked onchange="toggleScenario('moderate')">
|
|
||||||
<label class="form-check-label fw-medium" for="moderate-enabled" data-bs-toggle="tooltip" title="Moderate: 3% churn, balanced growth">
|
|
||||||
<span class="text-warning fs-4">●</span> Moderate
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-lg">
|
|
||||||
<input class="form-check-input" type="checkbox" id="aggressive-enabled" checked onchange="toggleScenario('aggressive')">
|
|
||||||
<label class="form-check-label fw-medium" for="aggressive-enabled" data-bs-toggle="tooltip" title="Aggressive: 5% churn, rapid growth">
|
|
||||||
<span class="text-danger fs-4">●</span> Aggressive
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Investment Benefits Display -->
|
|
||||||
<div class="row mb-4" id="investment-benefits">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card bg-light border-0">
|
|
||||||
<div class="card-header bg-success text-white">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-trophy"></i> Your Investment Benefits</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body py-3">
|
|
||||||
<div class="row text-center">
|
|
||||||
<div class="col-md-3 col-6 mb-2">
|
|
||||||
<div class="benefit-metric">
|
|
||||||
<div class="benefit-value text-primary fw-bold" id="instance-scaling">1.0x</div>
|
|
||||||
<div class="benefit-label small text-muted">Instance Scaling</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-2">
|
|
||||||
<div class="benefit-metric">
|
|
||||||
<div class="benefit-value text-success fw-bold" id="revenue-premium">+0%</div>
|
|
||||||
<div class="benefit-label small text-muted">Revenue Premium</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-2">
|
|
||||||
<div class="benefit-metric">
|
|
||||||
<div class="benefit-value text-info fw-bold" id="grace-period-display">6 months</div>
|
|
||||||
<div class="benefit-label small text-muted">Grace Period</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-2">
|
|
||||||
<div class="benefit-metric">
|
|
||||||
<div class="benefit-value text-warning fw-bold" id="max-bonus">15%</div>
|
|
||||||
<div class="benefit-label small text-muted">Max Performance Bonus</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="d-flex gap-3 flex-wrap">
|
|
||||||
<button class="btn btn-outline-info btn-lg" type="button" onclick="toggleAdvancedControls()" id="advancedToggleBtn">
|
|
||||||
<i class="bi bi-gear"></i> Advanced Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Real-Time Results -->
|
|
||||||
<div class="col-lg-4 col-xl-5">
|
|
||||||
<div class="results-panel h-100">
|
|
||||||
<div class="card h-100 border-0 shadow">
|
|
||||||
<div class="card-header bg-primary text-white">
|
|
||||||
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Real-Time Results</h5>
|
|
||||||
<small class="opacity-75">Live calculations based on your parameters</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body d-flex flex-column justify-content-center">
|
|
||||||
<!-- Direct Investment Results -->
|
|
||||||
<div class="result-item mb-4">
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
|
||||||
<div class="result-icon bg-success text-white rounded-circle me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
|
||||||
<i class="bi bi-rocket"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-0 text-success fw-bold">Direct Investment</h6>
|
|
||||||
<small class="text-muted">Performance-based returns</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="result-metrics bg-light rounded p-3">
|
|
||||||
<div class="row text-center">
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="h4 mb-1 text-success fw-bold" id="net-position-direct">CHF 0</div>
|
|
||||||
<small class="text-muted">Net Profit</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="h4 mb-1 text-primary fw-bold" id="roi-percentage-direct">0%</div>
|
|
||||||
<small class="text-muted">Total ROI</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loan Model Results -->
|
|
||||||
<div class="result-item">
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
|
||||||
<div class="result-icon bg-warning text-dark rounded-circle me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
|
||||||
<i class="bi bi-bank"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h6 class="mb-0 text-warning fw-bold">Loan Model</h6>
|
|
||||||
<small class="text-muted">Fixed guaranteed returns</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="result-metrics bg-light rounded p-3">
|
|
||||||
<div class="row text-center">
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="h4 mb-1 text-success fw-bold" id="net-position-loan">CHF 0</div>
|
|
||||||
<small class="text-muted">Net Profit</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="h4 mb-1 text-primary fw-bold" id="roi-percentage-loan">0%</div>
|
|
||||||
<small class="text-muted">Total ROI</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Controls Section -->
|
|
||||||
<div class="collapse" id="advancedControls">
|
|
||||||
<div class="bg-light border-top py-4">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<h6 class="text-primary mb-4"><i class="bi bi-gear"></i> Advanced Configuration</h6>
|
|
||||||
|
|
||||||
<!-- Investment Model Parameters -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="text-secondary mb-3">Investment Model Settings</h6>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loan Rate -->
|
|
||||||
<div class="col-lg-3 col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Loan Interest Rate</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="number" class="form-control" id="loan-interest-rate"
|
|
||||||
min="3" max="8" step="0.1" value="5.0" onchange="updateCalculations()">
|
|
||||||
<span class="input-group-text">% annual</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" class="form-range mt-2" id="loan-rate-slider"
|
|
||||||
min="3" max="8" step="0.1" value="5.0" onchange="updateLoanRate(this.value)">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<small class="text-muted">3%</small>
|
|
||||||
<small class="text-muted">8%</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Servala Share -->
|
|
||||||
<div class="col-lg-3 col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Servala Revenue Share</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="number" class="form-control" id="servala-share"
|
|
||||||
min="10" max="40" step="1" value="25" onchange="updateCalculations()">
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" class="form-range mt-2" id="share-slider"
|
|
||||||
min="10" max="40" step="1" value="25" onchange="updateServalaShare(this.value)">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<small class="text-muted">10%</small>
|
|
||||||
<small class="text-muted">40%</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grace Period -->
|
|
||||||
<div class="col-lg-3 col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Grace Period</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="number" class="form-control" id="grace-period"
|
|
||||||
min="0" max="24" step="1" value="6" onchange="updateCalculations()">
|
|
||||||
<span class="input-group-text">months</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" class="form-range mt-2" id="grace-slider"
|
|
||||||
min="0" max="24" step="1" value="6" onchange="updateGracePeriod(this.value)">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<small class="text-muted">0</small>
|
|
||||||
<small class="text-muted">24</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Core Service Revenue -->
|
|
||||||
<div class="col-lg-3 col-md-6 mb-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Core Service Revenue</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="number" class="form-control" id="core-service-revenue"
|
|
||||||
min="0" max="500" step="5" value="0" onchange="updateCalculations()">
|
|
||||||
<span class="input-group-text">CHF/month</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" class="form-range mt-2" id="core-revenue-slider"
|
|
||||||
min="0" max="500" step="5" value="0" onchange="updateCoreServiceRevenue(this.value)">
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<small class="text-muted">CHF 0</small>
|
|
||||||
<small class="text-muted">CHF 500</small>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Additional compute/storage revenue per instance</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scenario Parameters -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h6 class="text-secondary mb-3">Growth Scenario Tuning</h6>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conservative Scenario -->
|
|
||||||
<div class="col-lg-4 mb-4">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header bg-success text-white">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-shield-check"></i> Conservative Scenario</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<!-- Churn Rate -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="number" class="form-control" id="conservative-churn"
|
|
||||||
min="0" max="10" step="0.1" value="2.0" onchange="updateScenarioChurn('conservative', this.value)">
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phase Growth Parameters -->
|
|
||||||
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 1 (6mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-0"
|
|
||||||
min="10" max="200" value="50" onchange="updateScenarioPhase('conservative', 0, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 2 (6mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-1"
|
|
||||||
min="10" max="200" value="75" onchange="updateScenarioPhase('conservative', 1, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 3 (12mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-2"
|
|
||||||
min="10" max="300" value="100" onchange="updateScenarioPhase('conservative', 2, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 4 (12mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="conservative-phase-3"
|
|
||||||
min="10" max="300" value="150" onchange="updateScenarioPhase('conservative', 3, this.value)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Moderate Scenario -->
|
|
||||||
<div class="col-lg-4 mb-4">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header bg-warning text-dark">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-speedometer2"></i> Moderate Scenario</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<!-- Churn Rate -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="number" class="form-control" id="moderate-churn"
|
|
||||||
min="0" max="10" step="0.1" value="3.0" onchange="updateScenarioChurn('moderate', this.value)">
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phase Growth Parameters -->
|
|
||||||
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 1 (6mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-0"
|
|
||||||
min="20" max="300" value="100" onchange="updateScenarioPhase('moderate', 0, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 2 (6mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-1"
|
|
||||||
min="20" max="400" value="200" onchange="updateScenarioPhase('moderate', 1, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 3 (12mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-2"
|
|
||||||
min="20" max="500" value="300" onchange="updateScenarioPhase('moderate', 2, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 4 (12mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="moderate-phase-3"
|
|
||||||
min="20" max="600" value="400" onchange="updateScenarioPhase('moderate', 3, this.value)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aggressive Scenario -->
|
|
||||||
<div class="col-lg-4 mb-4">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header bg-danger text-white">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-rocket"></i> Aggressive Scenario</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<!-- Churn Rate -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label fw-semibold mb-2">Monthly Churn Rate</label>
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="number" class="form-control" id="aggressive-churn"
|
|
||||||
min="0" max="15" step="0.1" value="5.0" onchange="updateScenarioChurn('aggressive', this.value)">
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phase Growth Parameters -->
|
|
||||||
<label class="form-label fw-semibold mb-2">Instance Growth per Phase</label>
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 1 (6mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-0"
|
|
||||||
min="50" max="500" value="200" onchange="updateScenarioPhase('aggressive', 0, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 2 (6mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-1"
|
|
||||||
min="50" max="600" value="400" onchange="updateScenarioPhase('aggressive', 1, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 3 (12mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-2"
|
|
||||||
min="50" max="800" value="600" onchange="updateScenarioPhase('aggressive', 2, this.value)">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">Phase 4 (12mo)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="aggressive-phase-3"
|
|
||||||
min="50" max="1000" value="800" onchange="updateScenarioPhase('aggressive', 3, this.value)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reset Button -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 text-center">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="resetAdvancedParameters()">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i> Reset to Defaults
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CHARTS - Maximum Space -->
|
|
||||||
<div class="container-fluid px-3 py-3" style="background: #f8f9fa;">
|
|
||||||
<!-- Loading Spinner -->
|
|
||||||
<div class="loading-spinner text-center py-5" id="loading-spinner" style="display: none;">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Calculating...</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2">Calculating scenarios...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- PRIMARY CHART - Full Width, Large Height -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0 pb-0">
|
|
||||||
<h5 class="mb-1"><i class="bi bi-graph-up-arrow text-primary"></i> ROI Progression Over Time</h5>
|
|
||||||
<p class="small text-muted mb-0">Investment profitability timeline - when you'll break even and achieve target returns</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body pt-3">
|
|
||||||
<canvas id="instanceGrowthChart" style="height: 500px; width: 100%;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SECONDARY CHARTS - Side by Side, Large -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-xl-6 mb-4">
|
|
||||||
<div class="card border-0 shadow-sm h-100">
|
|
||||||
<div class="card-header bg-white border-0 pb-0">
|
|
||||||
<h5 class="mb-1"><i class="bi bi-cash-stack text-success"></i> Net Financial Position</h5>
|
|
||||||
<p class="small text-muted mb-0">Cumulative profit/loss over time</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body pt-3">
|
|
||||||
<canvas id="revenueChart" style="height: 400px; width: 100%;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xl-6 mb-4">
|
|
||||||
<div class="card border-0 shadow-sm h-100">
|
|
||||||
<div class="card-header bg-white border-0 pb-0">
|
|
||||||
<h5 class="mb-1"><i class="bi bi-bar-chart text-warning"></i> Performance Comparison</h5>
|
|
||||||
<p class="small text-muted mb-0">ROI performance across growth scenarios</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body pt-3">
|
|
||||||
<canvas id="cashFlowChart" style="height: 400px; width: 100%;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CSP REVENUE BREAKDOWN CHART - Full Width -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card border-0 shadow-sm">
|
|
||||||
<div class="card-header bg-white border-0 pb-0">
|
|
||||||
<h5 class="mb-1"><i class="bi bi-cash-stack text-success"></i> CSP Revenue Breakdown</h5>
|
|
||||||
<p class="small text-muted mb-0">Direct investment revenue breakdown: Service fees, core infrastructure sales, total CSP revenue, and Servala share over time</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-body pt-3">
|
|
||||||
<canvas id="cspRevenueChart" style="height: 400px; width: 100%;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DATA TABLE - Collapsible to Save Space -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="accordion" id="dataAccordion">
|
|
||||||
<div class="accordion-item border-0 shadow-sm">
|
|
||||||
<h2 class="accordion-header" id="dataHeading">
|
|
||||||
<button class="accordion-button collapsed" type="button" onclick="toggleDataCollapse()" id="dataToggleBtn">
|
|
||||||
<i class="bi bi-table me-2"></i> Detailed Financial Analysis
|
|
||||||
</button>
|
|
||||||
</h2>
|
|
||||||
<div id="dataCollapse" class="accordion-collapse collapse" aria-labelledby="dataHeading" data-bs-parent="#dataAccordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<!-- Improved Scenario Performance Summary -->
|
|
||||||
<h6 class="mb-3">Investment Model Comparison by Scenario</h6>
|
|
||||||
<div class="table-responsive mb-4">
|
|
||||||
<table class="table table-sm table-hover" id="comparison-table">
|
|
||||||
<thead class="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th>Scenario</th>
|
|
||||||
<th>Investment Model</th>
|
|
||||||
<th>Final Scale</th>
|
|
||||||
<th>Your Net Profit</th>
|
|
||||||
<th>Total ROI</th>
|
|
||||||
<th>Break-even Time</th>
|
|
||||||
<th>Key Features</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="comparison-tbody">
|
|
||||||
<!-- Dynamic content -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Monthly Financial Flow -->
|
|
||||||
<h6 class="mb-3">Monthly Financial Breakdown</h6>
|
|
||||||
|
|
||||||
<!-- Breakdown Filter Controls -->
|
|
||||||
<div class="row mb-3 breakdown-filters">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label small fw-semibold mb-2">Investment Models</label>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="breakdown-direct-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
|
||||||
<label class="form-check-label small fw-medium text-success" for="breakdown-direct-enabled">
|
|
||||||
Direct Investment
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="breakdown-loan-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
|
||||||
<label class="form-check-label small fw-medium text-warning" for="breakdown-loan-enabled">
|
|
||||||
Loan Model
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="form-label small fw-semibold mb-2">Growth Scenarios</label>
|
|
||||||
<div class="d-flex gap-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="breakdown-conservative-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
|
||||||
<label class="form-check-label small fw-medium" for="breakdown-conservative-enabled">
|
|
||||||
<span class="text-success">•</span> Conservative
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="breakdown-moderate-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
|
||||||
<label class="form-check-label small fw-medium" for="breakdown-moderate-enabled">
|
|
||||||
<span class="text-warning">•</span> Moderate
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="breakdown-aggressive-enabled" checked onchange="updateMonthlyBreakdownFilters()">
|
|
||||||
<label class="form-check-label small fw-medium" for="breakdown-aggressive-enabled">
|
|
||||||
<span class="text-danger">•</span> Aggressive
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
|
|
||||||
<table class="table table-sm table-striped" id="monthly-table">
|
|
||||||
<thead class="table-dark sticky-top">
|
|
||||||
<tr>
|
|
||||||
<th>Month</th>
|
|
||||||
<th>Scenario</th>
|
|
||||||
<th>Model</th>
|
|
||||||
<th>Instances</th>
|
|
||||||
<th>Service Revenue</th>
|
|
||||||
<th>Core Revenue</th>
|
|
||||||
<th>Total Revenue</th>
|
|
||||||
<th>Your Share</th>
|
|
||||||
<th>Servala Share</th>
|
|
||||||
<th>Cumulative Net Position</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="monthly-tbody">
|
|
||||||
<!-- Dynamic content -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
{% extends 'base.html' %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Authentication Required{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-5">
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-md-6 col-lg-4">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<i class="bi bi-shield-lock text-primary" style="font-size: 3rem;"></i>
|
|
||||||
<h2 class="h4 mt-3">Authentication Required</h2>
|
|
||||||
<p class="text-muted">Please enter the password to access the CSP ROI Calculator</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if messages %}
|
|
||||||
{% for message in messages %}
|
|
||||||
<div class="alert alert-{% if message.tags == 'error' %}danger{% else %}{{ message.tags }}{% endif %}" role="alert">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not password_error %}
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Password</label>
|
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary w-100">Access Calculator</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-muted">The calculator is temporarily unavailable due to configuration issues.</p>
|
|
||||||
<a href="/" class="btn btn-outline-secondary">Return to Homepage</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,755 +0,0 @@
|
||||||
{% extends 'base.html' %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}ROI Calculator Help - Servala Investment Models{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
|
|
||||||
<style>
|
|
||||||
.help-section {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.help-section h2 {
|
|
||||||
color: #007bff;
|
|
||||||
border-bottom: 2px solid #007bff;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.help-section h3 {
|
|
||||||
color: #28a745;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
.comparison-table {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.model-card {
|
|
||||||
border-left: 4px solid;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
.loan-model {
|
|
||||||
border-left-color: #ffc107;
|
|
||||||
}
|
|
||||||
.direct-model {
|
|
||||||
border-left-color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced navigation styling */
|
|
||||||
.list-group-item {
|
|
||||||
border: none !important;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: block !important;
|
|
||||||
width: 100% !important;
|
|
||||||
clear: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group-flush {
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group-item:hover {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding-left: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group-item.active {
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smooth scrolling */
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section spacing */
|
|
||||||
.help-section {
|
|
||||||
scroll-margin-top: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container my-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<div>
|
|
||||||
<h1>ROI Calculator Help</h1>
|
|
||||||
<p class="text-muted">Understanding Servala's Investment Models</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-arrow-left"></i> Back to Calculator
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<!-- Investment Scaling Benefits Section (MOST IMPORTANT) -->
|
|
||||||
<div class="help-section" id="investment-benefits">
|
|
||||||
<h2><i class="bi bi-graph-up text-primary"></i> Investment Benefits & Market-Realistic Returns</h2>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h5><i class="bi bi-bar-chart"></i> Conservative Scaling Based on Industry Standards</h5>
|
|
||||||
<p class="mb-0">Our calculator uses proven, market-realistic scaling based on European managed services industry data. Returns are conservative and align with current investment expectations.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Market-Realistic Investment Benefits</h3>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped comparison-table">
|
|
||||||
<thead class="table-success">
|
|
||||||
<tr>
|
|
||||||
<th>Investment Amount</th>
|
|
||||||
<th>Instance Multiplier</th>
|
|
||||||
<th>Revenue Premium</th>
|
|
||||||
<th>Performance Bonus Cap</th>
|
|
||||||
<th>Grace Period Bonus</th>
|
|
||||||
<th>Expected 3-Year ROI</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><strong>500,000</strong></td>
|
|
||||||
<td>1.0x</td>
|
|
||||||
<td>Standard rates</td>
|
|
||||||
<td>8%</td>
|
|
||||||
<td>Base period</td>
|
|
||||||
<td class="text-success"><strong>40-60%</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="table-warning">
|
|
||||||
<td><strong>1,000,000</strong></td>
|
|
||||||
<td><span class="text-primary">1.5x</span></td>
|
|
||||||
<td><span class="text-primary">+10% per instance</span></td>
|
|
||||||
<td>10%</td>
|
|
||||||
<td>+2 months</td>
|
|
||||||
<td class="text-success"><strong>60-80%</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="table-info">
|
|
||||||
<td><strong>1,500,000</strong></td>
|
|
||||||
<td><span class="text-success">1.8x</span></td>
|
|
||||||
<td><span class="text-success">+15% per instance</span></td>
|
|
||||||
<td>12%</td>
|
|
||||||
<td>+3 months</td>
|
|
||||||
<td class="text-success"><strong>70-90%</strong></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="table-success">
|
|
||||||
<td><strong>2,000,000</strong></td>
|
|
||||||
<td><span class="text-success fw-bold">2.0x</span></td>
|
|
||||||
<td><span class="text-success fw-bold">+20% per instance</span></td>
|
|
||||||
<td><span class="text-success fw-bold">15%</span></td>
|
|
||||||
<td>+3 months</td>
|
|
||||||
<td class="text-success"><strong>80-100%</strong></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Model Comparison Section -->
|
|
||||||
<div class="help-section" id="comparison">
|
|
||||||
<h2><i class="bi bi-bar-chart"></i> Investment Model Comparison</h2>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="model-card loan-model">
|
|
||||||
<h5><i class="bi bi-bank"></i> Loan Model</h5>
|
|
||||||
<p><strong>3-8% Annual Returns</strong></p>
|
|
||||||
<p>Fixed interest lending with guaranteed monthly payments. Low risk, predictable returns.</p>
|
|
||||||
<ul class="text-muted small">
|
|
||||||
<li>Guaranteed monthly payments</li>
|
|
||||||
<li>No performance risk</li>
|
|
||||||
<li>Fixed 3-8% annual returns</li>
|
|
||||||
<li>Contractual protection</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="model-card direct-model">
|
|
||||||
<h5><i class="bi bi-rocket"></i> Direct Investment</h5>
|
|
||||||
<p><strong>40-100% Market-Realistic Returns</strong></p>
|
|
||||||
<p>Performance-based revenue sharing with conservative scaling and sustainable grace periods.</p>
|
|
||||||
<ul class="text-success small">
|
|
||||||
<li>Linear scaling up to 2.0x instances</li>
|
|
||||||
<li>Revenue premiums up to 20%</li>
|
|
||||||
<li>Performance bonuses up to 15%</li>
|
|
||||||
<li>Grace periods max 6 months</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Direct Comparison - 3 Years ROI:</h3>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Model</th>
|
|
||||||
<th>Risk Level</th>
|
|
||||||
<th>Expected ROI</th>
|
|
||||||
<th>Break-even</th>
|
|
||||||
<th>Profit Potential</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Loan Model (1M)</strong></td>
|
|
||||||
<td><span class="badge bg-success">Low</span></td>
|
|
||||||
<td>15-25% over 3 years</td>
|
|
||||||
<td>12-18 months</td>
|
|
||||||
<td>150,000 - 250,000</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="table-warning">
|
|
||||||
<td><strong>Direct Investment (1M)</strong></td>
|
|
||||||
<td><span class="badge bg-warning">Moderate</span></td>
|
|
||||||
<td>60-80% over 3 years</td>
|
|
||||||
<td>18-24 months</td>
|
|
||||||
<td>600,000 - 800,000</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="table-success">
|
|
||||||
<td><strong>Direct Investment (2M)</strong></td>
|
|
||||||
<td><span class="badge bg-danger">High</span></td>
|
|
||||||
<td>80-100% over 3 years</td>
|
|
||||||
<td>20-26 months</td>
|
|
||||||
<td>1,600,000 - 2,000,000</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calculator Guide Section -->
|
|
||||||
<div class="help-section" id="calculator-guide">
|
|
||||||
<h2><i class="bi bi-calculator"></i> How to Use the Calculator</h2>
|
|
||||||
|
|
||||||
<h3>Quick Start Guide</h3>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Essential Settings</h5>
|
|
||||||
<ol>
|
|
||||||
<li><strong>Investment Amount:</strong> Use slider or type amount (100K - 2M)</li>
|
|
||||||
<li><strong>Timeframe:</strong> Choose 1-5 years for your projection</li>
|
|
||||||
<li><strong>Currency:</strong> Select CHF or EUR</li>
|
|
||||||
<li><strong>Growth Scenario:</strong> Enable scenarios that match your market</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Understanding Results</h5>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Net Position:</strong> Your profit after investment</li>
|
|
||||||
<li><strong>ROI Percentage:</strong> Return on investment rate</li>
|
|
||||||
<li><strong>Investment Benefits:</strong> Real-time scaling display</li>
|
|
||||||
<li><strong>Break-even:</strong> When you start profiting</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Advanced Parameters (Optional)</h3>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<ul>
|
|
||||||
<li><strong>Service Revenue/Instance:</strong> Monthly Servala service fee (20-200)</li>
|
|
||||||
<li><strong>Core Revenue/Instance:</strong> Additional infrastructure revenue (0-500)</li>
|
|
||||||
<li><strong>Loan Rate:</strong> Annual interest for loan model (3-8%)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<ul>
|
|
||||||
<li><strong>Servala Share:</strong> Revenue split for direct investment (10-40%)</li>
|
|
||||||
<li><strong>Grace Period:</strong> 100% revenue retention period (0-24 months)</li>
|
|
||||||
<li><strong>Churn Rates:</strong> Customer loss by scenario (0-15%)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Growth Scenarios Section -->
|
|
||||||
<div class="help-section" id="scenarios">
|
|
||||||
<h2><i class="bi bi-speedometer2"></i> Growth Scenarios</h2>
|
|
||||||
<p>Choose the scenarios that best match your market conditions and sales capabilities:</p>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card border-success">
|
|
||||||
<div class="card-header bg-success text-white">
|
|
||||||
<h5 class="mb-0">Safe (Conservative)</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p><strong>2.5% monthly churn</strong></p>
|
|
||||||
<p>Steady growth: 15-40 new instances/month</p>
|
|
||||||
<p>Best for: Established markets, risk-averse CSPs</p>
|
|
||||||
<p class=\"small text-muted\">~5-8 new clients/month × 3-5 instances each</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card border-warning">
|
|
||||||
<div class="card-header bg-warning text-white">
|
|
||||||
<h5 class="mb-0">Balanced (Moderate)</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p><strong>3% monthly churn</strong></p>
|
|
||||||
<p>Balanced growth: 25-90 new instances/month</p>
|
|
||||||
<p>Best for: Competitive markets, balanced approach</p>
|
|
||||||
<p class=\"small text-muted\">~8-15 new clients/month × 3-6 instances each</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card border-danger">
|
|
||||||
<div class="card-header bg-danger text-white">
|
|
||||||
<h5 class="mb-0">Fast (Aggressive)</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p><strong>3.5% monthly churn</strong></p>
|
|
||||||
<p>Rapid growth: 40-150 new instances/month</p>
|
|
||||||
<p>Best for: High-growth strategies, active sales</p>
|
|
||||||
<p class=\"small text-muted\">~12-25 new clients/month × 3-6 instances each</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Understanding Charts Section -->
|
|
||||||
<div class="help-section" id="charts">
|
|
||||||
<h2><i class="bi bi-graph-up"></i> Reading the Charts</h2>
|
|
||||||
|
|
||||||
<h3>Key Charts Explained</h3>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>1. ROI Progression Over Time</h5>
|
|
||||||
<p>Shows when your investment becomes profitable and how returns develop monthly. Look for the point where lines cross zero.</p>
|
|
||||||
|
|
||||||
<h5>2. Net Financial Position</h5>
|
|
||||||
<p>Your cumulative profit/loss over time. Above zero = profitable, below zero = still recovering investment.</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>3. Performance Comparison</h5>
|
|
||||||
<p>ROI percentages across different growth scenarios - compare best and worst-case outcomes.</p>
|
|
||||||
|
|
||||||
<h5>4. CSP Revenue Breakdown</h5>
|
|
||||||
<p>Monthly revenue analysis showing service revenue, core revenue, CSP total, and Servala revenue share.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4>Chart Legend</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Solid Lines:</strong> Direct Investment Model</li>
|
|
||||||
<li><strong>Dashed Lines:</strong> Loan Model</li>
|
|
||||||
<li><strong>Green:</strong> Conservative Scenario | <strong>Yellow:</strong> Moderate | <strong>Red:</strong> Aggressive</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Market Context & Risk Factors Section -->
|
|
||||||
<div class=\"help-section\" id=\"market-context\">
|
|
||||||
<h2><i class=\"bi bi-exclamation-triangle text-warning\"></i> Market Context & Risk Factors</h2>
|
|
||||||
|
|
||||||
<div class=\"alert alert-warning\">
|
|
||||||
<h6><i class=\"bi bi-info-circle\"></i> Important Disclaimer</h6>
|
|
||||||
<p class=\"mb-0\">These projections are based on European managed services market data (13-15% CAGR) and current industry standards. Actual results may vary significantly based on market conditions, execution, and competitive factors.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Key Risk Factors</h3>
|
|
||||||
<div class=\"row\">
|
|
||||||
<div class=\"col-md-6\">
|
|
||||||
<h5>Market Risks</h5>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Competition:</strong> US hyperscalers (AWS, Azure, Google) may respond aggressively</li>
|
|
||||||
<li><strong>Market Maturity:</strong> European managed services adoption varies by region</li>
|
|
||||||
<li><strong>Economic Conditions:</strong> Recession or funding limitations could impact growth</li>
|
|
||||||
<li><strong>Regulatory Changes:</strong> Data sovereignty laws may change</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class=\"col-md-6\">
|
|
||||||
<h5>Execution Risks</h5>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Technology Development:</strong> Platform development may face delays</li>
|
|
||||||
<li><strong>Partner Adoption:</strong> CSPs may be slower to adopt than projected</li>
|
|
||||||
<li><strong>Talent Acquisition:</strong> Skilled technical resources are scarce</li>
|
|
||||||
<li><strong>Customer Churn:</strong> Actual churn rates may exceed projections</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Conservative Scenario Modeling</h3>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped comparison-table">
|
|
||||||
<thead class="table-success">
|
|
||||||
<tr>
|
|
||||||
<th>Scenario</th>
|
|
||||||
<th>Market Conditions</th>
|
|
||||||
<th>Expected Growth</th>
|
|
||||||
<th>Break-even Time</th>
|
|
||||||
<th>3-Year ROI Range</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class="table-danger">
|
|
||||||
<td><strong>Pessimistic</strong></td>
|
|
||||||
<td>Economic downturn, strong competition</td>
|
|
||||||
<td>5-10% annually</td>
|
|
||||||
<td>30-36 months</td>
|
|
||||||
<td>20-40%</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="table-warning">
|
|
||||||
<td><strong>Realistic</strong></td>
|
|
||||||
<td>Normal market conditions</td>
|
|
||||||
<td>15-25% annually</td>
|
|
||||||
<td>18-24 months</td>
|
|
||||||
<td>50-80%</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="table-success">
|
|
||||||
<td><strong>Optimistic</strong></td>
|
|
||||||
<td>Favorable market, rapid adoption</td>
|
|
||||||
<td>25-35% annually</td>
|
|
||||||
<td>12-18 months</td>
|
|
||||||
<td>80-120%</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class=\"alert alert-info mt-3\">
|
|
||||||
<h6><i class=\"bi bi-lightbulb\"></i> Investment Recommendation</h6>
|
|
||||||
<p class=\"mb-0\">Consider these projections as best-case scenarios under favorable conditions. Prudent investors should plan for the \"Realistic\" scenario while hoping for \"Optimistic\" outcomes.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Market Analysis & Validation Section -->
|
|
||||||
<div class="help-section" id="market-analysis">
|
|
||||||
<h2><i class="bi bi-graph-up-arrow text-success"></i> Market Analysis & ROI Validation</h2>
|
|
||||||
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<h6><i class="bi bi-check-circle"></i> Market-Validated Projections</h6>
|
|
||||||
<p class="mb-0">Our ROI calculator uses data-driven projections based on comprehensive European managed services market research. All growth scenarios and return expectations are benchmarked against industry standards and competitive analysis.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>European Managed Services Market Reality</h3>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5><i class="bi bi-currency-euro"></i> Market Size & Growth</h5>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Market Value</strong>: €51-85 billion in 2024 <a href="https://www.grandviewresearch.com/horizon/outlook/managed-services-market/europe" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
<li><strong>Projected Growth</strong>: €113-255 billion by 2030-2033 <a href="https://www.marketdataforecast.com/market-reports/europe-managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
<li><strong>CAGR</strong>: 13-15% annually (matches Servala's assumptions) <a href="https://www.mordorintelligence.com/industry-reports/europe-managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
<li><strong>SME Growth</strong>: 10.6% CAGR (fastest growing segment) <a href="https://www.grandviewresearch.com/industry-analysis/managed-services-market" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5><i class="bi bi-cloud"></i> Cloud Adoption Trends</h5>
|
|
||||||
<ul>
|
|
||||||
<li><strong>EU Enterprise Adoption</strong>: 45% use cloud services in 2023 <a href="https://ec.europa.eu/eurostat/statistics-explained/index.php/Cloud_computing_-_statistics_on_the_use_by_enterprises" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
<li><strong>Multi-Cloud Strategy</strong>: 92% of organizations use multiple providers <a href="https://www.cloudzero.com/blog/cloud-computing-statistics/" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
<li><strong>Self-Service Growth</strong>: Strong demand for automated provisioning <a href="https://www.appvia.io/blog/why-self-service-is-key-to-cloud-adoption" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
<li><strong>Swiss Leadership</strong>: 95% have adopted some cloud services <a href="https://finance.yahoo.com/news/swiss-public-cloud-market-grows-090000699.html" target="_blank" class="text-primary"><i class="bi bi-box-arrow-up-right"></i></a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Competitive Landscape Analysis</h3>
|
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<h6><i class="bi bi-exclamation-triangle"></i> European Provider Challenge</h6>
|
|
||||||
<p class="mb-0">European cloud providers' market share declined from 27% to 13% over the past five years as US hyperscalers (AWS, Azure, GCP) now control 72% of the regional market. <a href="https://www.telecoms.com/public-cloud/european-cloud-players-face-declining-market-share-as-us-hyperscalers-clean-up" target="_blank" class="text-primary">Source: Telecoms.com</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped comparison-table">
|
|
||||||
<thead class="table-success">
|
|
||||||
<tr>
|
|
||||||
<th>Market Factor</th>
|
|
||||||
<th>Current Reality</th>
|
|
||||||
<th>Servala Opportunity</th>
|
|
||||||
<th>Source</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Hyperscaler Dominance</strong></td>
|
|
||||||
<td>AWS (31%), Azure (25%), GCP (11%)</td>
|
|
||||||
<td>European alternative with data sovereignty</td>
|
|
||||||
<td><a href="https://holori.com/cloud-market-share-2024-aws-azure-gcp/" target="_blank" class="text-primary">Holori 2024</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Data Sovereignty</strong></td>
|
|
||||||
<td>GDPR, NIS2 compliance requirements</td>
|
|
||||||
<td>Built-in European regulatory compliance</td>
|
|
||||||
<td><a href="https://thenextweb.com/news/european-cloud-alternative-to-aws-azure-and-gcp" target="_blank" class="text-primary">TNW Analysis</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Vendor Lock-in Concerns</strong></td>
|
|
||||||
<td>Growing enterprise resistance</td>
|
|
||||||
<td>Open-source, multi-cloud architecture</td>
|
|
||||||
<td><a href="https://www.computerweekly.com/feature/Introducing-the-EuroStack-initiative-Could-this-turn-the-tide-on-hyperscale-cloud-in-Europe" target="_blank" class="text-primary">Computer Weekly</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Regional Provider Decline</strong></td>
|
|
||||||
<td>Market share falling despite 167% revenue growth</td>
|
|
||||||
<td>Platform to help regain competitiveness</td>
|
|
||||||
<td><a href="https://www.telecoms.com/public-cloud/european-cloud-players-face-declining-market-share-as-us-hyperscalers-clean-up" target="_blank" class="text-primary">Telecoms.com</a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>ROI Projections: Market Benchmarking</h3>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5><i class="bi bi-calculator"></i> Growth Scenario Validation</h5>
|
|
||||||
<div class="card border-success">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title text-success">Conservative Scenario</h6>
|
|
||||||
<p><strong>15-40 instances/month</strong></p>
|
|
||||||
<ul class="small">
|
|
||||||
<li>5-8 new clients/month × 3-5 instances each</li>
|
|
||||||
<li>Aligns with EU enterprise cloud adoption (45%)</li>
|
|
||||||
<li>Conservative vs. Servala's current 65+ instances/customer</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card border-warning mt-2">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title text-warning">Aggressive Scenario</h6>
|
|
||||||
<p><strong>40-150 instances/month</strong></p>
|
|
||||||
<ul class="small">
|
|
||||||
<li>12-25 new clients/month × 3-6 instances each</li>
|
|
||||||
<li>Leverages self-service signup trends</li>
|
|
||||||
<li>Supported by 92% multi-cloud adoption rate</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5><i class="bi bi-graph-up"></i> Return Expectations</h5>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm table-bordered">
|
|
||||||
<thead class="table-info">
|
|
||||||
<tr>
|
|
||||||
<th>Investment Level</th>
|
|
||||||
<th>Servala ROI</th>
|
|
||||||
<th>Market Benchmark</th>
|
|
||||||
<th>Assessment</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>500K (3 years)</td>
|
|
||||||
<td>40-60%</td>
|
|
||||||
<td>SaaS CAC: $1.18-1.50/ARR</td>
|
|
||||||
<td><span class="badge bg-success">Realistic</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>1M (3 years)</td>
|
|
||||||
<td>60-80%</td>
|
|
||||||
<td>Managed services: 13-15% CAGR</td>
|
|
||||||
<td><span class="badge bg-success">Conservative</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>2M (3 years)</td>
|
|
||||||
<td>80-100%</td>
|
|
||||||
<td>High-growth SaaS: 50-100%</td>
|
|
||||||
<td><span class="badge bg-warning">Ambitious</span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info mt-2">
|
|
||||||
<h6><i class="bi bi-info-circle"></i> SaaS Benchmarks</h6>
|
|
||||||
<p class="mb-0 small">Industry CAC ranges $400-$5,000 per customer (low to high-touch). Servala's self-service model targets the lower end while premium revenue per instance supports healthy unit economics. <a href="https://churnfree.com/blog/average-customer-acquisition-cost-saas/" target="_blank" class="text-primary">Source: ChurnFree</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Market Timing & Strategic Positioning</h3>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card border-primary">
|
|
||||||
<div class="card-header bg-primary text-white">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-shield-check"></i> Regulatory Tailwinds</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<ul class="small mb-0">
|
|
||||||
<li><strong>GDPR Compliance</strong>: European data residency requirements</li>
|
|
||||||
<li><strong>NIS2 Directive</strong>: Enhanced cybersecurity mandates</li>
|
|
||||||
<li><strong>Digital Sovereignty</strong>: Growing political support for European solutions</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card border-success">
|
|
||||||
<div class="card-header bg-success text-white">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-graph-up"></i> Market Dynamics</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<ul class="small mb-0">
|
|
||||||
<li><strong>Self-Service Demand</strong>: Growing preference for automated provisioning</li>
|
|
||||||
<li><strong>Multi-Cloud Strategy</strong>: 92% of enterprises avoid single vendor</li>
|
|
||||||
<li><strong>SME Growth</strong>: Fastest segment at 10.6% CAGR</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card border-warning">
|
|
||||||
<div class="card-header bg-warning text-white">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-target"></i> Competitive Advantage</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<ul class="small mb-0">
|
|
||||||
<li><strong>Open Source</strong>: No vendor lock-in vs. proprietary platforms</li>
|
|
||||||
<li><strong>European Focus</strong>: Built for EU regulatory environment</li>
|
|
||||||
<li><strong>Proven Traction</strong>: 2,000+ instances, 30+ customers</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-success mt-4">
|
|
||||||
<h6><i class="bi bi-check-circle-fill"></i> Market Validation Summary</h6>
|
|
||||||
<p class="mb-2">Our market analysis confirms that Servala's ROI projections are <strong>conservative and market-realistic</strong>:</p>
|
|
||||||
<ul class="mb-0">
|
|
||||||
<li><strong>Growth scenarios</strong> align with European cloud adoption trends and self-service demand</li>
|
|
||||||
<li><strong>ROI expectations</strong> (40-100%) match industry benchmarks for SaaS and managed services</li>
|
|
||||||
<li><strong>Market timing</strong> leverages regulatory tailwinds and competitive gaps in the European market</li>
|
|
||||||
<li><strong>Business model</strong> addresses proven market needs with validated technology (2,000+ instances running)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center mt-4">
|
|
||||||
<h6>Additional Research Sources</h6>
|
|
||||||
<div class="row text-small">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<p><strong>Market Research:</strong><br>
|
|
||||||
<a href="https://www.statista.com/topics/8472/cloud-computing-in-europe/" target="_blank" class="text-muted">Statista - Cloud Computing in Europe</a><br>
|
|
||||||
<a href="https://www.forrester.com/report/the-state-of-cloud-in-europe-2024/RES181812" target="_blank" class="text-muted">Forrester - State of Cloud Europe 2024</a></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<p><strong>Industry Analysis:</strong><br>
|
|
||||||
<a href="https://www.gminsights.com/industry-analysis/managed-services-market" target="_blank" class="text-muted">GM Insights - Managed Services Market</a><br>
|
|
||||||
<a href="https://straitsresearch.com/report/managed-services-market" target="_blank" class="text-muted">Straits Research - Market Trends</a></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<p><strong>Competitive Intelligence:</strong><br>
|
|
||||||
<a href="https://www.g2.com/articles/cloud-computing-statistics" target="_blank" class="text-muted">G2 - Cloud Computing Statistics</a><br>
|
|
||||||
<a href="https://spacelift.io/blog/cloud-computing-statistics" target="_blank" class="text-muted">Spacelift - Industry Statistics</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Currency Support Section -->
|
|
||||||
<div class="help-section" id="currency-support">
|
|
||||||
<h2><i class="bi bi-cash-stack"></i> Currency Support</h2>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5><i class="bi bi-cash"></i> Swiss Franc (CHF) - Default</h5>
|
|
||||||
<ul>
|
|
||||||
<li>Swiss locale formatting (de-CH)</li>
|
|
||||||
<li>Traditional Swiss business format</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5><i class="bi bi-currency-euro"></i> Euro (EUR)</h5>
|
|
||||||
<ul>
|
|
||||||
<li>European locale formatting (de-DE)</li>
|
|
||||||
<li>EU business format compliance</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info mt-3">
|
|
||||||
<h6><i class="bi bi-info-circle"></i> Important</h6>
|
|
||||||
<p class="mb-0">Currency selection only changes display format - no conversion is performed. Enter all amounts in your chosen currency.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Technical Details Section (LOWER PRIORITY) -->
|
|
||||||
<div class="help-section" id="technical-details">
|
|
||||||
<h2><i class="bi bi-gear"></i> Technical Details</h2>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h4>Loan Model Details</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Payment Calculation:</strong> Standard amortization formula</li>
|
|
||||||
<li><strong>Interest Rates:</strong> 3-8% annually</li>
|
|
||||||
<li><strong>Risk Level:</strong> Very low - contractually guaranteed</li>
|
|
||||||
<li><strong>Break-even:</strong> Typically 12-18 months</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h5>Monthly Payment Formula</h5>
|
|
||||||
<code>Monthly Payment = P × [r(1+r)^n] / [(1+r)^n - 1]</code>
|
|
||||||
<p class="small text-muted mt-2">Where P = Principal, r = Monthly rate, n = Total payments</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h4>Direct Investment Details</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Revenue Streams:</strong> Service fees + Core infrastructure sales</li>
|
|
||||||
<li><strong>Performance Tracking:</strong> Automatic baseline comparison</li>
|
|
||||||
<li><strong>Grace Periods:</strong> 100% revenue retention periods</li>
|
|
||||||
<li><strong>Churn Reduction:</strong> Investment-based customer success</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h5>Performance Multiplier</h5>
|
|
||||||
<p>Automatically calculated: Actual instances ÷ Baseline instances</p>
|
|
||||||
<p class="text-muted small">1.0x = baseline, 1.5x = 50% above baseline, 2.0x = double baseline</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FAQ Section (LOWEST PRIORITY) -->
|
|
||||||
<div class="help-section" id="faq">
|
|
||||||
<h2><i class="bi bi-question-circle"></i> Frequently Asked Questions</h2>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Basic Questions</h5>
|
|
||||||
<h6>What does "Net Position" mean?</h6>
|
|
||||||
<p>Your profit after subtracting your initial investment. Positive = profitable.</p>
|
|
||||||
|
|
||||||
<h6>What is Core Service Revenue?</h6>
|
|
||||||
<p>Additional income from selling compute/storage per instance. <strong>100% retained by CSP</strong> - not shared with Servala.</p>
|
|
||||||
|
|
||||||
<h6>What happens during grace periods?</h6>
|
|
||||||
<p>You keep 100% of service revenue + all core revenue. Larger investments get longer grace periods.</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Advanced Questions</h5>
|
|
||||||
<h6>How are performance bonuses calculated?</h6>
|
|
||||||
<p>Bonuses apply when you exceed 110% of baseline growth, providing up to 15% additional revenue share for large investments.</p>
|
|
||||||
|
|
||||||
<h6>What is the Performance Multiplier?</h6>
|
|
||||||
<p>Automatically calculated metric: actual results ÷ baseline expectations. Cannot be manually configured.</p>
|
|
||||||
|
|
||||||
<h6>How accurate are projections?</h6>
|
|
||||||
<p>Based on industry benchmarks and historical data, but actual results may vary with market conditions.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center mt-5 mb-4">
|
|
||||||
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-success btn-lg me-3">
|
|
||||||
<i class="bi bi-calculator"></i> Start Calculating Your ROI
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'services:csp_roi_calculator' %}" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-arrow-left"></i> Back to Calculator
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load contact_tags %}
|
{% load contact_tags %}
|
||||||
|
|
||||||
{% block title %}About The Sovereign App Store{% endblock %}
|
{% block title %}About Open Cloud Native Services Hub{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section bg-primary-subtle">
|
<section class="section bg-primary-subtle">
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<header class="section-primary__header text-center">
|
<header class="section-primary__header text-center">
|
||||||
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">About Servala</h1>
|
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">About Servala</h1>
|
||||||
<div class="text-gray-300 w-lg-37 mx-auto">
|
<div class="text-gray-300 w-lg-37 mx-auto">
|
||||||
<p class="mb-0">The Sovereign App Store. Unlock the Power of Sovereign Managed Applications.</p>
|
<p class="mb-0">Open Cloud Native Service Hub. Unlock the Power of Cloud Native Applications.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -238,7 +238,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="w-lg-30">
|
<div class="w-lg-30">
|
||||||
<div class="page-header__image-wrapper">
|
<div class="page-header__image-wrapper">
|
||||||
<img class="page-header__image" src="{% static "img/sir-vala-text.png" %}" alt="Cartoon serval cat named Sir Vala with blue eyes and a happy expression. It's the mascot of Servala - The Sovereign App Store" title="Sir Vala - Mascot of Servala">
|
<img class="page-header__image" src="{% static "img/sir-vala-text.png" %}" alt="Cartoon serval cat named Sir Vala with blue eyes and a happy expression. It's the mascot of Servala - Open Cloud Native Service Hub" title="Sir Vala - Mascot of Servala">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}The Sovereign App Store{% endblock %}
|
{% block title %}Open Cloud Native Services Hub{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section section-hero bg-primary-subtle">
|
<section class="section section-hero bg-primary-subtle">
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
<div class="section-hero-mask"></div>
|
<div class="section-hero-mask"></div>
|
||||||
<div class="px-3 px-lg-0 pt-80 pb-120 position-relative">
|
<div class="px-3 px-lg-0 pt-80 pb-120 position-relative">
|
||||||
<header class="section-hero__header">
|
<header class="section-hero__header">
|
||||||
<h1 class="section-h1 fs-40 fs-lg-64">Sovereign App Store</h1>
|
<h1 class="section-h1 fs-40 fs-lg-64">Servala - Open Cloud Native Service Hub</h1>
|
||||||
<div class="section-hero__desc">
|
<div class="section-hero__desc">
|
||||||
<p>Unlock the Power of Sovereign Managed Applications.</p>
|
<p>Unlock the Power of Cloud Native Applications.</p>
|
||||||
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
|
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -208,7 +208,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-8">
|
<div class="col-12 col-lg-8">
|
||||||
<header class="section-primary__header">
|
<header class="section-primary__header">
|
||||||
<h2 class="section-h1 fs-40 fs-lg-60">Servala - The Sovereign App Store</h2>
|
<h2 class="section-h1 fs-40 fs-lg-60">Servala - Open Cloud Native Service Hub</h2>
|
||||||
<div class="section-primary__desc">
|
<div class="section-primary__desc">
|
||||||
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
|
<p>Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.</p>
|
||||||
<p>Discover:</p>
|
<p>Discover:</p>
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,6 @@
|
||||||
{% block meta_description %}{{ article.excerpt }}{% endblock %}
|
{% block meta_description %}{{ article.excerpt }}{% endblock %}
|
||||||
{% block meta_keywords %}{{ article.meta_keywords }}{% endblock %}
|
{% block meta_keywords %}{{ article.meta_keywords }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
<!-- RSS Feed -->
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Servala Articles RSS Feed" href="{% url 'services:article_rss' %}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section bg-primary-subtle">
|
<section class="section bg-primary-subtle">
|
||||||
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
||||||
|
|
|
||||||
|
|
@ -5,29 +5,13 @@
|
||||||
{% block title %}Articles{% endblock %}
|
{% block title %}Articles{% endblock %}
|
||||||
{% block meta_description %}Explore all articles on Servala, covering cloud services, consulting partners, and cloud provider insights.{% endblock %}
|
{% block meta_description %}Explore all articles on Servala, covering cloud services, consulting partners, and cloud provider insights.{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
<!-- RSS Feed -->
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Servala Articles RSS Feed" href="{% url 'services:article_rss' %}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section bg-primary-subtle">
|
<section class="section bg-primary-subtle">
|
||||||
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
<div class="container mx-auto px-20 px-lg-0 pt-40 pb-60">
|
||||||
<header class="section-primary__header text-center">
|
<header class="section-primary__header text-center">
|
||||||
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">Articles</h1>
|
<h1 class="section-h1 fs-40 fs-lg-64 mb-24">Articles</h1>
|
||||||
<div class="text-gray-300 w-lg-37 mx-auto">
|
<div class="text-gray-300 w-lg-37 mx-auto">
|
||||||
<p class="mb-3">Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.</p>
|
<p class="mb-0">Discover insights, guides, and updates about cloud services, consulting partners, and technology trends.</p>
|
||||||
<!-- RSS Feed Link -->
|
|
||||||
<div class="mb-0">
|
|
||||||
<a href="{% url 'services:article_rss' %}" class="btn btn-outline-light btn-sm" title="Subscribe to RSS Feed">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="me-2">
|
|
||||||
<path d="M4 11a9 9 0 0 1 9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M4 4a16 16 0 0 1 16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<circle cx="5" cy="19" r="1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
Subscribe to RSS Feed
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,47 +9,7 @@
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{% if debug %}
|
{% if debug %}
|
||||||
<!-- Development: Load individual modules for easier debugging -->
|
<!-- Development: Load individual modules for easier debugging -->
|
||||||
<script src="{% static 'js/price-calculator/dom-manager.js' %}"></script>
|
<script defer src="{% static 'js/price-calculator.js' %}"></script>
|
||||||
<script src="{% static 'js/price-calculator/pricing-data-manager.js' %}"></script>
|
|
||||||
<script src="{% static 'js/price-calculator/plan-manager.js' %}"></script>
|
|
||||||
<script src="{% static 'js/price-calculator/addon-manager.js' %}"></script>
|
|
||||||
<script src="{% static 'js/price-calculator/ui-manager.js' %}"></script>
|
|
||||||
<script src="{% static 'js/price-calculator/order-manager.js' %}"></script>
|
|
||||||
<script src="{% static 'js/price-calculator/price-calculator.js' %}"></script>
|
|
||||||
<script>
|
|
||||||
// Initialize calculator when DOM is loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Check if we're on a page that needs the price calculator
|
|
||||||
if (document.getElementById('cpuRange')) {
|
|
||||||
try {
|
|
||||||
window.priceCalculator = new PriceCalculator();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize price calculator:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Global function for traditional plan selection (used by template buttons)
|
|
||||||
function selectPlan(element) {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const planId = element.getAttribute('data-plan-id');
|
|
||||||
const planName = element.getAttribute('data-plan-name');
|
|
||||||
|
|
||||||
// Find the plan dropdown in the contact form
|
|
||||||
const planDropdown = document.getElementById('id_choice');
|
|
||||||
if (planDropdown) {
|
|
||||||
// Find the option with matching plan id and select it
|
|
||||||
for (let i = 0; i < planDropdown.options.length; i++) {
|
|
||||||
const optionValue = planDropdown.options[i].value;
|
|
||||||
if (optionValue.startsWith(planId + '|')) {
|
|
||||||
planDropdown.selectedIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Production: Load compressed bundle -->
|
<!-- Production: Load compressed bundle -->
|
||||||
{% compress js %}
|
{% compress js %}
|
||||||
|
|
@ -65,34 +25,9 @@ function selectPlan(element) {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Check if we're on a page that needs the price calculator
|
// Check if we're on a page that needs the price calculator
|
||||||
if (document.getElementById('cpuRange')) {
|
if (document.getElementById('cpuRange')) {
|
||||||
try {
|
window.priceCalculator = new PriceCalculator();
|
||||||
window.priceCalculator = new PriceCalculator();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize price calculator:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global function for traditional plan selection (used by template buttons)
|
|
||||||
function selectPlan(element) {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const planId = element.getAttribute('data-plan-id');
|
|
||||||
const planName = element.getAttribute('data-plan-name');
|
|
||||||
|
|
||||||
// Find the plan dropdown in the contact form
|
|
||||||
const planDropdown = document.getElementById('id_choice');
|
|
||||||
if (planDropdown) {
|
|
||||||
// Find the option with matching plan id and select it
|
|
||||||
for (let i = 0; i < planDropdown.options.length; i++) {
|
|
||||||
const optionValue = planDropdown.options[i].value;
|
|
||||||
if (optionValue.startsWith(planId + '|')) {
|
|
||||||
planDropdown.selectedIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{% endcompress %}
|
{% endcompress %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -440,18 +375,22 @@ function selectPlan(element) {
|
||||||
<!-- Managed Service Section -->
|
<!-- Managed Service Section -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<div class="d-flex align-items-center flex-shrink-1" style="min-width: 0; max-width: calc(100% - 120px);">
|
<div class="d-flex align-items-center">
|
||||||
<span class="text-nowrap me-1">Managed Service</span>
|
<span>Managed Service (incl. Compute)</span>
|
||||||
<button class="btn btn-link btn-sm p-0 text-muted flex-shrink-0" type="button" data-bs-toggle="collapse" data-bs-target="#managedServiceIncludes" aria-expanded="false" aria-controls="managedServiceIncludes" title="Show what's included" id="managedServiceToggleButton">
|
<button class="btn btn-link btn-sm p-0 ms-2 text-muted" type="button" data-bs-toggle="collapse" data-bs-target="#managedServiceIncludes" aria-expanded="false" aria-controls="managedServiceIncludes" title="Show what's included">
|
||||||
<i class="bi bi-info-circle" id="managedServiceToggleIcon"></i>
|
<i class="bi bi-info-circle" id="managedServiceToggleIcon"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF <span id="managedServicePrice">0.00</span></span>
|
<span class="fw-bold">CHF <span id="managedServicePrice">0.00</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- What's included in managed service (collapsible) -->
|
<!-- What's included in managed service (collapsible) -->
|
||||||
<div class="collapse" id="managedServiceIncludes">
|
<div class="collapse" id="managedServiceIncludes">
|
||||||
<div class="ps-3 border-start border-2 border-subtle">
|
<div class="ps-3 border-start border-2 border-success-subtle">
|
||||||
|
<div class="small text-muted mb-2">
|
||||||
|
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
||||||
|
<em>Included in managed service price:</em>
|
||||||
|
</div>
|
||||||
<div id="managedServiceIncludesContainer">
|
<div id="managedServiceIncludesContainer">
|
||||||
<!-- Required add-ons will be dynamically added here -->
|
<!-- Required add-ons will be dynamically added here -->
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -460,9 +399,9 @@ function selectPlan(element) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Storage - separate billable item -->
|
<!-- Storage - separate billable item -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span class="text-nowrap flex-shrink-1" style="min-width: 0;">Storage - <span id="storageAmount">20</span> GB</span>
|
<span>Storage - <span id="storageAmount">20</span> GB</span>
|
||||||
<span class="fw-bold text-nowrap flex-shrink-0" style="min-width: 110px; text-align: right;">CHF <span id="storagePrice">0.00</span></span>
|
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Optional Addons Pricing -->
|
<!-- Optional Addons Pricing -->
|
||||||
|
|
@ -471,9 +410,9 @@ function selectPlan(element) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between">
|
||||||
<span class="fs-5 fw-bold text-nowrap flex-shrink-1" style="min-width: 0;">Total Monthly Price</span>
|
<span class="fs-5 fw-bold">Total Monthly Price</span>
|
||||||
<span class="fs-4 fw-bold text-primary text-nowrap flex-shrink-0" style="min-width: 120px; text-align: right;">CHF <span id="totalPrice">0.00</span></span>
|
<span class="fs-4 fw-bold text-primary">CHF <span id="totalPrice">0.00</span></span>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted mt-2 d-block">
|
<small class="text-muted mt-2 d-block">
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@
|
||||||
|
|
||||||
{% if partner.address %}
|
{% if partner.address %}
|
||||||
<li>
|
<li>
|
||||||
<div class="d-flex align-items-start text-gray-500 lh-32">
|
<div class="d-flex align-items-start text-gray-500 h-32 lh-32">
|
||||||
<span class="pr-10 pt-1">
|
<span class="pr-10 pt-1">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-alt-fill" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-alt-fill" viewBox="0 0 16 16">
|
||||||
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6" fill="#9A63EC"/>
|
<path d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6" fill="#9A63EC"/>
|
||||||
|
|
@ -157,18 +157,18 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for service in services %}
|
{% for service in services %}
|
||||||
<div class="col-12 col-md-6 mb-30">
|
<div class="col-12 col-md-6 mb-30">
|
||||||
<div class="card h-100 d-flex flex-column clickable-card"
|
<div class="card h-100 d-flex flex-column">
|
||||||
onclick="cardClicked(event, '{{ service.get_absolute_url }}')">
|
|
||||||
{% if service.get_logo %}
|
|
||||||
<div class="d-flex justify-content-between mb-3">
|
|
||||||
<div class="card__image flex-shrink-0">
|
|
||||||
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="card__content d-flex flex-column flex-grow-1">
|
<div class="card__content d-flex flex-column flex-grow-1">
|
||||||
|
{% if service.get_logo %}
|
||||||
|
<div class="d-flex align-items-center justify-content-start" style="height: 60px; margin-bottom: 1rem; width: 100%;">
|
||||||
|
<a href="{{ service.get_absolute_url }}" class="clickable-link" style="display: block; width: 120px; height: 60px;">
|
||||||
|
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo"
|
||||||
|
style="width: 100%; height: 100%; object-fit: contain; object-position: left center; display: block;">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<h3 class="card__title">{{ service.name }}</h3>
|
<h3 class="card__title"><a href="{{ service.get_absolute_url }}" class="text-decoration-none">{{ service.name }}</a></h3>
|
||||||
<p class="card__subtitle">
|
<p class="card__subtitle">
|
||||||
{% for category in service.categories.all %}
|
{% for category in service.categories.all %}
|
||||||
<span>{{ category.full_path }}</span>
|
<span>{{ category.full_path }}</span>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
{% block title %}Complete Price List{% endblock %}
|
{% block title %}Complete Price List{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="{% static "js/chart.umd.min.js" %}"></script>
|
<script src="{% static "js/chart.js" %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ def json_ld_structured_data(context):
|
||||||
data = {
|
data = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
"name": "Servala - The Sovereign App Store",
|
"name": "Servala - Open Cloud Native Service Hub",
|
||||||
"url": base_url,
|
"url": base_url,
|
||||||
}
|
}
|
||||||
json_ld = json.dumps(data, indent=2)
|
json_ld = json.dumps(data, indent=2)
|
||||||
|
|
@ -65,7 +65,7 @@ def json_ld_structured_data(context):
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
"name": "Servala - The Sovereign App Store",
|
"name": "Servala - Open Cloud Native Service Hub",
|
||||||
"url": base_url,
|
"url": base_url,
|
||||||
"description": "Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
"description": "Servala connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
||||||
"potentialAction": {
|
"potentialAction": {
|
||||||
|
|
@ -217,80 +217,87 @@ def json_ld_structured_data(context):
|
||||||
offering = context["offering"]
|
offering = context["offering"]
|
||||||
offering_url = request.build_absolute_uri()
|
offering_url = request.build_absolute_uri()
|
||||||
|
|
||||||
# Check if we have pricing data available
|
data = {
|
||||||
has_pricing_data = False
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Product",
|
||||||
|
"name": f"Managed {offering.service.name} on {offering.cloud_provider.name}",
|
||||||
|
"description": offering.description or offering.service.description,
|
||||||
|
"url": offering_url,
|
||||||
|
"category": "Cloud Service",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add brand (service)
|
||||||
|
data["brand"] = {"@type": "Brand", "name": offering.service.name}
|
||||||
|
|
||||||
|
# Add image if available
|
||||||
|
if hasattr(offering.service, "get_logo") and offering.service.get_logo:
|
||||||
|
data["image"] = request.build_absolute_uri(offering.service.get_logo.url)
|
||||||
|
|
||||||
|
# Add offers if available
|
||||||
if hasattr(offering, "plans") and offering.plans.exists():
|
if hasattr(offering, "plans") and offering.plans.exists():
|
||||||
# Get all plans with pricing
|
# Get all plans with pricing
|
||||||
plans_with_prices = offering.plans.filter(
|
plans_with_prices = offering.plans.filter(
|
||||||
plan_prices__isnull=False
|
plan_prices__isnull=False
|
||||||
).distinct()
|
).distinct()
|
||||||
has_pricing_data = plans_with_prices.exists()
|
|
||||||
|
|
||||||
if has_pricing_data:
|
if plans_with_prices.exists():
|
||||||
# Use Product type with complete pricing information
|
# Create individual offers for each plan
|
||||||
data = {
|
offers = []
|
||||||
"@context": "https://schema.org",
|
all_prices = []
|
||||||
"@type": "Product",
|
|
||||||
"name": f"Managed {offering.service.name} on {offering.cloud_provider.name}",
|
|
||||||
"description": offering.description or offering.service.description,
|
|
||||||
"url": offering_url,
|
|
||||||
"category": "Cloud Service",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add brand (service)
|
for plan in plans_with_prices:
|
||||||
data["brand"] = {"@type": "Brand", "name": offering.service.name}
|
plan_prices = plan.plan_prices.all()
|
||||||
|
if plan_prices.exists():
|
||||||
|
first_price = plan_prices.first()
|
||||||
|
all_prices.extend([p.amount for p in plan_prices])
|
||||||
|
|
||||||
# Add image if available
|
offer = {
|
||||||
if hasattr(offering.service, "get_logo") and offering.service.get_logo:
|
"@type": "Offer",
|
||||||
data["image"] = request.build_absolute_uri(
|
"name": plan.name,
|
||||||
offering.service.get_logo.url
|
"price": str(first_price.amount),
|
||||||
)
|
"priceCurrency": first_price.currency,
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
# Create individual offers for each plan with pricing
|
"url": offering_url + "#plan-order-form",
|
||||||
offers = []
|
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||||
all_prices = []
|
}
|
||||||
|
offers.append(offer)
|
||||||
for plan in plans_with_prices:
|
|
||||||
plan_prices = plan.plan_prices.all()
|
|
||||||
if plan_prices.exists():
|
|
||||||
first_price = plan_prices.first()
|
|
||||||
all_prices.extend([p.amount for p in plan_prices])
|
|
||||||
|
|
||||||
offer = {
|
|
||||||
"@type": "Offer",
|
|
||||||
"name": plan.name,
|
|
||||||
"price": str(first_price.amount),
|
|
||||||
"priceCurrency": first_price.currency,
|
|
||||||
"availability": "https://schema.org/InStock",
|
|
||||||
"url": offering_url + "#plan-order-form",
|
|
||||||
"seller": {"@type": "Organization", "name": "VSHN"},
|
|
||||||
}
|
|
||||||
offers.append(offer)
|
|
||||||
|
|
||||||
# Add aggregate offer with all required pricing fields
|
|
||||||
if all_prices and offers:
|
|
||||||
# Use the currency from the first plan's first price
|
|
||||||
first_plan_with_prices = plans_with_prices.first()
|
|
||||||
first_currency = first_plan_with_prices.plan_prices.first().currency
|
|
||||||
|
|
||||||
|
# Add aggregate offer with all individual offers
|
||||||
data["offers"] = {
|
data["offers"] = {
|
||||||
"@type": "AggregateOffer",
|
"@type": "AggregateOffer",
|
||||||
"availability": "https://schema.org/InStock",
|
"availability": "https://schema.org/InStock",
|
||||||
"offerCount": len(offers),
|
"offerCount": len(offers),
|
||||||
"offers": offers,
|
"offers": offers,
|
||||||
"lowPrice": str(min(all_prices)),
|
|
||||||
"highPrice": str(max(all_prices)),
|
|
||||||
"priceCurrency": first_currency,
|
|
||||||
"seller": {"@type": "Organization", "name": "VSHN"},
|
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add lowPrice, highPrice and priceCurrency if we have prices
|
||||||
|
if all_prices:
|
||||||
|
data["offers"]["lowPrice"] = str(min(all_prices))
|
||||||
|
data["offers"]["highPrice"] = str(max(all_prices))
|
||||||
|
# Use the currency from the first plan's first price
|
||||||
|
first_plan_with_prices = plans_with_prices.first()
|
||||||
|
first_currency = first_plan_with_prices.plan_prices.first().currency
|
||||||
|
data["offers"]["priceCurrency"] = first_currency
|
||||||
|
|
||||||
# Note: aggregateRating and review fields are not included as this is a B2B
|
# Note: aggregateRating and review fields are not included as this is a B2B
|
||||||
# service marketplace without a review system. These could be added in the future
|
# service marketplace without a review system. These could be added in the future
|
||||||
# if customer reviews/ratings are implemented.
|
# if customer reviews/ratings are implemented.
|
||||||
else:
|
# Example structure for future implementation:
|
||||||
# No pricing data available - use Organization data instead of Product
|
# if hasattr(offering, 'reviews') and offering.reviews.exists():
|
||||||
# to avoid Google Search Console errors for missing required Product fields
|
# data["aggregateRating"] = {
|
||||||
data = organization_data
|
# "@type": "AggregateRating",
|
||||||
|
# "ratingValue": "4.5",
|
||||||
|
# "reviewCount": "10"
|
||||||
|
# }
|
||||||
|
else:
|
||||||
|
# No pricing available, just basic offer info
|
||||||
|
data["offers"] = {
|
||||||
|
"@type": "AggregateOffer",
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
|
"offerCount": offering.plans.count(),
|
||||||
|
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||||
|
}
|
||||||
|
|
||||||
elif view_name == "article_list":
|
elif view_name == "article_list":
|
||||||
data = {
|
data = {
|
||||||
|
|
@ -353,7 +360,12 @@ def json_ld_structured_data(context):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add about field based on related entities
|
# Add about field based on related entities
|
||||||
if article.related_consulting_partner:
|
if article.related_service:
|
||||||
|
data["about"] = {
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": article.related_service.name,
|
||||||
|
}
|
||||||
|
elif article.related_consulting_partner:
|
||||||
data["about"] = {
|
data["about"] = {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": article.related_consulting_partner.name,
|
"name": article.related_consulting_partner.name,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
from .feeds import ArticleRSSFeed
|
|
||||||
|
|
||||||
app_name = "services"
|
app_name = "services"
|
||||||
|
|
||||||
|
|
@ -20,7 +19,6 @@ urlpatterns = [
|
||||||
path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"),
|
path("provider/<slug:slug>/", views.provider_detail, name="provider_detail"),
|
||||||
path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"),
|
path("partner/<slug:slug>/", views.partner_detail, name="partner_detail"),
|
||||||
path("articles/", views.article_list, name="article_list"),
|
path("articles/", views.article_list, name="article_list"),
|
||||||
path("articles/rss/", ArticleRSSFeed(), name="article_rss"),
|
|
||||||
path("article/<slug:slug>/", views.article_detail, name="article_detail"),
|
path("article/<slug:slug>/", views.article_detail, name="article_detail"),
|
||||||
path("contact/", views.leads.contact, name="contact"),
|
path("contact/", views.leads.contact, name="contact"),
|
||||||
path("contact/thank-you/", views.thank_you, name="thank_you"),
|
path("contact/thank-you/", views.thank_you, name="thank_you"),
|
||||||
|
|
@ -31,14 +29,4 @@ urlpatterns = [
|
||||||
views.pricelist,
|
views.pricelist,
|
||||||
name="pricelist",
|
name="pricelist",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"csp-roi-calculator/",
|
|
||||||
views.csp_roi_calculator,
|
|
||||||
name="csp_roi_calculator",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"csp-roi-calculator/help/",
|
|
||||||
views.roi_calculator_help,
|
|
||||||
name="roi_calculator_help",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,3 @@ from .services import *
|
||||||
from .pages import *
|
from .pages import *
|
||||||
from .subscriptions import *
|
from .subscriptions import *
|
||||||
from .pricelist import *
|
from .pricelist import *
|
||||||
from .calculator import *
|
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
from django.shortcuts import render, redirect
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def csp_roi_calculator(request):
|
|
||||||
"""
|
|
||||||
CSP ROI Calculator - Protected view with password authentication
|
|
||||||
Provides a comprehensive ROI calculation tool for cloud provider investors
|
|
||||||
"""
|
|
||||||
# Handle logout
|
|
||||||
if request.method == "POST" and request.POST.get("logout"):
|
|
||||||
request.session.pop("csp_calculator_authenticated", None)
|
|
||||||
return redirect("services:csp_roi_calculator")
|
|
||||||
|
|
||||||
# Get password from Django settings
|
|
||||||
calculator_password = getattr(settings, "CSP_CALCULATOR_PASSWORD", None)
|
|
||||||
|
|
||||||
# If no password is configured, deny access
|
|
||||||
if not calculator_password:
|
|
||||||
messages.error(
|
|
||||||
request,
|
|
||||||
"Calculator is not properly configured. Please contact administrator.",
|
|
||||||
)
|
|
||||||
return render(
|
|
||||||
request, "calculator/password_form.html", {"password_error": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Password protection - check if authenticated in session
|
|
||||||
if not request.session.get("csp_calculator_authenticated", False):
|
|
||||||
if request.method == "POST":
|
|
||||||
password = request.POST.get("password", "")
|
|
||||||
|
|
||||||
# Validate password
|
|
||||||
if password == calculator_password:
|
|
||||||
request.session["csp_calculator_authenticated"] = True
|
|
||||||
# Set session timeout (optional - expires after 24 hours of inactivity)
|
|
||||||
request.session.set_expiry(86400) # 24 hours
|
|
||||||
messages.success(request, "Access granted to CSP ROI Calculator.")
|
|
||||||
return redirect("services:csp_roi_calculator")
|
|
||||||
else:
|
|
||||||
messages.error(request, "Invalid password. Please try again.")
|
|
||||||
|
|
||||||
# Show password form
|
|
||||||
return render(request, "calculator/password_form.html")
|
|
||||||
|
|
||||||
# User is authenticated, show the calculator
|
|
||||||
context = {
|
|
||||||
"page_title": "CSP ROI Calculator",
|
|
||||||
"page_description": "Calculate potential returns from investing in Servala platform",
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(request, "calculator/csp_roi_calculator.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def roi_calculator_help(request):
|
|
||||||
"""
|
|
||||||
ROI Calculator Help page - Protected view with same password authentication as calculator
|
|
||||||
Shows detailed information about investment models and market analysis
|
|
||||||
"""
|
|
||||||
# Handle logout
|
|
||||||
if request.method == "POST" and request.POST.get("logout"):
|
|
||||||
request.session.pop("csp_calculator_authenticated", None)
|
|
||||||
return redirect("services:roi_calculator_help")
|
|
||||||
|
|
||||||
# Get password from Django settings
|
|
||||||
calculator_password = getattr(settings, "CSP_CALCULATOR_PASSWORD", None)
|
|
||||||
|
|
||||||
# If no password is configured, deny access
|
|
||||||
if not calculator_password:
|
|
||||||
messages.error(
|
|
||||||
request,
|
|
||||||
"Calculator help is not properly configured. Please contact administrator.",
|
|
||||||
)
|
|
||||||
return render(
|
|
||||||
request, "calculator/password_form.html", {"password_error": True}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Password protection - check if authenticated in session
|
|
||||||
if not request.session.get("csp_calculator_authenticated", False):
|
|
||||||
if request.method == "POST":
|
|
||||||
password = request.POST.get("password", "")
|
|
||||||
|
|
||||||
# Validate password
|
|
||||||
if password == calculator_password:
|
|
||||||
request.session["csp_calculator_authenticated"] = True
|
|
||||||
# Set session timeout (optional - expires after 24 hours of inactivity)
|
|
||||||
request.session.set_expiry(86400) # 24 hours
|
|
||||||
messages.success(request, "Access granted to CSP ROI Calculator Help.")
|
|
||||||
return redirect("services:roi_calculator_help")
|
|
||||||
else:
|
|
||||||
messages.error(request, "Invalid password. Please try again.")
|
|
||||||
|
|
||||||
# Show password form
|
|
||||||
return render(request, "calculator/password_form.html")
|
|
||||||
|
|
||||||
# User is authenticated, show the help page
|
|
||||||
context = {
|
|
||||||
"page_title": "ROI Calculator Help - Investment Models",
|
|
||||||
"page_description": "Understand Servala's Loan and Direct Investment models with detailed explanations and market analysis",
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(request, "calculator/roi_calculator_help.html", context)
|
|
||||||
|
|
@ -190,7 +190,7 @@ def generate_exoscale_marketplace_yaml(offering):
|
||||||
yaml_key: {
|
yaml_key: {
|
||||||
"page_class": "tmpl-marketplace-product",
|
"page_class": "tmpl-marketplace-product",
|
||||||
"html_title": title,
|
"html_title": title,
|
||||||
"meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the The Sovereign App Store. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
"meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
||||||
"page_header_title": title,
|
"page_header_title": title,
|
||||||
"provider_key": "vshn",
|
"provider_key": "vshn",
|
||||||
"slug": f"{offering.service.slug}-by-servala",
|
"slug": f"{offering.service.slug}-by-servala",
|
||||||
|
|
|
||||||
|
|
@ -238,9 +238,6 @@ ODOO_CONFIG = {
|
||||||
"mailing_list_id": env.int("ODOO_MAILING_LIST_ID", default=46),
|
"mailing_list_id": env.int("ODOO_MAILING_LIST_ID", default=46),
|
||||||
}
|
}
|
||||||
|
|
||||||
# CSP ROI Calculator Configuration
|
|
||||||
CSP_CALCULATOR_PASSWORD = env.str("CSP_CALCULATOR_PASSWORD", default=None)
|
|
||||||
|
|
||||||
BROKER_USERNAME = env.str("BROKER_USERNAME", default="broker")
|
BROKER_USERNAME = env.str("BROKER_USERNAME", default="broker")
|
||||||
BROKER_PASSWORD = env.str("BROKER_PASSWORD", default="secret")
|
BROKER_PASSWORD = env.str("BROKER_PASSWORD", default="secret")
|
||||||
BASE_URL = "https://your-domain.com"
|
BASE_URL = "https://your-domain.com"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from hub.services.models import (
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
ConsultingPartner,
|
ConsultingPartner,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
Article,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,12 +13,7 @@ class StaticSitemap(Sitemap):
|
||||||
priority = 1.0
|
priority = 1.0
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return [
|
return ["services:homepage", "services:contact"]
|
||||||
"services:homepage",
|
|
||||||
"services:contact",
|
|
||||||
"services:article_list",
|
|
||||||
"services:article_rss",
|
|
||||||
]
|
|
||||||
|
|
||||||
def location(self, item):
|
def location(self, item):
|
||||||
return reverse(item)
|
return reverse(item)
|
||||||
|
|
@ -66,14 +60,3 @@ class ConsultingPartnerSitemap(Sitemap):
|
||||||
|
|
||||||
def lastmod(self, obj):
|
def lastmod(self, obj):
|
||||||
return obj.updated_at
|
return obj.updated_at
|
||||||
|
|
||||||
|
|
||||||
class ArticleSitemap(Sitemap):
|
|
||||||
changefreq = "weekly"
|
|
||||||
priority = 0.8
|
|
||||||
|
|
||||||
def items(self):
|
|
||||||
return Article.objects.filter(is_published=True)
|
|
||||||
|
|
||||||
def lastmod(self, obj):
|
|
||||||
return obj.updated_at
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ from .sitemaps import (
|
||||||
OfferingSitemap,
|
OfferingSitemap,
|
||||||
CloudProviderSitemap,
|
CloudProviderSitemap,
|
||||||
ConsultingPartnerSitemap,
|
ConsultingPartnerSitemap,
|
||||||
ArticleSitemap,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,13 +37,12 @@ sitemaps = {
|
||||||
"offerings": OfferingSitemap,
|
"offerings": OfferingSitemap,
|
||||||
"providers": CloudProviderSitemap,
|
"providers": CloudProviderSitemap,
|
||||||
"partners": ConsultingPartnerSitemap,
|
"partners": ConsultingPartnerSitemap,
|
||||||
"articles": ArticleSitemap,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("", include("hub.services.urls")),
|
path("", include("hub.services.urls")),
|
||||||
# path("broker/", include("hub.broker.urls", namespace="broker")),
|
path("broker/", include("hub.broker.urls", namespace="broker")),
|
||||||
path(
|
path(
|
||||||
"sitemap.xml",
|
"sitemap.xml",
|
||||||
sitemap,
|
sitemap,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue