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:
Deeman
2026-02-20 17:12:28 +01:00
parent e6f4c0a540
commit 7c710ada6b
15 changed files with 1446 additions and 1327 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 ────────────────────────────────────────────────────

View File

@@ -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"])

View File

@@ -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 }} &middot; {{ s.own == 'buy' and t.label_build_buy or t.label_rent }} &middot; {{ d.totalCourts }} {{ t.label_courts }} &middot; {{ 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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&amp;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>

View File

@@ -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&amp;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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }} &middot; {{ s.own == 'buy' and t.label_build_buy or t.label_rent }} &middot; {{ d.totalCourts }} {{ t.label_courts }} &middot; {{ d.capex | fmt_k }}</span>
{% if user %}
<div class="scenario-controls">
@@ -29,240 +54,326 @@
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">
<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>
<!-- ═══════════════════════════════════════════════════════════════════
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 }}">
<!-- Step 1: Your 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>
<!-- 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">
{% 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>
{% 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>
</div>
<div class="mb-section">
<!-- Step 1: Venue -->
<div class="wizard-step active" data-wiz="1">
{% if lang == 'de' %}
<div class="section-header"><h3>Platzkonfiguration</h3></div>
<h2 class="wizard-step__title">Dein Padel-Platz</h2>
<p class="wizard-step__sub">Definiere den Typ des Padel-Platzes, den du planst.</p>
{% else %}
<div class="section-header"><h3>Court Configuration</h3></div>
<h2 class="wizard-step__title">Your Venue</h2>
<p class="wizard-step__sub">Define the type of facility you're planning to build.</p>
{% endif %}
<div id="inp-courts"></div>
<div class="mb-section">
<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 %}
{{ 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 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: 300350m².') }}
{{ 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: 200250m².') }}
</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: 280320m².') }}
{{ 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: 180220m².') }}
</div>
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
</div>
</div>
<!-- Step 2: Pricing & Utilization -->
<div class="wizard-step" data-wiz="2">
{% if lang == 'de' %}
<div class="section-header" style="margin-top:1rem"><h3>Platzbedarf</h3></div>
<h2 class="wizard-step__title">Preise &amp; Auslastung</h2>
<p class="wizard-step__sub">Lege Deine Platztarife, Betriebszeiten und Nebeneinnahmen fest.</p>
{% else %}
<div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div>
<h2 class="wizard-step__title">Pricing &amp; Utilization</h2>
<p class="wizard-step__sub">Set your court rates, operating schedule, and ancillary revenue streams.</p>
{% endif %}
<div id="inp-space"></div>
<div class="court-summary" id="courtSummary"></div>
</div>
</div>
<!-- Step 2: Pricing & Utilization -->
<div class="wizard-step" data-wiz="2">
{% if lang == 'de' %}
<h2 class="wizard-step__title">Preise &amp; 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 &amp; Betrieb</h3></div>
<div id="inp-util"></div>
</div>
{% else %}
<h2 class="wizard-step__title">Pricing &amp; 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 &amp; Operations</h3></div>
<div id="inp-util"></div>
</div>
{% endif %}
</div>
<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:0022: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 3040% 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 515% of court revenue.') }}
</div>
<!-- Step 3: Investment & Build Costs -->
<div class="wizard-step" data-wiz="3">
{% if lang == 'de' %}
<h2 class="wizard-step__title">Investition &amp; 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 &amp; CAPEX</h3><span class="hint">Nach Szenario anpassen</span></div>
<div id="inp-capex"></div>
<div class="mb-section">
{% if lang == 'de' %}
<div class="section-header"><h3>Auslastung &amp; Betrieb</h3></div>
{% else %}
<div class="section-header"><h3>Utilization &amp; Operations</h3></div>
{% endif %}
{{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, 'Percentage of available court-hours that are actually booked. 3545% 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:0023:00 (16h). Some extend to 6:0024: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>
{% else %}
<h2 class="wizard-step__title">Investment &amp; 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 &amp; CAPEX</h3><span class="hint">Adjust per scenario</span></div>
<div id="inp-capex"></div>
</div>
{% endif %}
</div>
<!-- Step 4: Operations & Financing -->
<div class="wizard-step" data-wiz="4">
{% if lang == 'de' %}
<h2 class="wizard-step__title">Betrieb &amp; 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 &amp; 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>
<!-- Step 3: Investment & Build Costs -->
<div class="wizard-step" data-wiz="3">
{% if lang == 'de' %}
<h2 class="wizard-step__title">Investition &amp; Baukosten</h2>
<p class="wizard-step__sub">Konfiguriere Baukosten, Glas- und Beleuchtungsoptionen sowie Dein Budgetziel.</p>
{% else %}
<h2 class="wizard-step__title">Investment &amp; Build Costs</h2>
<p class="wizard-step__sub">Configure construction costs, glass and lighting options, and your budget target.</p>
{% endif %}
<!-- Preview bar + navigation (sticky) -->
<div class="wizard-footer">
<div class="wizard-preview" id="wizPreview"></div>
<div class="wizard-nav" id="wizNav"></div>
</div>
<div class="mb-section">
{% if lang == 'de' %}
<div class="section-header"><h3>Bau &amp; CAPEX</h3><span class="hint">Nach Szenario anpassen</span></div>
{% else %}
<div class="section-header"><h3>Construction &amp; 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: €2530K per court. Panoramic glass: €3045K. 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 6070% 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 1012m 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: €2060. Suburban: €60150. Urban: €150300+. 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. 1015% is standard for construction, 1520% 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 -->
<div class="wizard-step" data-wiz="4">
{% if lang == 'de' %}
<h2 class="wizard-step__title">Betrieb &amp; Finanzierung</h2>
<p class="wizard-step__sub">Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.</p>
{% else %}
<h2 class="wizard-step__title">Operations &amp; Financing</h2>
<p class="wizard-step__sub">Monthly operating costs, loan terms, and exit assumptions.</p>
{% 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>
{{ 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 7085%. 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: 57 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: 46x, strong brand: 68x.') }}
{{ 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">{% 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&amp;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

View File

@@ -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."""