refactor(planner): HTMX server-render refactor — eliminate JS SPA
Replace the 847-line client-side planner with an HTMX architecture:
- All tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered
server-side as Jinja2 partials; slider changes POST to /planner/calculate
which returns HTML; HTMX swaps into #tab-content
- Merge _PLANNER_TRANSLATIONS into _TRANSLATIONS; delete get_planner_translations()
and window.__PADELNOMICS_LOCALE__; all strings now {{ t.key }} in templates
- New form_to_state() and augment_d() helpers in routes.py; calculate endpoint
returns HTML instead of JSON; OOB swaps update header tag + wizard preview
- Add 5 Jinja2 filters: fmt_currency, fmt_k, fmt_pct, fmt_x, fmt_n
- Rewrite planner.js to ~200 lines: chart init on htmx:afterSettle, slider sync,
toggle management, wizard nav, scenario save/load, reset to defaults
- Add 7 new template partials: tab_capex, tab_operating, tab_cashflow,
tab_returns, tab_metrics, calculate_response, court_summary, wizard_preview
- Update test_phase0 to match new HTML-returning /calculate endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- planner: full HTMX refactor — replaced 847-line SPA `planner.js` with server-rendered Jinja2 tab partials; planner now uses `hx-post /planner/calculate` + form state; all tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered server-side; Chart.js data embedded as `<script type="application/json">` tags, re-initialized on `htmx:afterSettle`; new `planner.js` is ~200 lines (chart init, slider sync, toggle management, wizard nav, scenario save/load)
|
||||
- planner/i18n: merged `_PLANNER_TRANSLATIONS` (~200 keys × 2 languages) into `_TRANSLATIONS`; deleted `get_planner_translations()` and `window.__PADELNOMICS_LOCALE__`; all planner strings now via standard `{{ t.key }}` Jinja2 template variables; adding a new language = one section in `_TRANSLATIONS`
|
||||
- planner/routes: `/planner/calculate` endpoint now returns HTML partial (HTMX) instead of JSON; added `form_to_state()` for form serialization, `augment_d()` for chart data + sensitivity table computation, `COUNTRY_PRESETS` dict; `index()` passes full calc result to template on initial load
|
||||
- app: added 5 Jinja2 template filters — `fmt_currency`, `fmt_k`, `fmt_pct`, `fmt_x`, `fmt_n` — replacing equivalent JS formatting functions
|
||||
- copy: switch all German UI copy from formal "Sie/Ihr" to informal "Du/Dein" — covers i18n.py (~60 keys), planner wizard step titles/subtitles, export waitlist page, quote wizard steps, quote submitted/verify pages, directory supplier detail, directory results partial, supplier signup step 4, supplier waitlist confirmed page
|
||||
- copy: replace "Platz-Anbieter" with "Anbieter" in CTAs; "Anlage" → "Padel-Platz" in planner wizard step 1 title/subtitle and planner translations (wiz_venue, sl_budget_target); "Anlageplanung" → "Padelplatz-Planung" in service checklist
|
||||
- copy: update directory H1 to SEO multi-term "Padelplatz-Hersteller, Platzbauer & Anbieter"; subheading now mentions Hersteller, Platzbauer, schlüsselfertige Lösungen
|
||||
|
||||
@@ -22,6 +22,38 @@ def _detect_lang() -> str:
|
||||
return "en"
|
||||
|
||||
|
||||
def _fmt_currency(n) -> str:
|
||||
"""Format as EUR with German-style thousands separator (€50.000)."""
|
||||
n = round(float(n))
|
||||
s = f"{abs(n):,}".replace(",", ".")
|
||||
return f"-\u20ac{s}" if n < 0 else f"\u20ac{s}"
|
||||
|
||||
|
||||
def _fmt_k(n) -> str:
|
||||
"""Short currency: €50K, €1.2M, or full fmt_currency."""
|
||||
n = float(n)
|
||||
if abs(n) >= 1_000_000:
|
||||
return f"\u20ac{n/1_000_000:.1f}M"
|
||||
if abs(n) >= 1_000:
|
||||
return f"\u20ac{n/1_000:.0f}K"
|
||||
return _fmt_currency(n)
|
||||
|
||||
|
||||
def _fmt_pct(n) -> str:
|
||||
"""Format fraction as percentage: 0.152 → '15.2%'."""
|
||||
return f"{float(n) * 100:.1f}%"
|
||||
|
||||
|
||||
def _fmt_x(n) -> str:
|
||||
"""Format as MOIC multiple: 2.30x."""
|
||||
return f"{float(n):.2f}x"
|
||||
|
||||
|
||||
def _fmt_n(n) -> str:
|
||||
"""Format integer with German thousands separator: 1.234."""
|
||||
return f"{round(float(n)):,}".replace(",", ".")
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
"""Create and configure the Quart application."""
|
||||
|
||||
@@ -35,6 +67,13 @@ def create_app() -> Quart:
|
||||
|
||||
app.secret_key = config.SECRET_KEY
|
||||
|
||||
# Jinja2 filters for financial formatting (used in planner templates)
|
||||
app.jinja_env.filters["fmt_currency"] = _fmt_currency
|
||||
app.jinja_env.filters["fmt_k"] = _fmt_k
|
||||
app.jinja_env.filters["fmt_pct"] = _fmt_pct
|
||||
app.jinja_env.filters["fmt_x"] = _fmt_x
|
||||
app.jinja_env.filters["fmt_n"] = _fmt_n
|
||||
|
||||
# Session config
|
||||
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
|
||||
@@ -442,6 +442,203 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"art_run_numbers_h2": "Run Your Own Numbers",
|
||||
"art_run_numbers_text": "Use our free financial planner to model a padel center with your own assumptions.",
|
||||
"art_open_planner_btn": "Open the Planner",
|
||||
# ── Planner UI strings ───────────────────────────────────────────────
|
||||
"tab_assumptions": "Assumptions",
|
||||
"tab_capex": "Investment",
|
||||
"tab_operating": "Operating Model",
|
||||
"tab_cashflow": "Cash Flow",
|
||||
"tab_returns": "Returns & Exit",
|
||||
"tab_metrics": "Key Metrics",
|
||||
"wiz_venue": "Venue",
|
||||
"wiz_pricing": "Pricing",
|
||||
"wiz_costs": "Costs",
|
||||
"wiz_finance": "Finance",
|
||||
"toggle_indoor": "Indoor",
|
||||
"toggle_outdoor": "Outdoor",
|
||||
"toggle_rent": "Rent / Lease",
|
||||
"toggle_buy": "Buy / Build",
|
||||
"pill_country": "Country",
|
||||
"pill_glass_type": "Glass Type",
|
||||
"pill_lighting_type": "Lighting Type",
|
||||
"pill_glass_standard": "Standard Glass",
|
||||
"pill_glass_panoramic": "Panoramic Glass",
|
||||
"pill_light_led_standard": "LED Standard",
|
||||
"pill_light_led_competition": "LED Competition",
|
||||
"pill_light_natural": "Natural Light",
|
||||
"country_de": "Germany",
|
||||
"country_es": "Spain",
|
||||
"country_it": "Italy",
|
||||
"country_fr": "France",
|
||||
"country_nl": "Netherlands",
|
||||
"country_se": "Sweden",
|
||||
"country_uk": "UK",
|
||||
"country_us": "USA",
|
||||
"sl_dbl_courts": "Double Courts (20\u00d710m)",
|
||||
"sl_sgl_courts": "Single Courts (20\u00d76m)",
|
||||
"sl_sqm_dbl_hall": "Hall m\u00b2 per Double Court",
|
||||
"sl_sqm_sgl_hall": "Hall m\u00b2 per Single Court",
|
||||
"sl_sqm_dbl_outdoor": "Land m\u00b2 per Double Court",
|
||||
"sl_sqm_sgl_outdoor": "Land m\u00b2 per Single Court",
|
||||
"sl_rate_peak": "Peak Hour Rate (\u20ac)",
|
||||
"sl_rate_offpeak": "Off-Peak Hour Rate (\u20ac)",
|
||||
"sl_rate_single": "Single Court Rate (\u20ac)",
|
||||
"sl_peak_pct": "Peak Hours Share",
|
||||
"sl_booking_fee": "Platform Fee",
|
||||
"sl_util_target": "Target Utilization",
|
||||
"sl_hours_per_day": "Operating Hours / Day",
|
||||
"sl_days_indoor": "Indoor Days / Month",
|
||||
"sl_days_outdoor": "Outdoor Days / Month",
|
||||
"sl_ancillary_header": "Ancillary Revenue (per court/month):",
|
||||
"sl_membership_rev": "Membership Revenue / Court",
|
||||
"sl_fb_rev": "F&B Revenue / Court",
|
||||
"sl_coaching_rev": "Coaching & Events / Court",
|
||||
"sl_retail_rev": "Retail / Court",
|
||||
"sl_court_cost_dbl": "Court Cost \u2014 Double",
|
||||
"sl_court_cost_sgl": "Court Cost \u2014 Single",
|
||||
"sl_hall_cost_sqm": "Hall Construction (\u20ac/m\u00b2)",
|
||||
"sl_foundation_sqm": "Foundation (\u20ac/m\u00b2)",
|
||||
"sl_land_price_sqm": "Land Price (\u20ac/m\u00b2)",
|
||||
"sl_hvac": "HVAC System",
|
||||
"sl_electrical": "Electrical + Lighting",
|
||||
"sl_sanitary": "Sanitary / Changing",
|
||||
"sl_fire": "Fire Protection",
|
||||
"sl_planning": "Planning + Permits",
|
||||
"sl_floor_prep": "Floor Preparation",
|
||||
"sl_hvac_upgrade": "HVAC Upgrade",
|
||||
"sl_lighting_upgrade": "Lighting Upgrade",
|
||||
"sl_fitout": "Fit-Out & Reception",
|
||||
"sl_outdoor_foundation": "Concrete (\u20ac/m\u00b2)",
|
||||
"sl_outdoor_site_work": "Site Work",
|
||||
"sl_outdoor_lighting": "Lighting per Court",
|
||||
"sl_outdoor_fencing": "Fencing",
|
||||
"sl_permits": "Permits & Compliance",
|
||||
"sl_working_capital": "Working Capital",
|
||||
"sl_contingency": "Contingency",
|
||||
"sl_budget_target": "Your Budget Target",
|
||||
"sl_rent_sqm": "Rent (\u20ac/m\u00b2/month)",
|
||||
"sl_outdoor_rent": "Monthly Land Rent",
|
||||
"sl_property_tax": "Property Tax / month",
|
||||
"sl_insurance": "Insurance (\u20ac/mo)",
|
||||
"sl_electricity": "Electricity (\u20ac/mo)",
|
||||
"sl_heating": "Heating (\u20ac/mo)",
|
||||
"sl_water": "Water (\u20ac/mo)",
|
||||
"sl_maintenance": "Maintenance (\u20ac/mo)",
|
||||
"sl_cleaning": "Cleaning (\u20ac/mo)",
|
||||
"sl_marketing": "Marketing / Misc (\u20ac/mo)",
|
||||
"sl_staff": "Staff (\u20ac/mo)",
|
||||
"sl_loan_pct": "Loan-to-Cost (LTC)",
|
||||
"sl_interest_rate": "Interest Rate",
|
||||
"sl_loan_term": "Loan Term",
|
||||
"sl_construction_months": "Construction Period",
|
||||
"sl_hold_years": "Holding Period",
|
||||
"sl_exit_multiple": "Exit EBITDA Multiple",
|
||||
"sl_annual_rev_growth": "Annual Revenue Growth",
|
||||
"btn_save": "Save",
|
||||
"btn_my_scenarios": "My Scenarios",
|
||||
"btn_reset": "Reset to Defaults",
|
||||
"btn_reset_confirm": "Sure? Reset",
|
||||
"btn_back": "\u2190 Back",
|
||||
"btn_next": "Next \u2192",
|
||||
"btn_show_results": "Show Results \u2192",
|
||||
"prompt_scenario_name": "Scenario name:",
|
||||
"prompt_scenario_default": "My Padel Plan",
|
||||
"toast_saved": "Scenario saved!",
|
||||
"label_indoor": "Indoor",
|
||||
"label_outdoor": "Outdoor",
|
||||
"label_build_buy": "Build/Buy",
|
||||
"label_rent": "Rent",
|
||||
"label_courts": "courts",
|
||||
"label_indoor_hall": "Indoor hall",
|
||||
"label_outdoor_land": "Outdoor land",
|
||||
"label_playing_surface": "Playing surface",
|
||||
"wiz_capex": "CAPEX",
|
||||
"wiz_monthly_cf": "Monthly CF",
|
||||
"wiz_irr": "IRR",
|
||||
"wiz_mo": "/mo",
|
||||
"card_total_courts": "Total Courts",
|
||||
"card_floor_area": "Floor Area",
|
||||
"card_court_area": "Court Area",
|
||||
"card_total_capex": "Total CAPEX",
|
||||
"card_per_court": "Per Court",
|
||||
"card_per_sqm": "Per m\u00b2",
|
||||
"budget_over": "BUDGET OVER",
|
||||
"budget_under": "BUDGET UNDER",
|
||||
"table_total_capex": "TOTAL CAPEX",
|
||||
"th_item": "Item",
|
||||
"th_amount": "Amount",
|
||||
"card_net_rev_mo": "Net Revenue/mo",
|
||||
"card_ebitda_mo": "EBITDA/mo",
|
||||
"card_annual_rev": "Annual Revenue",
|
||||
"card_rev_pah": "RevPAH",
|
||||
"sub_stabilized": "Stabilized",
|
||||
"sub_year3": "Year 3",
|
||||
"stream_court_rental": "Court Rental (net of fees)",
|
||||
"stream_equipment": "Equipment Rental (rackets/balls)",
|
||||
"stream_memberships": "Memberships",
|
||||
"stream_fb": "F&B",
|
||||
"stream_coaching": "Coaching & Events",
|
||||
"stream_retail": "Retail",
|
||||
"table_total_net_rev": "Total Net Revenue",
|
||||
"table_total_opex": "Total Monthly OpEx",
|
||||
"th_stream": "Stream",
|
||||
"th_monthly": "Monthly",
|
||||
"th_share": "Share",
|
||||
"chart_revenue": "Revenue",
|
||||
"chart_opex_debt": "OpEx+Debt",
|
||||
"chart_court_rev": "Court Rev",
|
||||
"chart_fees": "Fees",
|
||||
"chart_ancillary": "Ancillary",
|
||||
"chart_opex": "OpEx",
|
||||
"chart_debt": "Debt",
|
||||
"card_y1_ncf": "Year 1 Net CF",
|
||||
"card_y3_ncf": "Year 3 Net CF",
|
||||
"card_payback": "Payback",
|
||||
"card_initial_inv": "Initial Investment",
|
||||
"payback_not_reached": "Not reached",
|
||||
"th_year": "Year",
|
||||
"th_revenue": "Revenue",
|
||||
"th_ebitda": "EBITDA",
|
||||
"th_debt_service": "Debt Service",
|
||||
"th_net_cf": "Net CF",
|
||||
"th_dscr": "DSCR",
|
||||
"th_util": "Util.",
|
||||
"card_irr": "IRR",
|
||||
"card_moic": "MOIC",
|
||||
"card_break_even": "Break-Even Util.",
|
||||
"card_cash_on_cash": "Cash-on-Cash",
|
||||
"wf_stab_ebitda": "Stabilized EBITDA (Y3)",
|
||||
"wf_exit_multiple": "\u00d7 Exit Multiple",
|
||||
"wf_enterprise_value": "= Enterprise Value",
|
||||
"wf_remaining_loan": "\u2013 Remaining Loan",
|
||||
"wf_net_exit": "= Net Exit Proceeds",
|
||||
"wf_cum_cf": "+ Cumulative Cash Flow",
|
||||
"wf_total_returns": "= Total Returns",
|
||||
"wf_investment": "\u00f7 Investment",
|
||||
"wf_moic": "= MOIC",
|
||||
"th_utilization": "Utilization",
|
||||
"th_monthly_rev": "Monthly Rev",
|
||||
"th_monthly_ncf": "Monthly NCF",
|
||||
"th_annual_ncf": "Annual NCF",
|
||||
"th_price_change": "Price Change",
|
||||
"th_avg_rate": "Avg Rate",
|
||||
"metrics_return": "Return Metrics",
|
||||
"metrics_revenue": "Revenue Efficiency",
|
||||
"metrics_cost": "Cost & Margin",
|
||||
"metrics_debt": "Debt & Coverage",
|
||||
"metrics_invest": "Investment Efficiency",
|
||||
"metrics_ops": "Operational",
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "May",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Oct",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dec",
|
||||
},
|
||||
"de": {
|
||||
# ── Navigation & footer ──────────────────────────────────────────────
|
||||
@@ -869,264 +1066,21 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"art_run_numbers_h2": "Eigene Zahlen berechnen",
|
||||
"art_run_numbers_text": "Nutze unseren kostenlosen Finanzplaner, um ein Padel-Center mit Deinen eigenen Annahmen zu modellieren.",
|
||||
"art_open_planner_btn": "Planer \u00f6ffnen",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_translations(lang: str) -> dict[str, str]:
|
||||
"""Return UI translation strings for the given language.
|
||||
|
||||
Falls back to English for unsupported languages (should never happen
|
||||
in practice since we 404 unsupported langs in before_request).
|
||||
"""
|
||||
assert lang in _TRANSLATIONS, f"Unknown lang: {lang!r}"
|
||||
return _TRANSLATIONS[lang]
|
||||
|
||||
|
||||
# ── Planner JS locale strings ────────────────────────────────────────────────
|
||||
# Injected as window.__PADELNOMICS_LOCALE__ in planner.html.
|
||||
# planner.js reads them via: const L = window.__PADELNOMICS_LOCALE__ || {};
|
||||
# Usage in JS: L['key'] || 'English Fallback'
|
||||
|
||||
_PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
# Tabs
|
||||
"tab_assumptions": "Assumptions",
|
||||
"tab_capex": "Investment",
|
||||
"tab_operating": "Operating Model",
|
||||
"tab_cashflow": "Cash Flow",
|
||||
"tab_returns": "Returns & Exit",
|
||||
"tab_metrics": "Key Metrics",
|
||||
# Wizard steps
|
||||
"wiz_venue": "Venue",
|
||||
"wiz_pricing": "Pricing",
|
||||
"wiz_costs": "Costs",
|
||||
"wiz_finance": "Finance",
|
||||
# Toggle options
|
||||
"toggle_indoor": "Indoor",
|
||||
"toggle_outdoor": "Outdoor",
|
||||
"toggle_rent": "Rent / Lease",
|
||||
"toggle_buy": "Buy / Build",
|
||||
# Pill / select labels
|
||||
"pill_country": "Country",
|
||||
"pill_glass_type": "Glass Type",
|
||||
"pill_lighting_type": "Lighting Type",
|
||||
"pill_glass_standard": "Standard Glass",
|
||||
"pill_glass_panoramic": "Panoramic Glass",
|
||||
"pill_light_led_standard": "LED Standard",
|
||||
"pill_light_led_competition": "LED Competition",
|
||||
"pill_light_natural": "Natural Light",
|
||||
# Country labels
|
||||
"country_de": "Germany",
|
||||
"country_es": "Spain",
|
||||
"country_it": "Italy",
|
||||
"country_fr": "France",
|
||||
"country_nl": "Netherlands",
|
||||
"country_se": "Sweden",
|
||||
"country_uk": "UK",
|
||||
"country_us": "USA",
|
||||
# Slider labels — courts & space
|
||||
"sl_dbl_courts": "Double Courts (20\u00d710m)",
|
||||
"sl_sgl_courts": "Single Courts (20\u00d76m)",
|
||||
"sl_sqm_dbl_hall": "Hall m\u00b2 per Double Court",
|
||||
"sl_sqm_sgl_hall": "Hall m\u00b2 per Single Court",
|
||||
"sl_sqm_dbl_outdoor": "Land m\u00b2 per Double Court",
|
||||
"sl_sqm_sgl_outdoor": "Land m\u00b2 per Single Court",
|
||||
# Slider labels — pricing & utilization
|
||||
"sl_rate_peak": "Peak Hour Rate (\u20ac)",
|
||||
"sl_rate_offpeak": "Off-Peak Hour Rate (\u20ac)",
|
||||
"sl_rate_single": "Single Court Rate (\u20ac)",
|
||||
"sl_peak_pct": "Peak Hours Share",
|
||||
"sl_booking_fee": "Platform Fee",
|
||||
"sl_util_target": "Target Utilization",
|
||||
"sl_hours_per_day": "Operating Hours / Day",
|
||||
"sl_days_indoor": "Indoor Days / Month",
|
||||
"sl_days_outdoor": "Outdoor Days / Month",
|
||||
"sl_ancillary_header": "Ancillary Revenue (per court/month):",
|
||||
"sl_membership_rev": "Membership Revenue / Court",
|
||||
"sl_fb_rev": "F&B Revenue / Court",
|
||||
"sl_coaching_rev": "Coaching & Events / Court",
|
||||
"sl_retail_rev": "Retail / Court",
|
||||
# Slider labels — CAPEX
|
||||
"sl_court_cost_dbl": "Court Cost \u2014 Double",
|
||||
"sl_court_cost_sgl": "Court Cost \u2014 Single",
|
||||
"sl_hall_cost_sqm": "Hall Construction (\u20ac/m\u00b2)",
|
||||
"sl_foundation_sqm": "Foundation (\u20ac/m\u00b2)",
|
||||
"sl_land_price_sqm": "Land Price (\u20ac/m\u00b2)",
|
||||
"sl_hvac": "HVAC System",
|
||||
"sl_electrical": "Electrical + Lighting",
|
||||
"sl_sanitary": "Sanitary / Changing",
|
||||
"sl_fire": "Fire Protection",
|
||||
"sl_planning": "Planning + Permits",
|
||||
"sl_floor_prep": "Floor Preparation",
|
||||
"sl_hvac_upgrade": "HVAC Upgrade",
|
||||
"sl_lighting_upgrade": "Lighting Upgrade",
|
||||
"sl_fitout": "Fit-Out & Reception",
|
||||
"sl_outdoor_foundation": "Concrete (\u20ac/m\u00b2)",
|
||||
"sl_outdoor_site_work": "Site Work",
|
||||
"sl_outdoor_lighting": "Lighting per Court",
|
||||
"sl_outdoor_fencing": "Fencing",
|
||||
"sl_permits": "Permits & Compliance",
|
||||
"sl_working_capital": "Working Capital",
|
||||
"sl_contingency": "Contingency",
|
||||
"sl_budget_target": "Your Budget Target",
|
||||
# Slider labels — OPEX
|
||||
"sl_rent_sqm": "Rent (\u20ac/m\u00b2/month)",
|
||||
"sl_outdoor_rent": "Monthly Land Rent",
|
||||
"sl_property_tax": "Property Tax / month",
|
||||
"sl_insurance": "Insurance (\u20ac/mo)",
|
||||
"sl_electricity": "Electricity (\u20ac/mo)",
|
||||
"sl_heating": "Heating (\u20ac/mo)",
|
||||
"sl_water": "Water (\u20ac/mo)",
|
||||
"sl_maintenance": "Maintenance (\u20ac/mo)",
|
||||
"sl_cleaning": "Cleaning (\u20ac/mo)",
|
||||
"sl_marketing": "Marketing / Misc (\u20ac/mo)",
|
||||
"sl_staff": "Staff (\u20ac/mo)",
|
||||
# Slider labels — Finance & Exit
|
||||
"sl_loan_pct": "Loan-to-Cost (LTC)",
|
||||
"sl_interest_rate": "Interest Rate",
|
||||
"sl_loan_term": "Loan Term",
|
||||
"sl_construction_months": "Construction Period",
|
||||
"sl_hold_years": "Holding Period",
|
||||
"sl_exit_multiple": "Exit EBITDA Multiple",
|
||||
"sl_annual_rev_growth": "Annual Revenue Growth",
|
||||
# Buttons & actions
|
||||
"btn_save": "Save",
|
||||
"btn_my_scenarios": "My Scenarios",
|
||||
"btn_reset": "Reset to Defaults",
|
||||
"btn_reset_confirm": "Sure? Reset",
|
||||
"btn_back": "\u2190 Back",
|
||||
"btn_next": "Next \u2192",
|
||||
"btn_show_results": "Show Results \u2192",
|
||||
# Prompts & toasts
|
||||
"prompt_scenario_name": "Scenario name:",
|
||||
"prompt_scenario_default": "My Padel Plan",
|
||||
"toast_saved": "Scenario saved!",
|
||||
# renderWith labels
|
||||
"label_indoor": "Indoor",
|
||||
"label_outdoor": "Outdoor",
|
||||
"label_build_buy": "Build/Buy",
|
||||
"label_rent": "Rent",
|
||||
"label_courts": "courts",
|
||||
"label_indoor_hall": "Indoor hall",
|
||||
"label_outdoor_land": "Outdoor land",
|
||||
"label_playing_surface": "Playing surface",
|
||||
# Wizard preview
|
||||
"wiz_capex": "CAPEX",
|
||||
"wiz_monthly_cf": "Monthly CF",
|
||||
"wiz_irr": "IRR",
|
||||
"wiz_mo": "/mo",
|
||||
# Summary card labels
|
||||
"card_total_courts": "Total Courts",
|
||||
"card_floor_area": "Floor Area",
|
||||
"card_court_area": "Court Area",
|
||||
# renderCapex cards
|
||||
"card_total_capex": "Total CAPEX",
|
||||
"card_per_court": "Per Court",
|
||||
"card_per_sqm": "Per m\u00b2",
|
||||
"budget_over": "BUDGET OVER",
|
||||
"budget_under": "BUDGET UNDER",
|
||||
"table_total_capex": "TOTAL CAPEX",
|
||||
"th_item": "Item",
|
||||
"th_amount": "Amount",
|
||||
# renderOperating cards & tables
|
||||
"card_net_rev_mo": "Net Revenue/mo",
|
||||
"card_ebitda_mo": "EBITDA/mo",
|
||||
"card_annual_rev": "Annual Revenue",
|
||||
"card_rev_pah": "RevPAH",
|
||||
"sub_stabilized": "Stabilized",
|
||||
"sub_year3": "Year 3",
|
||||
"stream_court_rental": "Court Rental (net of fees)",
|
||||
"stream_equipment": "Equipment Rental (rackets/balls)",
|
||||
"stream_memberships": "Memberships",
|
||||
"stream_fb": "F&B",
|
||||
"stream_coaching": "Coaching & Events",
|
||||
"stream_retail": "Retail",
|
||||
"table_total_net_rev": "Total Net Revenue",
|
||||
"table_total_opex": "Total Monthly OpEx",
|
||||
"th_stream": "Stream",
|
||||
"th_monthly": "Monthly",
|
||||
"th_share": "Share",
|
||||
"chart_revenue": "Revenue",
|
||||
"chart_opex_debt": "OpEx+Debt",
|
||||
"chart_court_rev": "Court Rev",
|
||||
"chart_fees": "Fees",
|
||||
"chart_ancillary": "Ancillary",
|
||||
"chart_opex": "OpEx",
|
||||
"chart_debt": "Debt",
|
||||
# renderCashflow cards & tables
|
||||
"card_y1_ncf": "Year 1 Net CF",
|
||||
"card_y3_ncf": "Year 3 Net CF",
|
||||
"card_payback": "Payback",
|
||||
"card_initial_inv": "Initial Investment",
|
||||
"payback_not_reached": "Not reached",
|
||||
"th_year": "Year",
|
||||
"th_revenue": "Revenue",
|
||||
"th_ebitda": "EBITDA",
|
||||
"th_debt_service": "Debt Service",
|
||||
"th_net_cf": "Net CF",
|
||||
"th_dscr": "DSCR",
|
||||
"th_util": "Util.",
|
||||
# renderReturns waterfall & tables
|
||||
"card_irr": "IRR",
|
||||
"card_moic": "MOIC",
|
||||
"card_break_even": "Break-Even Util.",
|
||||
"card_cash_on_cash": "Cash-on-Cash",
|
||||
"wf_stab_ebitda": "Stabilized EBITDA (Y3)",
|
||||
"wf_exit_multiple": "\u00d7 Exit Multiple",
|
||||
"wf_enterprise_value": "= Enterprise Value",
|
||||
"wf_remaining_loan": "\u2013 Remaining Loan",
|
||||
"wf_net_exit": "= Net Exit Proceeds",
|
||||
"wf_cum_cf": "+ Cumulative Cash Flow",
|
||||
"wf_total_returns": "= Total Returns",
|
||||
"wf_investment": "\u00f7 Investment",
|
||||
"wf_moic": "= MOIC",
|
||||
"th_utilization": "Utilization",
|
||||
"th_monthly_rev": "Monthly Rev",
|
||||
"th_monthly_ncf": "Monthly NCF",
|
||||
"th_annual_ncf": "Annual NCF",
|
||||
"th_price_change": "Price Change",
|
||||
"th_avg_rate": "Avg Rate",
|
||||
# renderMetrics section headers
|
||||
"metrics_return": "Return Metrics",
|
||||
"metrics_revenue": "Revenue Efficiency",
|
||||
"metrics_cost": "Cost & Margin",
|
||||
"metrics_debt": "Debt & Coverage",
|
||||
"metrics_invest": "Investment Efficiency",
|
||||
"metrics_ops": "Operational",
|
||||
# Months (seasonality chart)
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "Mar",
|
||||
"month_apr": "Apr",
|
||||
"month_may": "May",
|
||||
"month_jun": "Jun",
|
||||
"month_jul": "Jul",
|
||||
"month_aug": "Aug",
|
||||
"month_sep": "Sep",
|
||||
"month_oct": "Oct",
|
||||
"month_nov": "Nov",
|
||||
"month_dec": "Dec",
|
||||
},
|
||||
"de": {
|
||||
# Tabs
|
||||
# ── Planner UI strings ───────────────────────────────────────────────
|
||||
"tab_assumptions": "Annahmen",
|
||||
"tab_capex": "Investition",
|
||||
"tab_operating": "Betriebsmodell",
|
||||
"tab_cashflow": "Cashflow",
|
||||
"tab_returns": "Renditen & Exit",
|
||||
"tab_metrics": "Kennzahlen",
|
||||
# Wizard steps
|
||||
"wiz_venue": "Padel-Platz",
|
||||
"wiz_pricing": "Preise",
|
||||
"wiz_costs": "Kosten",
|
||||
"wiz_finance": "Finanzierung",
|
||||
# Toggle options
|
||||
"toggle_indoor": "Indoor",
|
||||
"toggle_outdoor": "Outdoor",
|
||||
"toggle_rent": "Miete / Pacht",
|
||||
"toggle_buy": "Kauf / Bau",
|
||||
# Pill / select labels
|
||||
"pill_country": "Land",
|
||||
"pill_glass_type": "Glastyp",
|
||||
"pill_lighting_type": "Beleuchtungstyp",
|
||||
@@ -1135,7 +1089,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"pill_light_led_standard": "LED Standard",
|
||||
"pill_light_led_competition": "LED Wettkampf",
|
||||
"pill_light_natural": "Tageslicht",
|
||||
# Country labels
|
||||
"country_de": "Deutschland",
|
||||
"country_es": "Spanien",
|
||||
"country_it": "Italien",
|
||||
@@ -1144,14 +1097,12 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"country_se": "Schweden",
|
||||
"country_uk": "UK",
|
||||
"country_us": "USA",
|
||||
# Slider labels — courts & space
|
||||
"sl_dbl_courts": "Doppelpl\u00e4tze (20\u00d710\u202fm)",
|
||||
"sl_sgl_courts": "Einzelpl\u00e4tze (20\u00d76\u202fm)",
|
||||
"sl_sqm_dbl_hall": "Hallen-m\u00b2 pro Doppelplatz",
|
||||
"sl_sqm_sgl_hall": "Hallen-m\u00b2 pro Einzelplatz",
|
||||
"sl_sqm_dbl_outdoor": "Grundst\u00fcck-m\u00b2 pro Doppelplatz",
|
||||
"sl_sqm_sgl_outdoor": "Grundst\u00fcck-m\u00b2 pro Einzelplatz",
|
||||
# Slider labels — pricing & utilization
|
||||
"sl_rate_peak": "Spitzenstundensatz (\u20ac)",
|
||||
"sl_rate_offpeak": "Nebenstundensatz (\u20ac)",
|
||||
"sl_rate_single": "Einzelplatz-Stundensatz (\u20ac)",
|
||||
@@ -1166,7 +1117,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"sl_fb_rev": "F&B-Einnahmen / Platz",
|
||||
"sl_coaching_rev": "Coaching & Events / Platz",
|
||||
"sl_retail_rev": "Einzelhandel / Platz",
|
||||
# Slider labels — CAPEX
|
||||
"sl_court_cost_dbl": "Platzkosten \u2014 Doppel",
|
||||
"sl_court_cost_sgl": "Platzkosten \u2014 Einzel",
|
||||
"sl_hall_cost_sqm": "Hallenbau (\u20ac/m\u00b2)",
|
||||
@@ -1189,7 +1139,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"sl_working_capital": "Betriebskapital",
|
||||
"sl_contingency": "Reserve",
|
||||
"sl_budget_target": "Dein Budgetziel",
|
||||
# Slider labels — OPEX
|
||||
"sl_rent_sqm": "Miete (\u20ac/m\u00b2/Monat)",
|
||||
"sl_outdoor_rent": "Monatliche Grundst\u00fccksmiete",
|
||||
"sl_property_tax": "Grundsteuer / Monat",
|
||||
@@ -1201,7 +1150,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"sl_cleaning": "Reinigung (\u20ac/Monat)",
|
||||
"sl_marketing": "Marketing / Sonstiges (\u20ac/Monat)",
|
||||
"sl_staff": "Personal (\u20ac/Monat)",
|
||||
# Slider labels — Finance & Exit
|
||||
"sl_loan_pct": "Fremdkapitalquote (LTC)",
|
||||
"sl_interest_rate": "Zinssatz",
|
||||
"sl_loan_term": "Kreditlaufzeit",
|
||||
@@ -1209,7 +1157,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"sl_hold_years": "Haltedauer",
|
||||
"sl_exit_multiple": "Exit-EBITDA-Multiplikator",
|
||||
"sl_annual_rev_growth": "J\u00e4hrliches Umsatzwachstum",
|
||||
# Buttons & actions
|
||||
"btn_save": "Speichern",
|
||||
"btn_my_scenarios": "Meine Szenarien",
|
||||
"btn_reset": "Zur\u00fccksetzen",
|
||||
@@ -1217,11 +1164,9 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"btn_back": "\u2190 Zur\u00fcck",
|
||||
"btn_next": "Weiter \u2192",
|
||||
"btn_show_results": "Ergebnisse anzeigen \u2192",
|
||||
# Prompts & toasts
|
||||
"prompt_scenario_name": "Szenario-Name:",
|
||||
"prompt_scenario_default": "Mein Padel-Plan",
|
||||
"toast_saved": "Szenario gespeichert!",
|
||||
# renderWith labels
|
||||
"label_indoor": "Indoor",
|
||||
"label_outdoor": "Outdoor",
|
||||
"label_build_buy": "Kauf/Bau",
|
||||
@@ -1230,16 +1175,13 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"label_indoor_hall": "Innenhalle",
|
||||
"label_outdoor_land": "Au\u00dfenfl\u00e4che",
|
||||
"label_playing_surface": "Spielfl\u00e4che",
|
||||
# Wizard preview
|
||||
"wiz_capex": "CAPEX",
|
||||
"wiz_monthly_cf": "Monatl. CF",
|
||||
"wiz_irr": "IRR",
|
||||
"wiz_mo": "/Monat",
|
||||
# Summary card labels
|
||||
"card_total_courts": "Pl\u00e4tze gesamt",
|
||||
"card_floor_area": "Grundfl\u00e4che",
|
||||
"card_court_area": "Platzfl\u00e4che",
|
||||
# renderCapex cards
|
||||
"card_total_capex": "Gesamt-CAPEX",
|
||||
"card_per_court": "Pro Platz",
|
||||
"card_per_sqm": "Pro m\u00b2",
|
||||
@@ -1248,7 +1190,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"table_total_capex": "GESAMT-CAPEX",
|
||||
"th_item": "Position",
|
||||
"th_amount": "Betrag",
|
||||
# renderOperating cards & tables
|
||||
"card_net_rev_mo": "Nettoumsatz/Monat",
|
||||
"card_ebitda_mo": "EBITDA/Monat",
|
||||
"card_annual_rev": "Jahresumsatz",
|
||||
@@ -1273,7 +1214,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"chart_ancillary": "Nebeneinnahmen",
|
||||
"chart_opex": "OPEX",
|
||||
"chart_debt": "Schulden",
|
||||
# renderCashflow cards & tables
|
||||
"card_y1_ncf": "Netto-CF Jahr 1",
|
||||
"card_y3_ncf": "Netto-CF Jahr 3",
|
||||
"card_payback": "Amortisation",
|
||||
@@ -1286,7 +1226,6 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"th_net_cf": "Netto-CF",
|
||||
"th_dscr": "DSCR",
|
||||
"th_util": "Auslastung",
|
||||
# renderReturns waterfall & tables
|
||||
"card_irr": "IRR",
|
||||
"card_moic": "MOIC",
|
||||
"card_break_even": "Break-Even-Auslastung",
|
||||
@@ -1306,14 +1245,12 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"th_annual_ncf": "J\u00e4hrl. Netto-CF",
|
||||
"th_price_change": "Preis\u00e4nderung",
|
||||
"th_avg_rate": "Durchschn. Satz",
|
||||
# renderMetrics section headers
|
||||
"metrics_return": "Rendite-Kennzahlen",
|
||||
"metrics_revenue": "Umsatzeffizienz",
|
||||
"metrics_cost": "Kosten & Marge",
|
||||
"metrics_debt": "Schulden & Abdeckung",
|
||||
"metrics_invest": "Investitionseffizienz",
|
||||
"metrics_ops": "Betrieb",
|
||||
# Months (seasonality chart)
|
||||
"month_jan": "Jan",
|
||||
"month_feb": "Feb",
|
||||
"month_mar": "M\u00e4r",
|
||||
@@ -1330,14 +1267,15 @@ _PLANNER_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
}
|
||||
|
||||
|
||||
def get_planner_translations(lang: str) -> dict[str, str]:
|
||||
"""Return planner JS locale strings for the given language.
|
||||
def get_translations(lang: str) -> dict[str, str]:
|
||||
"""Return UI translation strings for the given language.
|
||||
|
||||
Injected as window.__PADELNOMICS_LOCALE__ in planner.html.
|
||||
planner.js reads: const L = window.__PADELNOMICS_LOCALE__ || {};
|
||||
Falls back to English for unsupported languages (should never happen
|
||||
in practice since we 404 unsupported langs in before_request).
|
||||
"""
|
||||
assert lang in _PLANNER_TRANSLATIONS, f"Unknown lang: {lang!r}"
|
||||
return _PLANNER_TRANSLATIONS[lang]
|
||||
assert lang in _TRANSLATIONS, f"Unknown lang: {lang!r}"
|
||||
return _TRANSLATIONS[lang]
|
||||
|
||||
|
||||
|
||||
# ── Calculator item names ────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Planner domain: padel court financial planner + scenario management.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,8 +18,8 @@ from ..core import (
|
||||
get_paddle_price,
|
||||
waitlist_gate,
|
||||
)
|
||||
from ..i18n import get_planner_translations
|
||||
from .calculator import calc, validate_state
|
||||
from ..i18n import get_translations
|
||||
from .calculator import DEFAULTS, calc, validate_state
|
||||
|
||||
bp = Blueprint(
|
||||
"planner",
|
||||
@@ -27,6 +28,17 @@ bp = Blueprint(
|
||||
url_prefix="/planner",
|
||||
)
|
||||
|
||||
# Country presets (mirrors JS COUNTRY_PRESETS)
|
||||
COUNTRY_PRESETS = {
|
||||
"DE": {"permitsCompliance": 12000},
|
||||
"ES": {"permitsCompliance": 25000},
|
||||
"IT": {"permitsCompliance": 18000},
|
||||
"FR": {"permitsCompliance": 15000},
|
||||
"NL": {"permitsCompliance": 10000},
|
||||
"SE": {"permitsCompliance": 8000},
|
||||
"UK": {"permitsCompliance": 10000},
|
||||
"US": {"permitsCompliance": 15000},
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# SQL Queries
|
||||
@@ -54,6 +66,139 @@ async def get_scenarios(user_id: int) -> list[dict]:
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
def form_to_state(form) -> dict:
|
||||
"""Convert Quart ImmutableMultiDict form data to state dict."""
|
||||
data: dict = {}
|
||||
ramp = form.getlist("ramp")
|
||||
if ramp:
|
||||
data["ramp"] = [float(v) for v in ramp]
|
||||
season = form.getlist("season")
|
||||
if season:
|
||||
data["season"] = [float(v) for v in season]
|
||||
for key in form.keys():
|
||||
if key not in ("ramp", "season", "activeTab"):
|
||||
data[key] = form.get(key)
|
||||
return data
|
||||
|
||||
|
||||
def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"""Add display-only derived fields to calc result dict (mutates d in-place)."""
|
||||
t = get_translations(lang)
|
||||
month_keys = ["jan", "feb", "mar", "apr", "may", "jun",
|
||||
"jul", "aug", "sep", "oct", "nov", "dec"]
|
||||
|
||||
d["irr_ok"] = math.isfinite(d.get("irr", 0))
|
||||
|
||||
# Chart data — embedded as JSON in partials, consumed by Chart.js via JS
|
||||
d["capex_chart"] = {
|
||||
"labels": [i["name"] for i in d["capexItems"] if i["amount"] > 0],
|
||||
"data": [i["amount"] for i in d["capexItems"] if i["amount"] > 0],
|
||||
}
|
||||
|
||||
ramp_data = d["months"][:24]
|
||||
d["ramp_chart"] = {
|
||||
"months": [f"M{m['m']}" for m in ramp_data],
|
||||
"revenue": [round(m["totalRev"]) for m in ramp_data],
|
||||
"opex_debt": [round(abs(m["opex"]) + abs(m["loan"])) for m in ramp_data],
|
||||
"label_revenue": t["chart_revenue"],
|
||||
"label_opex_debt": t["chart_opex_debt"],
|
||||
}
|
||||
|
||||
d["pl_chart"] = {
|
||||
"labels": [
|
||||
t["chart_court_rev"], t["chart_fees"], t["chart_ancillary"],
|
||||
t["chart_opex"], t["chart_debt"],
|
||||
],
|
||||
"values": [
|
||||
round(d["courtRevMonth"]),
|
||||
-round(d["feeDeduction"]),
|
||||
round(d["racketRev"] + d["ballMargin"] + d["membershipRev"]
|
||||
+ d["fbRev"] + d["coachingRev"] + d["retailRev"]),
|
||||
-round(d["opex"]),
|
||||
-round(d["monthlyPayment"]),
|
||||
],
|
||||
}
|
||||
|
||||
d["cf_chart"] = {
|
||||
"labels": [f"Y{m['yr']}" if m["m"] % 12 == 1 else "" for m in d["months"]],
|
||||
"values": [round(m["ncf"]) for m in d["months"]],
|
||||
"pos": [m["ncf"] >= 0 for m in d["months"]],
|
||||
}
|
||||
|
||||
d["cum_chart"] = {
|
||||
"labels": [f"M{m['m']}" if m["m"] % 6 == 1 else "" for m in d["months"]],
|
||||
"values": [round(m["cum"]) for m in d["months"]],
|
||||
}
|
||||
|
||||
d["dscr_chart"] = {
|
||||
"labels": [f"Y{x['year']}" for x in d["dscr"]],
|
||||
"values": [min(x["dscr"], 10) for x in d["dscr"]],
|
||||
"pos": [x["dscr"] >= 1.2 for x in d["dscr"]],
|
||||
}
|
||||
|
||||
d["season_chart"] = {
|
||||
"labels": [t[f"month_{k}"] for k in month_keys],
|
||||
"values": [v * 100 for v in s["season"]],
|
||||
"pos": [v > 0 for v in s["season"]],
|
||||
}
|
||||
|
||||
# Sensitivity tables (pre-computed for returns tab)
|
||||
is_in = s["venue"] == "indoor"
|
||||
w_rate = d["weightedRate"]
|
||||
rev_per_hr = (
|
||||
w_rate * (1 - s["bookingFee"] / 100)
|
||||
+ (s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
|
||||
)
|
||||
utils = [15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70]
|
||||
ancillary_per_court = (
|
||||
s["membershipRevPerCourt"] + s["fbRevPerCourt"]
|
||||
+ s["coachingRevPerCourt"] + s["retailRevPerCourt"]
|
||||
)
|
||||
sens_rows = []
|
||||
for u in utils:
|
||||
booked = d["availHoursMonth"] * (u / 100)
|
||||
rev = booked * rev_per_hr + d["totalCourts"] * ancillary_per_court * (u / max(s["utilTarget"], 1))
|
||||
ncf = rev - d["opex"] - d["monthlyPayment"]
|
||||
annual = ncf * (12 if is_in else 6)
|
||||
ebitda = rev - d["opex"]
|
||||
dscr = (ebitda * (12 if is_in else 6)) / d["annualDebtService"] if d["annualDebtService"] > 0 else 999
|
||||
sens_rows.append({
|
||||
"util": u,
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"annual": round(annual),
|
||||
"dscr": min(dscr, 99),
|
||||
"is_target": u == s["utilTarget"],
|
||||
})
|
||||
d["sens_rows"] = sens_rows
|
||||
|
||||
prices = [-20, -10, -5, 0, 5, 10, 15, 20]
|
||||
price_rows = []
|
||||
for delta in prices:
|
||||
adj_rate = w_rate * (1 + delta / 100)
|
||||
booked = d["bookedHoursMonth"]
|
||||
rev = (
|
||||
booked * adj_rate * (1 - s["bookingFee"] / 100)
|
||||
+ booked * ((s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"]))
|
||||
+ d["totalCourts"] * ancillary_per_court
|
||||
)
|
||||
ncf = rev - d["opex"] - d["monthlyPayment"]
|
||||
price_rows.append({
|
||||
"delta": delta,
|
||||
"adj_rate": round(adj_rate),
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"is_base": delta == 0,
|
||||
})
|
||||
d["price_rows"] = price_rows
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
@@ -66,26 +211,40 @@ async def index():
|
||||
scenario_count = await count_scenarios(g.user["id"])
|
||||
default = await get_default_scenario(g.user["id"])
|
||||
initial_state = json.loads(default["state_json"]) if default else {}
|
||||
state = validate_state(initial_state)
|
||||
s = validate_state(initial_state)
|
||||
lang = g.get("lang", "en")
|
||||
initial_d = calc(state, lang=lang)
|
||||
d = calc(s, lang=lang)
|
||||
augment_d(d, s, lang)
|
||||
return await render_template(
|
||||
"planner.html",
|
||||
initial_state=default["state_json"] if default else None,
|
||||
initial_d=json.dumps(initial_d),
|
||||
s=s,
|
||||
d=d,
|
||||
scenario_count=scenario_count,
|
||||
planner_t=get_planner_translations(lang),
|
||||
lang=lang,
|
||||
active_tab="capex",
|
||||
country_presets=COUNTRY_PRESETS,
|
||||
defaults=DEFAULTS,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/calculate", methods=["POST"])
|
||||
async def calculate():
|
||||
data = await request.get_json()
|
||||
state = validate_state(data.get("state", {}))
|
||||
"""HTMX endpoint: form data → HTML tab partial + OOB swaps."""
|
||||
form = await request.form
|
||||
s = validate_state(form_to_state(form))
|
||||
lang = g.get("lang", "en")
|
||||
d = calc(state, lang=lang)
|
||||
return jsonify(d)
|
||||
d = calc(s, lang=lang)
|
||||
augment_d(d, s, lang)
|
||||
active_tab = form.get("activeTab", "capex")
|
||||
if active_tab not in {"capex", "operating", "cashflow", "returns", "metrics"}:
|
||||
active_tab = "capex"
|
||||
return await render_template(
|
||||
"partials/calculate_response.html",
|
||||
s=s,
|
||||
d=d,
|
||||
active_tab=active_tab,
|
||||
lang=lang,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/scenarios", methods=["GET"])
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{% set tab_template = "partials/tab_" ~ active_tab ~ ".html" %}
|
||||
{% include tab_template %}
|
||||
|
||||
<span id="headerTag" hx-swap-oob="true">{{ s.venue == 'indoor' and t.label_indoor or t.label_outdoor }} · {{ s.own == 'buy' and t.label_build_buy or t.label_rent }} · {{ d.totalCourts }} {{ t.label_courts }} · {{ d.capex | fmt_k }}</span>
|
||||
|
||||
<div id="courtSummary" hx-swap-oob="true">{% include "partials/court_summary.html" %}</div>
|
||||
|
||||
<div id="wizPreview" hx-swap-oob="true">{% include "partials/wizard_preview.html" %}</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
{% set court_play_sqm = s.dblCourts * 200 + s.sglCourts * 120 %}
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">{{ t.card_total_courts }}</div>
|
||||
<div class="metric-card__value c-head">{{ d.totalCourts }}</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">{{ t.card_floor_area }}</div>
|
||||
<div class="metric-card__value c-head">{{ d.sqm | fmt_n }} m²</div>
|
||||
<div class="metric-card__sub">{{ s.venue == 'indoor' and t.label_indoor_hall or t.label_outdoor_land }}</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">{{ t.card_court_area }}</div>
|
||||
<div class="metric-card__value c-head">{{ court_play_sqm | fmt_n }} m²</div>
|
||||
<div class="metric-card__sub">{{ t.label_playing_surface }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="grid-3 mb-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_total_capex }}</div>
|
||||
<div class="metric-card__value c-red">{{ d.capex | fmt_currency }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_per_court }}</div>
|
||||
<div class="metric-card__value c-head">{{ d.capexPerCourt | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">{{ d.totalCourts }} {{ t.label_courts }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_per_sqm }}</div>
|
||||
<div class="metric-card__value c-head">{{ d.capexPerSqm | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">{{ d.sqm | fmt_n }} m²</div>
|
||||
</div>
|
||||
{% if d.budgetTarget > 0 %}
|
||||
<div class="budget-indicator budget-indicator--{{ 'over' if d.budgetVariance > 0 else 'under' }}">
|
||||
<div class="metric-card__label">{{ t.budget_over if d.budgetVariance > 0 else t.budget_under }}</div>
|
||||
<div class="metric-card__value {{ 'c-red' if d.budgetVariance > 0 else 'c-green' }}">{{ ('+' if d.budgetVariance > 0 else '') }}{{ d.budgetVariance | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">{{ d.budgetPct | int }}% of {{ d.budgetTarget | fmt_k }} budget</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>{{ t.th_item }}</th><th class="right">{{ t.th_amount }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in d.capexItems %}
|
||||
<tr>
|
||||
<td>{{ item.name }}{% if item.info %} <span style="color:var(--txt-3);font-size:10px">({{ item.info }})</span>{% endif %}</td>
|
||||
<td class="mono">{{ item.amount | fmt_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td>{{ t.table_total_capex }}</td>
|
||||
<td class="mono">{{ d.capex | fmt_currency }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="chart-container mt-4">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}CAPEX-Aufschlüsselung{% else %}CAPEX Breakdown{% endif %}</div>
|
||||
<div class="chart-h-56 chart-container__canvas">
|
||||
<canvas id="chartCapex"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="chartCapex-data">{{ d.capex_chart | tojson }}</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
{% set y1ncf = d.annuals[0].ncf if d.annuals else 0 %}
|
||||
{% set y3ncf = d.annuals[2].ncf if d.annuals | length >= 3 else 0 %}
|
||||
{% set payback_label = t.payback_not_reached if d.paybackIdx < 0 else ('Month ' ~ (d.paybackIdx + 1)) %}
|
||||
{% set payback_sub = '' if d.paybackIdx < 0 else ('~' ~ ((d.paybackIdx + 1) / 12) | round(1) ~ ' years') %}
|
||||
|
||||
<div class="grid-4 mb-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_y1_ncf }}</div>
|
||||
<div class="metric-card__value {{ 'c-green' if y1ncf >= 0 else 'c-red' }}">{{ y1ncf | int | fmt_currency }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_y3_ncf }}</div>
|
||||
<div class="metric-card__value {{ 'c-green' if y3ncf >= 0 else 'c-red' }}">{{ y3ncf | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">{{ t.sub_stabilized }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_payback }}</div>
|
||||
<div class="metric-card__value c-head">{{ payback_label }}</div>
|
||||
<div class="metric-card__sub">{{ payback_sub }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_initial_inv }}</div>
|
||||
<div class="metric-card__value c-red">{{ d.capex | fmt_currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container mb-4">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Monatlicher Netto-Cashflow (60 Monate){% else %}Monthly Net Cash Flow (60 Months){% endif %}</div>
|
||||
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCF"></canvas></div>
|
||||
</div>
|
||||
<script type="application/json" id="chartCF-data">{{ d.cf_chart | tojson }}</script>
|
||||
|
||||
<div class="chart-container mb-4">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Kumulierter Cashflow{% else %}Cumulative Cash Flow{% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartCum"></canvas></div>
|
||||
</div>
|
||||
<script type="application/json" id="chartCum-data">{{ d.cum_chart | tojson }}</script>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Jahresübersicht{% else %}Annual Summary{% endif %}</h3></div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t.th_year }}</th>
|
||||
<th class="right">{{ t.th_revenue }}</th>
|
||||
<th class="right">{{ t.th_ebitda }}</th>
|
||||
<th class="right">{{ t.th_debt_service }}</th>
|
||||
<th class="right">{{ t.th_net_cf }}</th>
|
||||
<th class="right">{{ t.th_dscr }}</th>
|
||||
<th class="right">{{ t.th_util }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for y in d.annuals %}
|
||||
{% set dscr = (y.ebitda / y.ds) if y.ds > 0 else 999 %}
|
||||
{% set util = ((y.booked / y.avail * 100) | int) if y.avail > 0 else 0 %}
|
||||
<tr>
|
||||
<td><b>Year {{ y.year }}</b></td>
|
||||
<td class="mono c-green">{{ y.revenue | int | fmt_currency }}</td>
|
||||
<td class="mono {{ 'c-green' if y.ebitda >= 0 else 'c-red' }}">{{ y.ebitda | int | fmt_currency }}</td>
|
||||
<td class="mono c-red">{{ y.ds | int | fmt_currency }}</td>
|
||||
<td class="mono" style="font-weight:700;{{ 'color:var(--gn)' if y.ncf >= 0 else 'color:var(--rd)' }}">{{ y.ncf | int | fmt_currency }}</td>
|
||||
<td class="mono">{{ '∞' if dscr > 99 else dscr | fmt_x }}</td>
|
||||
<td class="mono">{{ util }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,159 @@
|
||||
{% set y3_rev = d.annuals[2].revenue if d.annuals | length >= 3 else 0 %}
|
||||
{% set y3_dscr = d.dscr[2].dscr if d.dscr | length >= 3 else 0 %}
|
||||
{% set is_in = s.venue == 'indoor' %}
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_return }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">IRR</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
||||
<div class="metric-card__sub">{{ s.holdYears }}-year</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">MOIC</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
||||
<div class="metric-card__sub">Total return multiple</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Cash-on-Cash</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Y3 NCF ÷ Equity</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Payback</div>
|
||||
<div class="metric-card__value c-head">{{ ((d.paybackIdx + 1) / 12) | round(1) ~ ' yr' if d.paybackIdx >= 0 else 'N/A' }}</div>
|
||||
<div class="metric-card__sub">Months: {{ d.paybackIdx + 1 if d.paybackIdx >= 0 else '∞' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_revenue }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">RevPAH</div>
|
||||
<div class="metric-card__value c-blue">{{ d.revPAH | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">Revenue per Available Hour</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Revenue / m²</div>
|
||||
<div class="metric-card__value c-blue">{{ d.revPerSqm | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">Annual net revenue ÷ area</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Revenue / Court</div>
|
||||
<div class="metric-card__value c-head">{{ (y3_rev / [1, d.totalCourts] | max) | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">Year 3 annual</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Avg Booked Rate</div>
|
||||
<div class="metric-card__value c-head">{{ d.weightedRate | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">Blended peak/off-peak</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_cost }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">EBITDA Margin</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.ebitdaMargin > 0.3 else 'c-amber' }}">{{ d.ebitdaMargin | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Operating profit margin</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">OpEx Ratio</div>
|
||||
<div class="metric-card__value c-head">{{ d.opexRatio | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">OpEx ÷ Revenue</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Occupancy Cost</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.rentRatio < 0.3 else 'c-red' }}">{{ d.rentRatio | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Rent ÷ Revenue</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Cost / Booked Hour</div>
|
||||
<div class="metric-card__value c-head">{{ d.costPerBookedHr | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">All-in cost per hour sold</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_debt }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">DSCR (Y3)</div>
|
||||
<div class="metric-card__value {{ 'c-green' if y3_dscr >= 1.2 else 'c-red' }}">{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}</div>
|
||||
<div class="metric-card__sub">Min 1.2x for banks</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">LTV</div>
|
||||
<div class="metric-card__value c-head">{{ d.ltv | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Loan ÷ Total Investment</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Debt Yield</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.debtYield > 0.1 else 'c-amber' }}">{{ d.debtYield | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Stab. EBITDA ÷ Loan</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Monthly Debt Service</div>
|
||||
<div class="metric-card__value c-red">{{ d.monthlyPayment | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">P&I payment</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_invest }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">CAPEX / Court</div>
|
||||
<div class="metric-card__value c-head">{{ d.capexPerCourt | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">Total investment per court</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">CAPEX / m²</div>
|
||||
<div class="metric-card__value c-head">{{ d.capexPerSqm | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">Investment per floor area</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Yield on Cost</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.yieldOnCost > 0.08 else 'c-amber' }}">{{ d.yieldOnCost | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Stab. EBITDA ÷ CAPEX</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Exit Value</div>
|
||||
<div class="metric-card__value c-head">{{ d.exitValue | fmt_k }}</div>
|
||||
<div class="metric-card__sub">{{ s.exitMultiple }}x Y3 EBITDA</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_ops }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Break-Even Util.</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Y3 Utilization</div>
|
||||
<div class="metric-card__value c-head">{{ d.avgUtil | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Effective avg utilization</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Available Hours/mo</div>
|
||||
<div class="metric-card__value c-head">{{ d.availHoursMonth | fmt_n }}</div>
|
||||
<div class="metric-card__sub">All courts combined</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Operating Months</div>
|
||||
<div class="metric-card__value c-head">{{ '12' if is_in else '~' ~ s.season | selectattr('>', 0) | list | length }}</div>
|
||||
<div class="metric-card__sub">{{ 'Year-round' if is_in else 'Seasonal' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
{% set margin = (d.ebitdaMonth / d.netRevMonth * 100) | round(1) if d.netRevMonth > 0 else 0 %}
|
||||
{% set y3_rev = d.annuals[2].revenue if d.annuals | length >= 3 else 0 %}
|
||||
<div class="grid-4 mb-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_net_rev_mo }}</div>
|
||||
<div class="metric-card__value c-green">{{ d.netRevMonth | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">{{ t.sub_stabilized }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_ebitda_mo }}</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.ebitdaMonth >= 0 else 'c-red' }}">{{ d.ebitdaMonth | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">{{ margin }}% margin</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_annual_rev }}</div>
|
||||
<div class="metric-card__value c-head">{{ y3_rev | int | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">{{ t.sub_year3 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_rev_pah }}</div>
|
||||
<div class="metric-card__value c-blue">{{ d.revPAH | fmt_currency }}</div>
|
||||
<div class="metric-card__sub">Revenue per available hour</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Monatlicher Umsatzaufbau (Anlaufphase){% else %}Monthly Revenue Build-Up (Ramp Period){% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartRevRamp"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Stabilisierte monatliche GuV{% else %}Stabilized Monthly P&L{% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartPL"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="chartRevRamp-data">{{ d.ramp_chart | tojson }}</script>
|
||||
<script type="application/json" id="chartPL-data">{{ d.pl_chart | tojson }}</script>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Einnahmequellen (stabilisierter Monat){% else %}Revenue Streams (Stabilized Month){% endif %}</h3></div>
|
||||
{% set streams = [
|
||||
(t.stream_court_rental, d.courtRevMonth - d.feeDeduction),
|
||||
(t.stream_equipment, d.racketRev + d.ballMargin),
|
||||
(t.stream_memberships, d.membershipRev),
|
||||
(t.stream_fb, d.fbRev),
|
||||
(t.stream_coaching, d.coachingRev),
|
||||
(t.stream_retail, d.retailRev),
|
||||
] %}
|
||||
{% set total_stream = namespace(v=0) %}
|
||||
{% for name, val in streams %}{% set total_stream.v = total_stream.v + val %}{% endfor %}
|
||||
<table class="data-table">
|
||||
<thead><tr><th>{{ t.th_stream }}</th><th class="right">{{ t.th_monthly }}</th><th class="right">{{ t.th_share }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for name, val in streams %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td class="mono">{{ val | int | fmt_currency }}</td>
|
||||
<td class="mono">{{ ((val / total_stream.v * 100) | int if total_stream.v > 0 else 0) }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td>{{ t.table_total_net_rev }}</td>
|
||||
<td class="mono">{{ total_stream.v | int | fmt_currency }}</td>
|
||||
<td class="mono">100%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Monatliche OPEX-Aufschlüsselung{% else %}Monthly OpEx Breakdown{% endif %}</h3></div>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>{{ t.th_item }}</th><th class="right">{{ t.th_monthly }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in d.opexItems %}
|
||||
<tr>
|
||||
<td>{{ item.name }}{% if item.info %} <span style="color:var(--txt-3);font-size:10px">({{ item.info }})</span>{% endif %}</td>
|
||||
<td class="mono">{{ item.amount | fmt_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td>{{ t.table_total_opex }}</td>
|
||||
<td class="mono">{{ d.opex | fmt_currency }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if s.venue == 'outdoor' %}
|
||||
<div class="mb-section season-section visible">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Outdoor-Saisonalität{% else %}Outdoor Seasonality{% endif %}</h3></div>
|
||||
<div class="chart-container"><div class="chart-h-40 chart-container__canvas"><canvas id="chartSeason"></canvas></div></div>
|
||||
</div>
|
||||
<script type="application/json" id="chartSeason-data">{{ d.season_chart | tojson }}</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,102 @@
|
||||
<div class="grid-4 mb-4">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_irr }}</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
||||
<div class="metric-card__sub">{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_moic }}</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
||||
<div class="metric-card__sub">{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_break_even }}</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">{{ t.card_cash_on_cash }}</div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Year 3 NCF ÷ Equity</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label" style="font-size:10px">{% if lang == 'de' %}Exit-Bewertungs-Wasserfall{% else %}Exit Valuation Waterfall{% endif %}</div>
|
||||
<div id="exitWaterfall" style="margin-top:10px">
|
||||
{% set wf_rows = [
|
||||
(t.wf_stab_ebitda, d.stabEbitda | int | fmt_currency, 'c-head'),
|
||||
(t.wf_exit_multiple, s.exitMultiple ~ 'x', 'c-head'),
|
||||
(t.wf_enterprise_value, d.exitValue | int | fmt_currency, 'c-blue'),
|
||||
(t.wf_remaining_loan, d.remainingLoan | int | fmt_currency, 'c-red'),
|
||||
(t.wf_net_exit, d.netExit | int | fmt_currency, 'c-green' if d.netExit > 0 else 'c-red'),
|
||||
(t.wf_cum_cf, (d.totalReturned - d.netExit) | int | fmt_currency, 'c-head'),
|
||||
(t.wf_total_returns, d.totalReturned | int | fmt_currency, 'c-green' if d.totalReturned > 0 else 'c-red'),
|
||||
(t.wf_investment, d.capex | fmt_currency, 'c-head'),
|
||||
(t.wf_moic, d.moic | fmt_x, 'c-green' if d.moic > 2 else 'c-red'),
|
||||
] %}
|
||||
{% for label, value, cls in wf_rows %}
|
||||
<div class="waterfall-row">
|
||||
<span class="waterfall-row__label">{{ label }}</span>
|
||||
<span class="waterfall-row__value {{ cls }}">{{ value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}DSCR nach Jahr{% else %}DSCR by Year{% endif %}</div>
|
||||
<div class="chart-h-44 chart-container__canvas"><canvas id="chartDSCR"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="chartDSCR-data">{{ d.dscr_chart | tojson }}</script>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Auslastungs-Sensitivität{% else %}Utilization Sensitivity{% endif %}</h3></div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t.th_utilization }}</th>
|
||||
<th class="right">{{ t.th_monthly_rev }}</th>
|
||||
<th class="right">{{ t.th_monthly_ncf }}</th>
|
||||
<th class="right">{{ t.th_annual_ncf }}</th>
|
||||
<th class="right">{{ t.th_dscr }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in d.sens_rows %}
|
||||
<tr{% if row.is_target %} style="background:var(--rd-bg)"{% endif %}>
|
||||
<td>{% if row.is_target %}<b>→ {% endif %}{{ row.util }}%{% if row.is_target %} ←</b>{% endif %}</td>
|
||||
<td class="mono">{{ row.rev | fmt_currency }}</td>
|
||||
<td class="mono {{ 'c-green' if row.ncf >= 0 else 'c-red' }}">{{ row.ncf | fmt_currency }}</td>
|
||||
<td class="mono {{ 'c-green' if row.annual >= 0 else 'c-red' }}">{{ row.annual | fmt_currency }}</td>
|
||||
<td class="mono">{{ '∞' if row.dscr > 99 else row.dscr | fmt_x }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Preis-Sensitivität (bei Ziel-Auslastung){% else %}Pricing Sensitivity (at target utilization){% endif %}</h3></div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t.th_price_change }}</th>
|
||||
<th class="right">{{ t.th_avg_rate }}</th>
|
||||
<th class="right">{{ t.th_monthly_rev }}</th>
|
||||
<th class="right">{{ t.th_monthly_ncf }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in d.price_rows %}
|
||||
<tr{% if row.is_base %} style="background:var(--rd-bg)"{% endif %}>
|
||||
<td>{% if row.is_base %}<b>→ {% endif %}{{ '+' if row.delta >= 0 else '' }}{{ row.delta }}%{% if row.is_base %} (base)</b>{% endif %}</td>
|
||||
<td class="mono">{{ row.adj_rate | fmt_currency }}</td>
|
||||
<td class="mono">{{ row.rev | fmt_currency }}</td>
|
||||
<td class="mono {{ 'c-green' if row.ncf >= 0 else 'c-red' }}">{{ row.ncf | fmt_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
{% set cf = d.ebitdaMonth - d.monthlyPayment %}
|
||||
<div class="wiz-preview__item">
|
||||
<div class="wiz-preview__label">{{ t.wiz_capex }}</div>
|
||||
<div class="wiz-preview__value">{{ d.capex | fmt_k }}</div>
|
||||
</div>
|
||||
<div class="wiz-preview__item">
|
||||
<div class="wiz-preview__label">{{ t.wiz_monthly_cf }}</div>
|
||||
<div class="wiz-preview__value {{ 'c-green' if cf >= 0 else 'c-red' }}">{{ cf | fmt_k }}{{ t.wiz_mo }}</div>
|
||||
</div>
|
||||
<div class="wiz-preview__item">
|
||||
<div class="wiz-preview__label">{{ t.wiz_irr }} ({{ s.holdYears }}yr)</div>
|
||||
<div class="wiz-preview__value">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
||||
</div>
|
||||
@@ -17,11 +17,36 @@
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% macro slider(name, label, min, max, step, value, tip='') %}
|
||||
<div class="slider-group">
|
||||
<label>
|
||||
<span class="slider-group__label">{{ label }}</span>{% if tip %}<span class="ti">i<span class="tp">{{ tip }}</span></span>{% endif %}
|
||||
</label>
|
||||
<div class="slider-combo">
|
||||
<input type="range" name="{{ name }}" min="{{ min }}" max="{{ max }}" step="{{ step }}" value="{{ value }}"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-trigger="input changed delay:200ms"
|
||||
hx-target="#tab-content"
|
||||
hx-include="#planner-form">
|
||||
<input type="number" value="{{ value }}" step="{{ step }}" data-sync="{{ name }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro pill_btn(key, val, label, active, extra_js='') %}
|
||||
<button type="button" class="pill-btn {{ 'pill-btn--active' if active }}"
|
||||
data-toggle="{{ key }}" data-val="{{ val }}"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content"
|
||||
hx-include="#planner-form"
|
||||
onclick="setToggle(this,'{{ key }}','{{ val }}'){{ (';' ~ extra_js) if extra_js else '' }}">{{ label }}</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="planner-app">
|
||||
<header class="planner-header">
|
||||
<h1>{% if lang == 'de' %}Padel-Platz Finanzrechner{% else %}Padel Court Financial Planner{% endif %}</h1>
|
||||
<span id="headerTag" class="planner-summary"></span>
|
||||
<span id="headerTag" class="planner-summary">{{ s.venue == 'indoor' and t.label_indoor or t.label_outdoor }} · {{ s.own == 'buy' and t.label_build_buy or t.label_rent }} · {{ d.totalCourts }} {{ t.label_courts }} · {{ d.capex | fmt_k }}</span>
|
||||
|
||||
{% if user %}
|
||||
<div class="scenario-controls">
|
||||
@@ -29,61 +54,137 @@
|
||||
hx-get="{{ url_for('planner.scenario_list') }}"
|
||||
hx-target="#scenario-drawer"
|
||||
hx-swap="innerHTML">
|
||||
{{ planner_t.btn_my_scenarios }} ({{ scenario_count }})
|
||||
{{ t.btn_my_scenarios }} ({{ scenario_count }})
|
||||
</button>
|
||||
<button id="saveScenarioBtn">{{ planner_t.btn_save }}</button>
|
||||
<button id="saveScenarioBtn">{{ t.btn_save }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<nav id="nav" class="tab-nav"></nav>
|
||||
<nav id="nav" class="tab-nav">
|
||||
<button class="tab-btn tab-btn--active" data-tab="assumptions" onclick="setActiveTab('assumptions')">{{ t.tab_assumptions }}</button>
|
||||
{% for tab_id, tab_key in [('capex','tab_capex'),('operating','tab_operating'),('cashflow','tab_cashflow'),('returns','tab_returns'),('metrics','tab_metrics')] %}
|
||||
<button class="tab-btn" data-tab="{{ tab_id }}"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content"
|
||||
hx-include="#planner-form"
|
||||
onclick="setActiveTab('{{ tab_id }}')">{{ t[tab_key] }}</button>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<main class="planner-main">
|
||||
<!-- ASSUMPTIONS (Wizard) -->
|
||||
<div class="tab" id="tab-assumptions">
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
FORM: wraps wizard + all state inputs. hx-include references this.
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<form id="planner-form">
|
||||
<!-- State: active tab -->
|
||||
<input type="hidden" name="activeTab" id="h-activeTab" value="{{ active_tab }}">
|
||||
|
||||
<!-- State: toggles (managed by setToggle JS) -->
|
||||
<input type="hidden" name="venue" id="h-venue" value="{{ s.venue }}">
|
||||
<input type="hidden" name="own" id="h-own" value="{{ s.own }}">
|
||||
<input type="hidden" name="country" id="h-country" value="{{ s.country }}">
|
||||
<input type="hidden" name="glassType" id="h-glassType" value="{{ s.glassType }}">
|
||||
<input type="hidden" name="lightingType" id="h-lightingType" value="{{ s.lightingType }}">
|
||||
|
||||
<!-- State: ramp and season arrays -->
|
||||
{% for val in s.ramp %}<input type="hidden" name="ramp" value="{{ val }}">{% endfor %}
|
||||
{% for val in s.season %}<input type="hidden" name="season" value="{{ val }}">{% endfor %}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
WIZARD (assumptions tab)
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div id="planner-wizard">
|
||||
<div class="wizard-header" id="wizardHeader">
|
||||
<div class="wizard-dots" id="wizardDots"></div>
|
||||
<button id="resetDefaultsBtn" class="btn-reset" title="Reset all assumptions to defaults">{{ planner_t.btn_reset }}</button>
|
||||
<div class="wizard-dots" id="wizardDots">
|
||||
{% set steps = [('wiz_venue',1),('wiz_pricing',2),('wiz_costs',3),('wiz_finance',4)] %}
|
||||
{% for key, n in steps %}
|
||||
<button type="button" class="wiz-dot {{ 'wiz-dot--active' if n == 1 }}" data-wiz="{{ n }}" onclick="showWizStep({{ n }})">
|
||||
<span class="wiz-dot__num">{{ n }}</span>{{ t[key] }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" id="resetDefaultsBtn" class="btn-reset">{{ t.btn_reset }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Your Venue -->
|
||||
<!-- Step 1: Venue -->
|
||||
<div class="wizard-step active" data-wiz="1">
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Dein Padel-Platz</h2>
|
||||
<p class="wizard-step__sub">Definiere den Typ des Padel-Platzes, den du planst.</p>
|
||||
<div class="mb-section">
|
||||
<label class="slider-group__label">Umgebung</label>
|
||||
<div class="toggle-group" id="tog-venue"></div>
|
||||
<label class="slider-group__label">Eigentumsmodell</label>
|
||||
<div class="toggle-group" id="tog-own"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Your Venue</h2>
|
||||
<p class="wizard-step__sub">Define the type of facility you're planning to build.</p>
|
||||
<div class="mb-section">
|
||||
<label class="slider-group__label">Environment</label>
|
||||
<div class="toggle-group" id="tog-venue"></div>
|
||||
<label class="slider-group__label">Ownership Model</label>
|
||||
<div class="toggle-group" id="tog-own"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-section">
|
||||
<div id="inp-country"></div>
|
||||
<label class="slider-group__label">{% if lang == 'de' %}Umgebung{% else %}Environment{% endif %}</label>
|
||||
<div class="toggle-group">
|
||||
<button type="button" class="toggle-btn {{ 'toggle-btn--active' if s.venue == 'indoor' }}"
|
||||
data-toggle="venue" data-val="indoor"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setToggle(this,'venue','indoor')">{{ t.toggle_indoor }}</button>
|
||||
<button type="button" class="toggle-btn {{ 'toggle-btn--active' if s.venue == 'outdoor' }}"
|
||||
data-toggle="venue" data-val="outdoor"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setToggle(this,'venue','outdoor')">{{ t.toggle_outdoor }}</button>
|
||||
</div>
|
||||
<label class="slider-group__label">{% if lang == 'de' %}Eigentumsmodell{% else %}Ownership Model{% endif %}</label>
|
||||
<div class="toggle-group">
|
||||
<button type="button" class="toggle-btn {{ 'toggle-btn--active' if s.own == 'rent' }}"
|
||||
data-toggle="own" data-val="rent"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setToggle(this,'own','rent')">{{ t.toggle_rent }}</button>
|
||||
<button type="button" class="toggle-btn {{ 'toggle-btn--active' if s.own == 'buy' }}"
|
||||
data-toggle="own" data-val="buy"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setToggle(this,'own','buy')">{{ t.toggle_buy }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="pill-group">
|
||||
<label><span class="slider-group__label">{{ t.pill_country }}</span></label>
|
||||
<div class="pill-options">
|
||||
{% for code, lkey in [('DE','country_de'),('ES','country_es'),('IT','country_it'),('FR','country_fr'),('NL','country_nl'),('SE','country_se'),('UK','country_uk'),('US','country_us')] %}
|
||||
<button type="button" class="pill-btn {{ 'pill-btn--active' if s.country == code }}"
|
||||
data-toggle="country" data-val="{{ code }}"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setToggle(this,'country','{{ code }}');setCountryPreset('{{ code }}')">{{ t[lkey] }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, 'Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country — feel free to override.') }}
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}
|
||||
<div class="section-header"><h3>Platzkonfiguration</h3></div>
|
||||
{% else %}
|
||||
<div class="section-header"><h3>Court Configuration</h3></div>
|
||||
{% endif %}
|
||||
<div id="inp-courts"></div>
|
||||
{{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, 'Standard padel court for 4 players. Most common format with highest recreational demand.') }}
|
||||
{{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, 'Narrow court for 2 players. Popular for coaching, training, and competitive play.') }}
|
||||
|
||||
{% if lang == 'de' %}
|
||||
<div class="section-header" style="margin-top:1rem"><h3>Platzbedarf</h3></div>
|
||||
{% else %}
|
||||
<div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div>
|
||||
{% endif %}
|
||||
<div id="inp-space"></div>
|
||||
<div class="court-summary" id="courtSummary"></div>
|
||||
<div data-show-venue="indoor">
|
||||
{{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, 'Total hall space needed per double court. Includes court (200m²), safety zones, circulation, and minimum clearances. Standard: 300–350m².') }}
|
||||
{{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, 'Total hall space needed per single court. Includes court (120m²), safety zones, and access. Standard: 200–250m².') }}
|
||||
</div>
|
||||
<div data-show-venue="outdoor">
|
||||
{{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, 'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280–320m².') }}
|
||||
{{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, 'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180–220m².') }}
|
||||
</div>
|
||||
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,26 +193,40 @@
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Preise & Auslastung</h2>
|
||||
<p class="wizard-step__sub">Lege Deine Platztarife, Betriebszeiten und Nebeneinnahmen fest.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Preise</h3><span class="hint">Pro Platz und Stunde</span></div>
|
||||
<div id="inp-pricing"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Auslastung & Betrieb</h3></div>
|
||||
<div id="inp-util"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Pricing & Utilization</h2>
|
||||
<p class="wizard-step__sub">Set your court rates, operating schedule, and ancillary revenue streams.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Pricing</h3><span class="hint">Per court per hour</span></div>
|
||||
<div id="inp-pricing"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Utilization & Operations</h3></div>
|
||||
<div id="inp-util"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}
|
||||
<div class="section-header"><h3>Preise</h3><span class="hint">Pro Platz und Stunde</span></div>
|
||||
{% else %}
|
||||
<div class="section-header"><h3>Pricing</h3><span class="hint">Per court per hour</span></div>
|
||||
{% endif %}
|
||||
{{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, 'Price per court per hour during peak times (evenings 17:00–22:00 and weekends). Highest demand period.') }}
|
||||
{{ slider('rateOffPeak', t.sl_rate_offpeak, 0, 150, 1, s.rateOffPeak, 'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30–40% lower than peak.') }}
|
||||
{{ slider('rateSingle', t.sl_rate_single, 0, 150, 1, s.rateSingle, 'Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.') }}
|
||||
{{ slider('peakPct', t.sl_peak_pct, 0, 100, 1, s.peakPct, 'Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.') }}
|
||||
{{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, 'Commission taken by booking platforms like Playtomic or Matchi. Typically 5–15% of court revenue.') }}
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}
|
||||
<div class="section-header"><h3>Auslastung & Betrieb</h3></div>
|
||||
{% else %}
|
||||
<div class="section-header"><h3>Utilization & Operations</h3></div>
|
||||
{% endif %}
|
||||
{{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, 'Percentage of available court-hours that are actually booked. 35–45% is realistic for new venues, 50%+ is strong.') }}
|
||||
{{ slider('hoursPerDay', t.sl_hours_per_day, 0, 24, 1, s.hoursPerDay, 'Total operating hours per day. Typical padel venues run 7:00–23:00 (16h). Some extend to 6:00–24:00.') }}
|
||||
{{ slider('daysPerMonthIndoor', t.sl_days_indoor, 0, 31, 1, s.daysPerMonthIndoor, 'Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.') }}
|
||||
{{ slider('daysPerMonthOutdoor', t.sl_days_outdoor, 0, 31, 1, s.daysPerMonthOutdoor, 'Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.') }}
|
||||
<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>{{ t.sl_ancillary_header }}</b></div>
|
||||
{{ slider('membershipRevPerCourt', t.sl_membership_rev, 0, 2000, 50, s.membershipRevPerCourt, 'Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.') }}
|
||||
{{ slider('fbRevPerCourt', t.sl_fb_rev, 0, 2000, 25, s.fbRevPerCourt, 'Food & Beverage revenue per court per month. Income from bar, café, restaurant, or vending machines at the venue.') }}
|
||||
{{ slider('coachingRevPerCourt', t.sl_coaching_rev, 0, 2000, 25, s.coachingRevPerCourt, 'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.') }}
|
||||
{{ slider('retailRevPerCourt', t.sl_retail_rev, 0, 1000, 10, s.retailRevPerCourt, 'Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Investment & Build Costs -->
|
||||
@@ -119,18 +234,71 @@
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Investition & Baukosten</h2>
|
||||
<p class="wizard-step__sub">Konfiguriere Baukosten, Glas- und Beleuchtungsoptionen sowie Dein Budgetziel.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Bau & CAPEX</h3><span class="hint">Nach Szenario anpassen</span></div>
|
||||
<div id="inp-capex"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Investment & Build Costs</h2>
|
||||
<p class="wizard-step__sub">Configure construction costs, glass and lighting options, and your budget target.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Construction & CAPEX</h3><span class="hint">Adjust per scenario</span></div>
|
||||
<div id="inp-capex"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}
|
||||
<div class="section-header"><h3>Bau & CAPEX</h3><span class="hint">Nach Szenario anpassen</span></div>
|
||||
{% else %}
|
||||
<div class="section-header"><h3>Construction & CAPEX</h3><span class="hint">Adjust per scenario</span></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="pill-group">
|
||||
<label><span class="slider-group__label">{{ t.pill_glass_type }}</span><span class="ti">i<span class="tp">Standard glass: €25–30K per court. Panoramic glass: €30–45K. Panoramic offers full visibility and premium feel.</span></span></label>
|
||||
<div class="pill-options">
|
||||
{{ pill_btn('glassType','standard', t.pill_glass_standard, s.glassType == 'standard') }}
|
||||
{{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pill-group">
|
||||
<label><span class="slider-group__label">{{ t.pill_lighting_type }}</span></label>
|
||||
<div class="pill-options">
|
||||
{{ pill_btn('lightingType','led_standard', t.pill_light_led_standard, s.lightingType == 'led_standard') }}
|
||||
{{ pill_btn('lightingType','led_competition', t.pill_light_led_competition, s.lightingType == 'led_competition') }}
|
||||
<span data-show-venue="outdoor">{{ pill_btn('lightingType','natural', t.pill_light_natural, s.lightingType == 'natural') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ slider('courtCostDbl', t.sl_court_cost_dbl, 0, 80000, 1000, s.courtCostDbl, 'Base price of one double padel court. The glass type multiplier is applied automatically.') }}
|
||||
{{ slider('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, 'Base price of one single padel court. Generally 60–70% of a double court cost.') }}
|
||||
|
||||
<!-- Indoor + Buy -->
|
||||
<div data-show-capex="indoor-buy">
|
||||
{{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, 'Construction cost per m² for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10–12m clear height.') }}
|
||||
{{ slider('foundationSqm', t.sl_foundation_sqm, 0, 400, 5, s.foundationSqm, 'Foundation cost per m². Depends on soil conditions, load-bearing requirements, and local ground water levels.') }}
|
||||
{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Rural: €20–60. Suburban: €60–150. Urban: €150–300+. Varies hugely by location.') }}
|
||||
{{ slider('hvac', t.sl_hvac, 0, 500000, 5000, s.hvac, 'Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.') }}
|
||||
{{ slider('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, 'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.') }}
|
||||
{{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, 'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.') }}
|
||||
{{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, 'Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.') }}
|
||||
{{ slider('planning', t.sl_planning, 0, 500000, 5000, s.planning, 'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.') }}
|
||||
</div>
|
||||
|
||||
<!-- Indoor + Rent -->
|
||||
<div data-show-capex="indoor-rent">
|
||||
{{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, 'Floor leveling, sealing, and preparation for court installation in an existing rented building.') }}
|
||||
{{ slider('hvacUpgrade', t.sl_hvac_upgrade, 0, 200000, 1000, s.hvacUpgrade, 'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.') }}
|
||||
{{ slider('lightingUpgrade', t.sl_lighting_upgrade, 0, 100000, 1000, s.lightingUpgrade, 'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.') }}
|
||||
{{ slider('fitout', t.sl_fitout, 0, 300000, 1000, s.fitout, 'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.') }}
|
||||
</div>
|
||||
|
||||
<!-- Outdoor -->
|
||||
<div data-show-capex="outdoor">
|
||||
{{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, 'Concrete pad per m² for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.') }}
|
||||
{{ slider('outdoorSiteWork', t.sl_outdoor_site_work, 0, 60000, 500, s.outdoorSiteWork, 'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.') }}
|
||||
{{ slider('outdoorLighting', t.sl_outdoor_lighting, 0, 20000, 500, s.outdoorLighting, 'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.') }}
|
||||
{{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, 'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.') }}
|
||||
<div data-show-capex="outdoor-buy">{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Varies by location, zoning, and accessibility.') }}</div>
|
||||
</div>
|
||||
|
||||
{{ slider('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, 'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer — underfunding is a common startup failure.') }}
|
||||
{{ slider('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, 'Percentage buffer on total CAPEX for unexpected costs. 10–15% is standard for construction, 15–20% for complex projects.') }}
|
||||
{{ slider('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, 'Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Operations & Financing -->
|
||||
@@ -138,131 +306,74 @@
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Betrieb & Finanzierung</h2>
|
||||
<p class="wizard-step__sub">Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Monatliche Betriebskosten</h3></div>
|
||||
<div id="inp-opex"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Finanzierung</h3></div>
|
||||
<div id="inp-finance"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Exit-Annahmen</h3></div>
|
||||
<div id="inp-exit"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Operations & Financing</h2>
|
||||
<p class="wizard-step__sub">Monthly operating costs, loan terms, and exit assumptions.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Monthly Operating Costs</h3></div>
|
||||
<div id="inp-opex"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Financing</h3></div>
|
||||
<div id="inp-finance"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Exit Assumptions</h3></div>
|
||||
<div id="inp-exit"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}<div class="section-header"><h3>Monatliche Betriebskosten</h3></div>
|
||||
{% else %}<div class="section-header"><h3>Monthly Operating Costs</h3></div>{% endif %}
|
||||
|
||||
<div data-show-opex="indoor-rent">{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, 'Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.') }}</div>
|
||||
<div data-show-opex="outdoor-rent">{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, 'Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.') }}</div>
|
||||
<div data-show-opex="buy">{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, 'Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.') }}</div>
|
||||
|
||||
{{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, 'Monthly insurance premium covering liability, property damage, business interruption, and equipment.') }}
|
||||
{{ slider('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, 'Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.') }}
|
||||
|
||||
<div data-show-opex="indoor">
|
||||
{{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, 'Monthly heating cost for indoor venue. Significant in northern European climates during winter months.') }}
|
||||
{{ slider('water', t.sl_water, 0, 1000, 25, s.water, 'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.') }}
|
||||
</div>
|
||||
|
||||
<!-- Preview bar + navigation (sticky) -->
|
||||
{{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, 'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.') }}
|
||||
|
||||
<div data-show-opex="indoor">{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, 'Monthly professional cleaning of courts, changing rooms, common areas, and reception.') }}</div>
|
||||
|
||||
{{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, 'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.') }}
|
||||
{{ slider('staff', t.sl_staff, 0, 20000, 100, s.staff, 'Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.') }}
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}<div class="section-header"><h3>Finanzierung</h3></div>
|
||||
{% else %}<div class="section-header"><h3>Financing</h3></div>{% endif %}
|
||||
{{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, 'Percentage of total CAPEX financed by debt. Banks typically offer 70–85%. Higher with personal guarantees or subsidies.') }}
|
||||
{{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, 'Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.') }}
|
||||
{{ slider('loanTerm', t.sl_loan_term, 0, 30, 1, s.loanTerm, 'Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.') }}
|
||||
{{ slider('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, 'Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.') }}
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}<div class="section-header"><h3>Exit-Annahmen</h3></div>
|
||||
{% else %}<div class="section-header"><h3>Exit Assumptions</h3></div>{% endif %}
|
||||
{{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, 'Investment holding period before exit/sale. Typical for PE/investors: 5–7 years. Owner-operators may hold indefinitely.') }}
|
||||
{{ slider('exitMultiple', t.sl_exit_multiple, 0, 20, 0.5, s.exitMultiple, 'EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4–6x, strong brand: 6–8x.') }}
|
||||
{{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, 'Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wizard footer: preview bar + navigation -->
|
||||
<div class="wizard-footer">
|
||||
<div class="wizard-preview" id="wizPreview"></div>
|
||||
<div class="wizard-nav" id="wizNav"></div>
|
||||
<div class="wizard-preview" id="wizPreview">{% include "partials/wizard_preview.html" %}</div>
|
||||
<div class="wizard-nav" id="wizNav">
|
||||
<div></div>
|
||||
<button type="button" class="wiz-btn--next" onclick="showWizStep(2)">{{ t.btn_next }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /planner-wizard -->
|
||||
</form>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════
|
||||
RESULT TABS (HTMX swap target)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<div id="tab-content" style="display:none">
|
||||
{% set tab_template = "partials/tab_" ~ active_tab ~ ".html" %}
|
||||
{% include tab_template %}
|
||||
</div>
|
||||
|
||||
<!-- CAPEX -->
|
||||
<div class="tab" id="tab-capex">
|
||||
<div class="grid-3 mb-4" id="capexCards"></div>
|
||||
<div id="capexTable"></div>
|
||||
<div class="chart-container mt-4">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}CAPEX-Aufschlüsselung{% else %}CAPEX Breakdown{% endif %}</div>
|
||||
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCapex"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPERATING -->
|
||||
<div class="tab" id="tab-operating">
|
||||
<div class="grid-4 mb-4" id="opCards"></div>
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Monatlicher Umsatzaufbau (Anlaufphase){% else %}Monthly Revenue Build-Up (Ramp Period){% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartRevRamp"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Stabilisierte monatliche GuV{% else %}Stabilized Monthly P&L{% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartPL"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Einnahmequellen (stabilisierter Monat){% else %}Revenue Streams (Stabilized Month){% endif %}</h3></div>
|
||||
<div id="revenueTable"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Monatliche OPEX-Aufschlüsselung{% else %}Monthly OpEx Breakdown{% endif %}</h3></div>
|
||||
<div id="opexDetailTable"></div>
|
||||
</div>
|
||||
<div class="mb-section season-section" id="seasonSection">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Outdoor-Saisonalität{% else %}Outdoor Seasonality{% endif %}</h3></div>
|
||||
<div class="chart-container"><div class="chart-h-40 chart-container__canvas"><canvas id="chartSeason"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CASH FLOW -->
|
||||
<div class="tab" id="tab-cashflow">
|
||||
<div class="grid-4 mb-4" id="cfCards"></div>
|
||||
<div class="chart-container mb-4">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Monatlicher Netto-Cashflow (60 Monate){% else %}Monthly Net Cash Flow (60 Months){% endif %}</div>
|
||||
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCF"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-container mb-4">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Kumulierter Cashflow{% else %}Cumulative Cash Flow{% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartCum"></canvas></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Jahresübersicht{% else %}Annual Summary{% endif %}</h3></div>
|
||||
<div id="annualTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RETURNS -->
|
||||
<div class="tab" id="tab-returns">
|
||||
<div class="grid-4 mb-4" id="retCards"></div>
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label" style="font-size:10px">{% if lang == 'de' %}Exit-Bewertungs-Wasserfall{% else %}Exit Valuation Waterfall{% endif %}</div>
|
||||
<div id="exitWaterfall" style="margin-top:10px"></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">{% if lang == 'de' %}DSCR nach Jahr{% else %}DSCR by Year{% endif %}</div>
|
||||
<div class="chart-h-44 chart-container__canvas"><canvas id="chartDSCR"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Auslastungs-Sensitivität{% else %}Utilization Sensitivity{% endif %}</h3></div>
|
||||
<div id="sensTable"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Preis-Sensitivität (bei Ziel-Auslastung){% else %}Pricing Sensitivity (at target utilization){% endif %}</h3></div>
|
||||
<div id="priceSensTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- METRICS -->
|
||||
<div class="tab" id="tab-metrics">
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_return }}</h3></div><div class="grid-4" id="mReturn"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_revenue }}</h3></div><div class="grid-4" id="mRevenue"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_cost }}</h3></div><div class="grid-4" id="mCost"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_debt }}</h3></div><div class="grid-4" id="mDebt"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_invest }}</h3></div><div class="grid-4" id="mInvest"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_ops }}</h3></div><div class="grid-4" id="mOps"></div></div>
|
||||
</div>
|
||||
<!-- Inline quote CTA (mobile / narrow screens) -->
|
||||
<div class="quote-inline-cta" id="quoteInlineCta">
|
||||
<div class="quote-inline-cta" id="quoteInlineCta" style="display:none">
|
||||
<div class="quote-inline-cta__label">{{ t.planner_quote_cta_label }}</div>
|
||||
<h3 class="quote-inline-cta__title">{{ t.planner_quote_cta_title }}</h3>
|
||||
<p class="quote-inline-cta__desc">{{ t.planner_quote_cta_desc }}</p>
|
||||
@@ -277,7 +388,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar CTA (desktop wide screens) -->
|
||||
<!-- Sidebar CTA (desktop) -->
|
||||
<aside class="quote-sidebar" id="quoteSidebar">
|
||||
<div class="quote-sidebar__label">{{ t.planner_quote_cta_label }}</div>
|
||||
<h3 class="quote-sidebar__title">{{ t.planner_quote_cta_title }}</h3>
|
||||
@@ -297,7 +408,7 @@
|
||||
</aside>
|
||||
|
||||
{% if not user %}
|
||||
<div class="signup-bar" id="signupBar">
|
||||
<div class="signup-bar" id="signupBar" style="display:none">
|
||||
<span>{{ t.planner_signup_bar_msg }}</span>
|
||||
<a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">{{ t.planner_signup_bar_btn }}</a>
|
||||
</div>
|
||||
@@ -311,15 +422,11 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
{% if initial_state %}
|
||||
window.__PADELNOMICS_INITIAL_STATE__ = {{ initial_state | safe }};
|
||||
{% endif %}
|
||||
window.__PADELNOMICS_INITIAL_D__ = {{ initial_d | safe }};
|
||||
window.__PADELNOMICS_LOCALE__ = {{ planner_t | tojson | safe }};
|
||||
window.__PADELNOMICS_CALC_URL__ = "{{ url_for('planner.calculate') }}";
|
||||
window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
|
||||
window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
|
||||
window.__PADELNOMICS_QUOTE_URL__ = "{{ url_for('leads.quote_request') }}";
|
||||
window.__COUNTRY_PRESETS__ = {{ country_presets | tojson | safe }};
|
||||
window.__DEFAULTS__ = {{ defaults | tojson | safe }};
|
||||
window.__SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
|
||||
window.__SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
|
||||
window.__QUOTE_URL__ = "{{ url_for('leads.quote_request') }}";
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,14 +43,15 @@ class TestGuestMode:
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_calculate_endpoint_works_without_login(self, client):
|
||||
"""POST /planner/calculate returns valid JSON for guest."""
|
||||
"""POST /planner/calculate returns HTML partial for guest."""
|
||||
resp = await client.post(
|
||||
"/en/planner/calculate",
|
||||
json={"state": {"dblCourts": 4}},
|
||||
data={"dblCourts": "4", "activeTab": "capex"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_json()
|
||||
assert "capex" in data
|
||||
html = (await resp.data).decode()
|
||||
# HTMX endpoint returns an HTML partial containing CAPEX data
|
||||
assert "capex" in html.lower() or "metric-card" in html
|
||||
|
||||
async def test_scenario_routes_require_login(self, client):
|
||||
"""Save/load/delete/list scenarios still require auth."""
|
||||
|
||||
Reference in New Issue
Block a user