From 6cb0fb32ec392583ce828ca6e00b645301b36ccc Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 25 Feb 2026 13:25:17 +0100 Subject: [PATCH 1/4] feat(cms): add language field + seo_head to manual article creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add language selector (en/de) to article create/edit form - Store language and generated seo_head in articles table on CREATE and UPDATE - Write HTML build to BUILD_DIR/{lang}/{slug}.html (consistent with pSEO) - article_detail.html: render article.seo_head when present (canonical, hreflang, OG, JSON-LD Article) — falls back to inline for legacy articles Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 62 ++++++++++++++++--- .../admin/templates/admin/article_form.html | 9 ++- .../content/templates/article_detail.html | 20 +++--- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 89336cc..d20cc47 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -31,6 +31,42 @@ from ..core import ( slugify, ) +def _build_article_seo_head( + url_path: str, + title: str, + meta_desc: str, + language: str, + published_at: str, + *, + base_url: str = "https://padelnomics.io", +) -> str: + """Build SEO head block (canonical, OG, JSON-LD) for a manually created article.""" + def _esc(text: str) -> str: + return text.replace("&", "&").replace('"', """).replace("<", "<") + + full_url = f"{base_url}/{language}{url_path}" + jsonld = json.dumps({ + "@context": "https://schema.org", + "@type": "Article", + "headline": title[:110], + "description": meta_desc[:200], + "url": full_url, + "inLanguage": language, + "datePublished": published_at, + "dateModified": published_at, + "author": {"@type": "Organization", "name": "Padelnomics", "url": "https://padelnomics.io"}, + "publisher": {"@type": "Organization", "name": "Padelnomics", "url": "https://padelnomics.io"}, + }, ensure_ascii=False) + return "\n".join([ + f'', + f'', + f'', + f'', + '', + f'', + ]) + + # Blueprint with its own template folder bp = Blueprint( "admin", @@ -1716,6 +1752,7 @@ async def article_new(): country = form.get("country", "").strip() region = form.get("region", "").strip() body = form.get("body", "").strip() + language = form.get("language", "en").strip() or "en" status = form.get("status", "draft") published_at = form.get("published_at", "").strip() @@ -1731,9 +1768,9 @@ async def article_new(): body_html = mistune.html(body) body_html = await bake_scenario_cards(body_html) - BUILD_DIR.mkdir(parents=True, exist_ok=True) - build_path = BUILD_DIR / f"{article_slug}.html" - build_path.write_text(body_html) + build_dir = BUILD_DIR / language + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / f"{article_slug}.html").write_text(body_html) # Save markdown source md_dir = Path("data/content/articles") @@ -1741,14 +1778,15 @@ async def article_new(): (md_dir / f"{article_slug}.md").write_text(body) pub_dt = published_at or datetime.utcnow().isoformat() + seo_head = _build_article_seo_head(url_path, title, meta_description, language, pub_dt) await execute( """INSERT INTO articles (url_path, slug, title, meta_description, og_image_url, - country, region, status, published_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + country, region, language, status, published_at, seo_head) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (url_path, article_slug, title, meta_description, og_image_url, - country, region, status, pub_dt), + country, region, language, status, pub_dt, seo_head), ) from ..sitemap import invalidate_sitemap_cache invalidate_sitemap_cache() @@ -1780,6 +1818,7 @@ async def article_edit(article_id: int): country = form.get("country", "").strip() region = form.get("region", "").strip() body = form.get("body", "").strip() + language = form.get("language", article.get("language", "en")).strip() or "en" status = form.get("status", article["status"]) published_at = form.get("published_at", "").strip() @@ -1793,8 +1832,9 @@ async def article_edit(article_id: int): if body: body_html = mistune.html(body) body_html = await bake_scenario_cards(body_html) - BUILD_DIR.mkdir(parents=True, exist_ok=True) - (BUILD_DIR / f"{article['slug']}.html").write_text(body_html) + build_dir = BUILD_DIR / language + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / f"{article['slug']}.html").write_text(body_html) md_dir = Path("data/content/articles") md_dir.mkdir(parents=True, exist_ok=True) @@ -1802,14 +1842,16 @@ async def article_edit(article_id: int): now = datetime.utcnow().isoformat() pub_dt = published_at or article["published_at"] + seo_head = _build_article_seo_head(url_path, title, meta_description, language, pub_dt) await execute( """UPDATE articles SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?, - country = ?, region = ?, status = ?, published_at = ?, updated_at = ? + country = ?, region = ?, language = ?, status = ?, published_at = ?, + seo_head = ?, updated_at = ? WHERE id = ?""", (title, url_path, meta_description, og_image_url, - country, region, status, pub_dt, now, article_id), + country, region, language, status, pub_dt, seo_head, now, article_id), ) await flash("Article updated.", "success") return redirect(url_for("admin.articles")) diff --git a/web/src/padelnomics/admin/templates/admin/article_form.html b/web/src/padelnomics/admin/templates/admin/article_form.html index aed810c..9e7df00 100644 --- a/web/src/padelnomics/admin/templates/admin/article_form.html +++ b/web/src/padelnomics/admin/templates/admin/article_form.html @@ -60,7 +60,14 @@

Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full

-
+
+
+ + +
+ + + + +
+

Company & Legal Entity Cover page + throughout

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+

Founder / Management Profile Gründerprofil — required by KfW

+
+
+ + +
+
+
+
+ + +

2–4 sentences. Banks want to see relevant experience or a credible team composition.

+
+
+
+ + +
+

Business Description Vorhabensbeschreibung

+
+
+ + +
+
+
+ + +
+

Operations Betriebskonzept + Personalplanung

+
+
+ + +
+
+ + +
+
+
+ + +
+

Marketing Concept Marketingkonzept

+
+
+ + +
+
+
+ +
+ +

+ Skip — generate PDF with auto-filled sections +

+
+ + + +
+ +{% endblock %} diff --git a/web/src/padelnomics/templates/businessplan/plan.css b/web/src/padelnomics/templates/businessplan/plan.css index d06ba06..bcaaec1 100644 --- a/web/src/padelnomics/templates/businessplan/plan.css +++ b/web/src/padelnomics/templates/businessplan/plan.css @@ -1,105 +1,689 @@ +/* ============================================================ + Padelnomics Business Plan PDF — Precision Finance Aesthetic + Rendered by WeasyPrint (A4, CSS3, no JavaScript) + ============================================================ */ + +/* ---------- Design tokens ---------- */ +:root { + --navy: #0F2651; + --navy-deep: #091a3a; + --gold: #C9922C; + --gold-lt: #EDD48A; + --text: #1C2333; + --muted: #5E6E8A; + --border: #DDE3EE; + --bg-light: #F6F8FC; + --white: #FFFFFF; + --green: #166534; + --green-bg: #DCFCE7; + --red: #991B1B; + --red-bg: #FEE2E2; +} + +/* ---------- Page setup ---------- */ @page { size: A4; - margin: 20mm 18mm; + margin: 22mm 20mm 22mm 20mm; + + @top-left { + content: string(doc-company); + font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif; + font-size: 7.5pt; + color: var(--muted); + letter-spacing: 0.04em; + } + @top-right { + content: string(doc-confidential); + font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif; + font-size: 7.5pt; + color: var(--muted); + font-style: italic; + letter-spacing: 0.04em; + } @bottom-center { - content: "Page " counter(page) " of " counter(pages); + content: counter(page); + font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif; font-size: 8pt; - color: #94A3B8; + color: var(--muted); + } + @bottom-right { + content: "padelnomics.io"; + font-family: 'Gill Sans', 'Trebuchet MS', Calibri, sans-serif; + font-size: 7pt; + color: var(--border); + letter-spacing: 0.05em; } } -body { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 10pt; - line-height: 1.5; - color: #1E293B; +/* Cover page: no running headers/footers, no margins */ +@page cover { + size: A4; + margin: 0; + @top-left { content: none; } + @top-right { content: none; } + @bottom-center { content: none; } + @bottom-right { content: none; } +} + +/* TOC page: suppress page counter display */ +@page toc { + @bottom-center { content: none; } +} + +/* ---------- Named strings for headers ---------- */ +.doc-company-anchor { string-set: doc-company content(); } +.doc-confidential-anchor { string-set: doc-confidential content(); } + +/* ---------- Base typography ---------- */ +body { + font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif; + font-size: 9.5pt; + line-height: 1.55; + color: var(--text); + background: var(--white); } -h1 { font-size: 22pt; font-weight: 800; color: #0F172A; margin: 0 0 4pt; } -h2 { font-size: 14pt; font-weight: 700; color: #0F172A; margin: 24pt 0 8pt; page-break-after: avoid; } -h3 { font-size: 10pt; font-weight: 700; color: #64748B; text-transform: uppercase; letter-spacing: 0.06em; margin: 16pt 0 6pt; } p { margin: 0 0 6pt; } -.subtitle { font-size: 12pt; color: #64748B; margin-bottom: 20pt; } -.meta { font-size: 8pt; color: #94A3B8; margin-bottom: 6pt; } - -/* Summary grid */ -.summary-grid { +/* ---------- Cover page ---------- */ +.cover { + page: cover; + page-break-after: always; + width: 210mm; + height: 297mm; display: flex; - flex-wrap: wrap; - gap: 8pt; - margin: 10pt 0 16pt; + box-sizing: border-box; + position: relative; } -.summary-card { - flex: 1 1 140pt; - border: 0.5pt solid #E2E8F0; - border-radius: 6pt; - padding: 8pt 10pt; - background: #F8FAFC; -} -.summary-card .label { font-size: 7pt; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; } -.summary-card .value { font-size: 14pt; font-weight: 800; color: #1E293B; } -.summary-card .value--blue { color: #1D4ED8; } -/* Tables */ +.cover__sidebar { + width: 68mm; + background: var(--navy); + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 18mm 10mm 14mm 14mm; +} + +.cover__logo { + font-family: Georgia, 'Times New Roman', serif; + font-size: 17pt; + font-weight: 700; + color: var(--white); + letter-spacing: -0.02em; +} + +.cover__logo-dot { + color: var(--gold); +} + +.cover__tagline { + font-size: 7.5pt; + color: rgba(255,255,255,0.5); + text-transform: uppercase; + letter-spacing: 0.12em; + margin-top: 5pt; +} + +.cover__sidebar-footer { + font-size: 7pt; + color: rgba(255,255,255,0.4); + line-height: 1.6; +} + +.cover__sidebar-footer a { + color: var(--gold); + text-decoration: none; +} + +.cover__main { + flex: 1; + padding: 18mm 14mm 14mm 14mm; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.cover__type-label { + font-size: 7.5pt; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--gold); + margin-bottom: 10pt; +} + +.cover__title { + font-family: Georgia, 'Times New Roman', serif; + font-size: 28pt; + font-weight: 700; + color: var(--navy); + line-height: 1.2; + margin: 0 0 16pt; + letter-spacing: -0.02em; +} + +.cover__subtitle { + font-size: 11pt; + color: var(--muted); + margin-bottom: 4pt; +} + +.cover__scenario { + font-size: 9.5pt; + color: var(--text); + font-weight: 600; + margin-bottom: 2pt; +} + +.cover__divider { + width: 40pt; + height: 2.5pt; + background: var(--gold); + margin: 14pt 0; +} + +.cover__meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10pt 14pt; +} + +.cover__meta-item {} +.cover__meta-label { + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 2pt; +} +.cover__meta-value { + font-size: 9.5pt; + font-weight: 600; + color: var(--text); +} + +.cover__footer-strip { + border-top: 1pt solid var(--border); + padding-top: 10pt; + font-size: 7.5pt; + color: var(--muted); + display: flex; + justify-content: space-between; +} + +/* ---------- Table of Contents ---------- */ +.toc-page { + page: toc; + page-break-after: always; +} + +.toc-heading { + font-family: Georgia, 'Times New Roman', serif; + font-size: 16pt; + font-weight: 700; + color: var(--navy); + margin: 0 0 20pt; + padding-bottom: 10pt; + border-bottom: 2pt solid var(--navy); +} + +.toc-list { + list-style: none; + padding: 0; + margin: 0; +} + +.toc-item { + display: flex; + align-items: baseline; + gap: 0; + padding: 5pt 0; + border-bottom: 0.5pt dotted var(--border); + font-size: 9pt; +} + +.toc-item--heading { + font-weight: 700; + color: var(--text); + padding-top: 10pt; + border-bottom: none; + font-size: 9.5pt; + color: var(--navy); + padding-bottom: 3pt; +} + +.toc-num { + font-size: 8pt; + color: var(--gold); + font-weight: 700; + width: 22pt; + flex-shrink: 0; +} + +.toc-text { + flex: 1; + color: var(--text); +} + +.toc-dots { + flex: 1; + border-bottom: 1pt dotted var(--border); + margin: 0 6pt 2pt; + min-width: 20pt; +} + +.toc-section-badge { + font-size: 7pt; + background: var(--bg-light); + color: var(--muted); + padding: 1pt 6pt; + border-radius: 3pt; + white-space: nowrap; +} + +/* ---------- Section headings ---------- */ +h1 { + font-family: Georgia, 'Times New Roman', serif; + font-size: 20pt; + font-weight: 700; + color: var(--navy); + margin: 0 0 6pt; + letter-spacing: -0.02em; +} + +h2 { + font-family: Georgia, 'Times New Roman', serif; + font-size: 13.5pt; + font-weight: 700; + color: var(--navy); + margin: 0 0 10pt; + padding: 8pt 0 8pt 12pt; + border-left: 3.5pt solid var(--gold); + page-break-after: avoid; + break-after: avoid; +} + +h3 { + font-size: 8.5pt; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 14pt 0 5pt; + page-break-after: avoid; +} + +.section { + margin-top: 22pt; + page-break-inside: avoid; +} + +.section--break { + page-break-before: always; + break-before: page; + margin-top: 0; + padding-top: 6pt; +} + +/* ---------- Executive summary cards ---------- */ +.exec-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8pt; + margin: 10pt 0 14pt; +} + +.exec-card { + border: 1pt solid var(--border); + border-top: 3pt solid var(--navy); + border-radius: 0 0 5pt 5pt; + padding: 10pt 10pt 8pt; + background: var(--bg-light); + page-break-inside: avoid; +} + +.exec-card--accent { + border-top-color: var(--gold); +} + +.exec-label { + font-size: 7pt; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 4pt; +} + +.exec-value { + font-family: Georgia, 'Times New Roman', serif; + font-size: 16pt; + font-weight: 700; + color: var(--navy); + line-height: 1.1; +} + +.exec-value--gold { color: var(--gold); } + +.exec-paragraph { + font-size: 9pt; + line-height: 1.6; + color: var(--text); + background: var(--bg-light); + border-left: 3pt solid var(--border); + padding: 8pt 10pt; + margin: 0 0 6pt; +} + +/* ---------- Narrative text blocks ---------- */ +.narrative-block { + font-size: 9pt; + line-height: 1.65; + color: var(--text); + margin-bottom: 8pt; + white-space: pre-wrap; +} + +.placeholder-block { + font-size: 8.5pt; + color: var(--muted); + font-style: italic; + background: var(--bg-light); + border: 1pt dashed var(--border); + border-radius: 4pt; + padding: 8pt 10pt; + margin-bottom: 8pt; +} + +/* ---------- Tables ---------- */ table { width: 100%; border-collapse: collapse; - margin: 6pt 0 16pt; - font-size: 9pt; + margin: 6pt 0 12pt; + font-size: 8.5pt; + page-break-inside: auto; } -th { - background: #F1F5F9; + +thead th { + background: var(--navy); + color: var(--white); font-size: 7pt; font-weight: 700; - color: #64748B; text-transform: uppercase; - letter-spacing: 0.04em; - padding: 6pt 8pt; + letter-spacing: 0.06em; + padding: 5.5pt 8pt; text-align: left; - border-bottom: 1pt solid #E2E8F0; + border: none; } -td { + +thead th:first-child { border-radius: 0; } + +tbody tr:nth-child(even) { background: var(--bg-light); } +tbody tr:nth-child(odd) { background: var(--white); } + +tbody td { padding: 5pt 8pt; - border-bottom: 0.5pt solid #F1F5F9; + border-bottom: 0.5pt solid var(--border); + color: var(--text); + vertical-align: top; } -tr:last-child td { border-bottom: none; } -.total-row td { font-weight: 700; border-top: 1.5pt solid #E2E8F0; background: #F8FAFC; } -/* Metrics grid */ +tbody tr:last-child td { border-bottom: none; } + +.total-row td { + font-weight: 700; + color: var(--navy); + background: #E8EDF5 !important; + border-top: 1.5pt solid var(--navy); + border-bottom: none; +} + +.highlight-row td { + background: #FEF9EE !important; + font-weight: 700; + color: var(--navy); + border-top: 1pt solid var(--gold); + border-bottom: 1pt solid var(--gold); +} + +.positive-cell { color: var(--green); font-weight: 600; } +.negative-cell { color: var(--red); font-weight: 600; } + +td.r, th.r { text-align: right; } +td.c, th.c { text-align: center; } + +.note-cell { + font-size: 7.5pt; + color: var(--muted); +} + +/* ---------- Key metrics grid ---------- */ .metrics-grid { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(4, 1fr); gap: 6pt; - margin: 8pt 0; + margin: 8pt 0 12pt; +} + +.metric-box { + border: 1pt solid var(--border); + border-radius: 5pt; + padding: 8pt 8pt 6pt; + text-align: center; + background: var(--white); + page-break-inside: avoid; +} + +.metric-label { + font-size: 6.5pt; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); + margin-bottom: 4pt; +} + +.metric-value { + font-family: Georgia, 'Times New Roman', serif; + font-size: 14pt; + font-weight: 700; + color: var(--navy); + line-height: 1.1; +} + +/* ---------- Financing bar ---------- */ +.fin-wrap { + margin: 8pt 0 12pt; } -.metric-box { - flex: 1 1 90pt; - border: 0.5pt solid #E2E8F0; - border-radius: 4pt; - padding: 6pt 8pt; - text-align: center; -} -.metric-box .label { font-size: 7pt; color: #94A3B8; } -.metric-box .value { font-size: 12pt; font-weight: 700; color: #1E293B; } -/* Financing structure */ .fin-bar { - height: 12pt; - border-radius: 6pt; + height: 14pt; + border-radius: 4pt; display: flex; overflow: hidden; - margin: 6pt 0 10pt; + margin-bottom: 6pt; } -.fin-bar__equity { background: #1D4ED8; } -.fin-bar__loan { background: #93C5FD; } -/* Footer */ +.fin-bar__equity { + background: var(--navy); + display: flex; + align-items: center; + padding: 0 6pt; +} + +.fin-bar__loan { + background: var(--gold); + display: flex; + align-items: center; + padding: 0 6pt; +} + +.fin-bar__label { + font-size: 6.5pt; + font-weight: 700; + color: rgba(255,255,255,0.9); + white-space: nowrap; + overflow: hidden; +} + +.fin-legend { + display: flex; + gap: 14pt; + font-size: 8pt; +} + +.fin-legend-item { + display: flex; + align-items: center; + gap: 5pt; +} + +.fin-legend-dot { + width: 8pt; + height: 8pt; + border-radius: 2pt; + flex-shrink: 0; +} + +/* ---------- Balance sheet 2-column ---------- */ +.balance-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10pt; + margin: 6pt 0 12pt; +} + +.balance-side { + border: 1pt solid var(--border); + border-radius: 5pt; + overflow: hidden; +} + +.balance-side__header { + background: var(--navy); + color: var(--white); + font-size: 7pt; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 5pt 8pt; +} + +.balance-side table { + margin: 0; +} + +.balance-side tbody td { + border-bottom: 0.5pt solid var(--border); +} + +.balance-total td { + font-weight: 700; + color: var(--navy); + background: #E8EDF5 !important; + border-top: 1.5pt solid var(--navy) !important; +} + +/* ---------- Charts ---------- */ +.chart-wrap { + margin: 8pt 0 14pt; + text-align: center; + page-break-inside: avoid; +} + +.chart-wrap svg { + max-width: 100%; + height: auto; +} + +.chart-caption { + font-size: 7.5pt; + color: var(--muted); + text-align: center; + margin-top: 3pt; + font-style: italic; +} + +/* ---------- Market data callout ---------- */ +.market-callout { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8pt; + margin: 8pt 0 12pt; +} + +.market-stat { + background: var(--bg-light); + border: 1pt solid var(--border); + border-radius: 5pt; + padding: 8pt 10pt; + text-align: center; +} + +.market-stat-value { + font-family: Georgia, 'Times New Roman', serif; + font-size: 14pt; + font-weight: 700; + color: var(--navy); +} + +.market-stat-label { + font-size: 7pt; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 2pt; +} + +/* ---------- Risk register ---------- */ +.risk-item { + display: flex; + gap: 8pt; + padding: 6pt 0; + border-bottom: 0.5pt solid var(--border); + font-size: 8.5pt; + page-break-inside: avoid; +} + +.risk-badge { + font-size: 6.5pt; + font-weight: 700; + padding: 2pt 6pt; + border-radius: 3pt; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-start; + margin-top: 1pt; +} + +.risk-badge--high { background: #FEE2E2; color: #991B1B; } +.risk-badge--medium { background: #FEF3C7; color: #92400E; } +.risk-badge--low { background: var(--green-bg); color: var(--green); } + +.risk-text strong { + display: block; + color: var(--navy); + font-size: 8.5pt; + margin-bottom: 2pt; +} + +/* ---------- Two-column layout helper ---------- */ +.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12pt; +} + +/* ---------- Disclaimer ---------- */ .disclaimer { font-size: 7pt; - color: #94A3B8; - margin-top: 30pt; - padding-top: 8pt; - border-top: 0.5pt solid #E2E8F0; - line-height: 1.4; + color: var(--muted); + line-height: 1.5; + padding-top: 10pt; + border-top: 0.5pt solid var(--border); + margin-top: 20pt; } + +/* ---------- Stat row (financing detail) ---------- */ +.stat-table tbody tr:nth-child(even) { background: var(--bg-light); } +.stat-table tbody tr:nth-child(odd) { background: var(--white); } diff --git a/web/src/padelnomics/templates/businessplan/plan.html b/web/src/padelnomics/templates/businessplan/plan.html index e3de3a9..f2bfff5 100644 --- a/web/src/padelnomics/templates/businessplan/plan.html +++ b/web/src/padelnomics/templates/businessplan/plan.html @@ -6,201 +6,668 @@ - -

{{ s.title }}

-
{{ s.subtitle }}
-{% if s.scenario_name %} -

{{ s.labels.scenario }}: {{ s.scenario_name }}{% if s.location %} — {{ s.location }}{% endif %}

-{% endif %} -

{{ s.courts }}

-

{{ s.labels.generated_by }}

+{# Named strings for running headers #} +{% if s.narrative.company_name %}{{ s.narrative.company_name }}{% else %}Padelnomics{% endif %} +{{ s.labels.confidential }} - -

{{ s.executive_summary.heading }}

-
-
-
{{ s.labels.total_investment }}
-
{{ s.executive_summary.total_capex }}
+{# ============================================================ #} +{# COVER PAGE #} +{# ============================================================ #} +
+
+
+ +
Business Plan
+
+
-
-
{{ s.labels.equity_required }}
-
{{ s.executive_summary.equity }}
-
-
-
{{ s.labels.year3_ebitda }}
-
{{ s.executive_summary.y3_ebitda }}
-
-
-
{{ s.labels.irr }}
-
{{ s.executive_summary.irr }}
-
-
-
{{ s.labels.payback_period }}
-
{{ s.executive_summary.payback }}
-
-
-
{{ s.labels.year1_revenue }}
-
{{ s.executive_summary.y1_revenue }}
+
+
+
{{ s.labels.confidential }} — Business Plan
+
+ {% if s.narrative.company_name %}{{ s.narrative.company_name }}{% else %}{{ s.scenario_name or s.title }}{% endif %} +
+
{{ s.subtitle }}
+ {% if s.location %}
{{ s.location }}
{% endif %} +
+
+
+
{{ s.labels.scenario }}
+
{{ s.scenario_name or "—" }}
+
+ {% if s.narrative.legal_entity %} +
+
Rechtsform
+
{{ s.narrative.legal_entity }}
+
+ {% endif %} + {% if s.narrative.founder_name %} +
+
{{ s.labels.founder_profile }}
+
{{ s.narrative.founder_name }}
+
+ {% endif %} + {% if s.narrative.planned_opening_date %} +
+
Planned Opening
+
{{ s.narrative.planned_opening_date }}
+
+ {% endif %} +
+
Courts
+
{{ s.courts }}
+
+
+
{{ s.labels.total_investment }}
+
{{ s.executive_summary.total_capex }}
+
+
+
+
-

{{ s.labels.exec_paragraph }}

- - -

{{ s.investment.heading }}

- - - - - - {% for item in s.investment['items'] %} - - - - - - {% endfor %} - - - - - - -
{{ s.labels.item }}{{ s.labels.amount }}{{ s.labels.notes }}
{{ item.name }}{{ item.formatted_amount }}{{ item.info }}
{{ s.labels.total_capex }}{{ s.investment.total }}
-

{{ s.labels.capex_stats }}

- - -

{{ s.financing.heading }}

-
-
-
+{# ============================================================ #} +{# TABLE OF CONTENTS #} +{# ============================================================ #} +
+
{{ s.labels.table_of_contents }}
+
    +
  • 1{{ s.executive_summary.heading }}
  • + {% if s.narrative.founder_background %} +
  • 2{{ s.labels.founder_profile }}
  • + {% endif %} +
  • 3{{ s.labels.venture_description }}
  • + {% if s.market_data %} +
  • 4{{ s.labels.market_analysis }}Data
  • + {% endif %} +
  • 5{{ s.investment.heading }}
  • +
  • 6{{ s.use_of_funds.heading }}
  • +
  • 7{{ s.opening_balance.heading }}
  • +
  • 8{{ s.financing.heading }}
  • +
  • 9{{ s.operations.heading }}
  • +
  • 10{{ s.revenue.heading }}
  • +
  • 11{{ s.annuals.heading }}
  • +
  • 12{{ s.cashflow_12m.heading }}
  • +
  • 13{{ s.metrics.heading }}
  • +
  • 14{{ s.sensitivity.heading }}Bank
  • + {% if s.narrative.marketing_concept %} +
  • 15{{ s.labels.market_analysis }} / Marketing
  • + {% endif %} +
  • 16{{ s.labels.risk_analysis }}
  • +
- - - - - - - - - - -
{{ s.labels.equity }}{{ s.financing.equity }}
{{ s.labels.loan }} ({{ s.financing.loan_pct }}){{ s.financing.loan }}
{{ s.labels.interest_rate }}{{ s.financing.interest_rate }}
{{ s.labels.loan_term }}{{ s.financing.term }}
{{ s.labels.monthly_payment }}{{ s.financing.monthly_payment }}
{{ s.labels.annual_debt_service }}{{ s.financing.annual_debt_service }}
{{ s.labels.ltv }}{{ s.financing.ltv }}
- -

{{ s.operations.heading }}

- - - - - - {% for item in s.operations['items'] %} - - - - - - {% endfor %} - - - - - - -
{{ s.labels.item }}{{ s.labels.monthly }}{{ s.labels.notes }}
{{ item.name }}{{ item.formatted_amount }}{{ item.info }}
{{ s.labels.total_monthly_opex }}{{ s.operations.monthly_total }}
-

{{ s.labels.annual_opex }}: {{ s.operations.annual_total }}

+{# ============================================================ #} +{# 1. EXECUTIVE SUMMARY #} +{# ============================================================ #} +
+

1   {{ s.executive_summary.heading }}

+
+
+
{{ s.labels.total_investment }}
+
{{ s.executive_summary.total_capex }}
+
+
+
{{ s.labels.equity_required }}
+
{{ s.executive_summary.equity }}
+
+
+
{{ s.labels.year1_revenue }}
+
{{ s.executive_summary.y1_revenue }}
+
+
+
{{ s.labels.year3_ebitda }}
+
{{ s.executive_summary.y3_ebitda }}
+
+
+
{{ s.labels.irr }}
+
{{ s.executive_summary.irr }}
+
+
+
{{ s.labels.payback_period }}
+
{{ s.executive_summary.payback }}
+
+
+

{{ s.labels.exec_paragraph }}

+
- -

{{ s.revenue.heading }}

- - - - - - - - - -
{{ s.labels.weighted_hourly_rate }}{{ s.revenue.weighted_rate }}
{{ s.labels.target_utilization }}{{ s.revenue.utilization }}
{{ s.labels.gross_monthly_revenue }}{{ s.revenue.gross_monthly }}
{{ s.labels.net_monthly_revenue }}{{ s.revenue.net_monthly }}
{{ s.labels.monthly_ebitda }}{{ s.revenue.ebitda_monthly }}
{{ s.labels.monthly_net_cf }}{{ s.revenue.net_cf_monthly }}
+{# ============================================================ #} +{# 2. FOUNDER / MANAGEMENT PROFILE #} +{# ============================================================ #} +
+

2   {{ s.labels.founder_profile }}

+ {% if s.narrative.founder_name %} +

{{ s.narrative.founder_name }}{% if s.narrative.legal_entity %}  —  {{ s.narrative.legal_entity }}{% endif %}

+ {% endif %} + {% if s.narrative.founder_background %} +

{{ s.narrative.founder_background }}

+ {% else %} +

+ [Gründerprofil: Beschreiben Sie hier Ihren beruflichen Hintergrund, relevante Erfahrungen im Sportbereich oder in der Gastronomie, unternehmerische Vorkenntnisse und Ihr persönliches Netzwerk. Banken möchten verstehen, warum Sie die richtige Person für dieses Vorhaben sind.] +

+ {% endif %} +
- -

{{ s.annuals.heading }}

- - - - - - {% for yr in s.annuals.years %} - - - - - - - - {% endfor %} - -
{{ s.labels.year }}{{ s.labels.revenue }}{{ s.labels.ebitda }}{{ s.labels.debt_service }}{{ s.labels.net_cf }}
{{ s.labels.year }} {{ yr.year }}{{ yr.revenue }}{{ yr.ebitda }}{{ yr.debt_service }}{{ yr.net_cf }}
+{# ============================================================ #} +{# 3. BUSINESS DESCRIPTION / VENTURE #} +{# ============================================================ #} +
+

3   {{ s.labels.venture_description }}

+ {% if s.narrative.facility_description %} +

{{ s.narrative.facility_description }}

+ {% else %} +

+ Das geplante Vorhaben ist eine {{ s.executive_summary.facility_type }}-Padelhalle mit + {{ s.executive_summary.courts }} Courts auf einer Nutzfläche von ca. {{ s.executive_summary.sqm }} m². + Die Anlage richtet sich an Freizeitsportler, Unternehmenskunden und organisierte Vereinsspieler im lokalen Einzugsgebiet. +

+ {% endif %} + {% if s.narrative.planned_location_address %} +

+ Geplanter Standort: {{ s.narrative.planned_location_address }} + {% if s.narrative.planned_opening_date %}  —  Eröffnung: {{ s.narrative.planned_opening_date }}{% endif %} +

+ {% endif %} +
- -

{{ s.metrics.heading }}

-
-
-
{{ s.labels.irr }}
-
{{ s.metrics.irr }}
+{# ============================================================ #} +{# 4. MARKET ANALYSIS (auto from DuckDB when available) #} +{# ============================================================ #} +
+

4   {{ s.labels.market_analysis }}

+ {% if s.market_data %} +

+ {{ s.market_data.city_name }}{% if s.market_data.country %}, {{ s.market_data.country }}{% endif %} +  —  + Marktdaten aus dem Padelnomics Analytics-Datensatz (Playtomic-Buchungsdaten). +

+
+ {% if s.market_data.padel_venue_count %} +
+
{{ s.market_data.padel_venue_count }}
+
Padel-Anlagen
+
+ {% endif %} + {% if s.market_data.venues_per_100k %} +
+
{{ "%.1f"|format(s.market_data.venues_per_100k) }}
+
{{ s.labels.venues_per_100k }}
+
+ {% endif %} + {% if s.market_data.median_occupancy_rate %} +
+
{{ "%.0f"|format(s.market_data.median_occupancy_rate * 100) }}%
+
{{ s.labels.median_occupancy }}
+
+ {% endif %} + {% if s.market_data.median_peak_rate %} +
+
{{ s.labels.currency_sym }}{{ "%.0f"|format(s.market_data.median_peak_rate) }}
+
{{ s.labels.median_peak_rate }}
+
+ {% endif %} + {% if s.market_data.median_offpeak_rate %} +
+
{{ s.labels.currency_sym }}{{ "%.0f"|format(s.market_data.median_offpeak_rate) }}
+
{{ s.labels.median_offpeak_rate }}
+
+ {% endif %} + {% if s.market_data.market_score %} +
+
{{ "%.0f"|format(s.market_data.market_score) }}/100
+
{{ s.labels.market_score }}
+
+ {% endif %}
-
-
{{ s.labels.moic }}
-
{{ s.metrics.moic }}
+ {% else %} +

+ [Marktanalyse: Beschreiben Sie die Nachfragesituation im Einzugsgebiet: Anzahl aktiver Padelspieler, bestehende Anlagen und deren Auslastung, Wettbewerbsumfeld, demographische Zielgruppe. Sofern eine Playtomic-Auswertung für Ihren Standort vorliegt, fügen Sie die Daten hier ein.] +

+ {% endif %} +
+ +{# ============================================================ #} +{# 5. INVESTMENT PLAN (CAPEX) #} +{# ============================================================ #} +
+

5   {{ s.investment.heading }}

+ + + + + + + + + + {% for item in s.investment['items'] %} + + + + + + {% endfor %} + + + + + + +
{{ s.labels.item }}{{ s.labels.amount }}{{ s.labels.notes }}
{{ item.name }}{{ item.formatted_amount }}{{ item.info }}
{{ s.labels.total_capex }}{{ s.investment.total }}
+

{{ s.labels.capex_stats }}

+
+ +{# ============================================================ #} +{# 6. USE OF FUNDS (Mittelverwendungsplan) #} +{# ============================================================ #} +
+

6   {{ s.use_of_funds.heading }}

+ + + + + + + + + + + {% for row in s.use_of_funds.rows %} + + + + + + + {% endfor %} + + + + + + + +
{{ s.labels.item }}{{ s.labels.total }}{{ s.labels.funded_by_loan }}{{ s.labels.funded_by_equity }}
{{ row.item }}{{ row.total }}{{ row.from_loan }}{{ row.from_equity }}
{{ s.labels.total }}{{ s.use_of_funds.total_capex }}{{ s.use_of_funds.total_loan }}{{ s.use_of_funds.total_equity }}
+
+ +{# ============================================================ #} +{# 7. OPENING BALANCE SHEET (Eröffnungsbilanz) #} +{# ============================================================ #} +
+

7   {{ s.opening_balance.heading }}

+
+
+
{{ s.labels.assets }}
+ + + + + + + + + +
{{ s.labels.fixed_assets }}{{ s.opening_balance.fixed_assets }}
{{ s.labels.working_capital }}{{ s.opening_balance.working_capital }}
{{ s.labels.total }}{{ s.opening_balance.total_assets }}
+
+
+
{{ s.labels.liabilities_equity }}
+ + + + + + + + + +
{{ s.labels.equity }}{{ s.opening_balance.equity }}
{{ s.labels.loan }}{{ s.opening_balance.loan }}
{{ s.labels.total }}{{ s.opening_balance.total_liabilities_equity }}
+
-
-
{{ s.labels.cash_on_cash }}
-
{{ s.metrics.cash_on_cash }}
+

+ {{ s.labels.equity_ratio }}: {{ s.opening_balance.equity_ratio }} +

+
+ +{# ============================================================ #} +{# 8. FINANCING STRUCTURE #} +{# ============================================================ #} +
+

8   {{ s.financing.heading }}

+
+
+
+ {{ s.labels.equity }} {{ (100 - (s.financing.loan_pct | replace('%','') | float)) | round(0) | int }}% +
+
+ {{ s.labels.loan }} {{ s.financing.loan_pct }} +
+
+
+
+
+ {{ s.labels.equity }}: {{ s.financing.equity }} +
+
+
+ {{ s.labels.loan }}: {{ s.financing.loan }} +
+
-
-
{{ s.labels.payback }}
-
{{ s.metrics.payback }}
+ + + + + + + + + + +
{{ s.labels.equity }}{{ s.financing.equity }}
{{ s.labels.loan }} ({{ s.financing.loan_pct }}){{ s.financing.loan }}
{{ s.labels.interest_rate }}{{ s.financing.interest_rate }}
{{ s.labels.loan_term }}{{ s.financing.term }}
{{ s.labels.monthly_payment }}{{ s.financing.monthly_payment }}
{{ s.labels.annual_debt_service }}{{ s.financing.annual_debt_service }}
{{ s.labels.ltv }}{{ s.financing.ltv }}
+
+ +{# ============================================================ #} +{# 9. OPERATING COSTS (OPEX) #} +{# ============================================================ #} +
+

9   {{ s.operations.heading }}

+ + + + + + + + + + {% for item in s.operations['items'] %} + + + + + + {% endfor %} + + + + + + +
{{ s.labels.item }}{{ s.labels.monthly }}{{ s.labels.notes }}
{{ item.name }}{{ item.formatted_amount }}{{ item.info }}
{{ s.labels.total_monthly_opex }}{{ s.operations.monthly_total }}
+

{{ s.labels.annual_opex }}: {{ s.operations.annual_total }}

+ {% if s.narrative.operations_concept %} +

Betriebskonzept

+

{{ s.narrative.operations_concept }}

+ {% endif %} + {% if s.narrative.staffing_plan %} +

Personalplanung

+

{{ s.narrative.staffing_plan }}

+ {% endif %} +
+ +{# ============================================================ #} +{# 10. REVENUE MODEL #} +{# ============================================================ #} +
+

10   {{ s.revenue.heading }}

+ + + + + + + + + +
{{ s.labels.weighted_hourly_rate }}{{ s.revenue.weighted_rate }}
{{ s.labels.target_utilization }}{{ s.revenue.utilization }}
{{ s.labels.gross_monthly_revenue }}{{ s.revenue.gross_monthly }}
{{ s.labels.net_monthly_revenue }}{{ s.revenue.net_monthly }}
{{ s.labels.monthly_ebitda }}{{ s.revenue.ebitda_monthly }}
{{ s.labels.monthly_net_cf }}{{ s.revenue.net_cf_monthly }}
+ {% if s.narrative.marketing_concept %} +

Marketingkonzept

+

{{ s.narrative.marketing_concept }}

+ {% endif %} +
+ +{# ============================================================ #} +{# 11. 5-YEAR PROJECTION (P&L) + CHART #} +{# ============================================================ #} +
+

11   {{ s.annuals.heading }}

+ {% if s.charts.pnl %} +
+ {{ s.charts.pnl }} +

Revenue, EBITDA and Net Cash Flow — 5-Year Projection

-
-
{{ s.labels.break_even_util }}
-
{{ s.metrics.break_even_util }}
+ {% endif %} + + + + + + + + + + + + {% for yr in s.annuals.years %} + + + + + + + + {% endfor %} + +
{{ s.labels.year }}{{ s.labels.revenue }}{{ s.labels.ebitda }}{{ s.labels.debt_service }}{{ s.labels.net_cf }}
{{ s.labels.year }} {{ yr.year }}{{ yr.revenue }}{{ yr.ebitda }}{{ yr.debt_service }}{{ yr.net_cf }}
+
+ +{# ============================================================ #} +{# 12. 12-MONTH CASH FLOW + CHART #} +{# ============================================================ #} +
+

12   {{ s.cashflow_12m.heading }}

+ {% if s.charts.cashflow %} +
+ {{ s.charts.cashflow }} +

Monthly NCF and Cumulative Cash Position — Year 1

-
-
{{ s.labels.ebitda_margin }}
-
{{ s.metrics.ebitda_margin }}
-
-
-
{{ s.labels.dscr_y3 }}
-
{{ s.metrics.dscr_y3 }}
-
-
-
{{ s.labels.yield_on_cost }}
-
{{ s.metrics.yield_on_cost }}
+ {% endif %} + + + + + + + + + + + + + + {% for m in s.cashflow_12m.months %} + + + + + + + + + + {% endfor %} + +
{{ s.labels.month }}{{ s.labels.revenue }}{{ s.labels.opex }}{{ s.labels.ebitda }}{{ s.labels.debt }}{{ s.labels.net_cf }}{{ s.labels.cumulative }}
{{ m.month }}{{ m.revenue }}{{ m.opex }}{{ m.ebitda }}{{ m.debt }}{{ m.ncf }}{{ m.cumulative }}
+
+ +{# ============================================================ #} +{# 13. KEY METRICS #} +{# ============================================================ #} +
+

13   {{ s.metrics.heading }}

+
+
+
{{ s.labels.irr }}
+
{{ s.metrics.irr }}
+
+
+
{{ s.labels.moic }}
+
{{ s.metrics.moic }}
+
+
+
{{ s.labels.cash_on_cash }}
+
{{ s.metrics.cash_on_cash }}
+
+
+
{{ s.labels.payback }}
+
{{ s.metrics.payback }}
+
+
+
{{ s.labels.break_even_util }}
+
{{ s.metrics.break_even_util }}
+
+
+
{{ s.labels.ebitda_margin }}
+
{{ s.metrics.ebitda_margin }}
+
+
+
{{ s.labels.dscr_y3 }}
+
{{ s.metrics.dscr_y3 }}
+
+
+
{{ s.labels.yield_on_cost }}
+
{{ s.metrics.yield_on_cost }}
+
- -

{{ s.cashflow_12m.heading }}

- - - - - - {% for m in s.cashflow_12m.months %} - - - - - - - - - - {% endfor %} - -
{{ s.labels.month }}{{ s.labels.revenue }}{{ s.labels.opex }}{{ s.labels.ebitda }}{{ s.labels.debt }}{{ s.labels.net_cf }}{{ s.labels.cumulative }}
{{ m.month }}{{ m.revenue }}{{ m.opex }}{{ m.ebitda }}{{ m.debt }}{{ m.ncf }}{{ m.cumulative }}
+{# ============================================================ #} +{# 14. SENSITIVITY ANALYSIS #} +{# ============================================================ #} +
+

14   {{ s.sensitivity.heading }}

- +

{{ s.sensitivity.util_heading }}

+ + + + + + + + + + + + {% for row in s.sensitivity.sens_rows %} + + + + + + + + {% endfor %} + +
{{ s.labels.utilization }}{{ s.labels.revenue }}{{ s.labels.monthly_ncf }}{{ s.labels.annual_ncf }}{{ s.labels.dscr }}
{{ row.util }}%{% if row.is_target %} ★{% endif %}{{ row.rev_fmt }}{{ row.ncf_fmt }}{{ row.annual_fmt }}{{ row.dscr_fmt }}
+

★ = target utilization  |  DSCR threshold: 1.20× (bank covenant)

+ +

{{ s.sensitivity.price_heading }}

+ + + + + + + + + + + {% for row in s.sensitivity.price_rows %} + + + + + + + {% endfor %} + +
{{ s.labels.price_delta }}{{ s.labels.hourly_rate }}{{ s.labels.revenue }}{{ s.labels.monthly_ncf }}
{% if row.delta > 0 %}+{% endif %}{{ row.delta }}%{% if row.is_base %} (base){% endif %}{{ row.adj_rate_fmt }}{{ row.rev_fmt }}{{ row.ncf_fmt }}
+
+ +{# ============================================================ #} +{# 15. RISK ANALYSIS #} +{# ============================================================ #} +
+

15   {{ s.labels.risk_analysis }}

+ +
+ Hoch +
+ Anlaufphase / Cashflow-Risiko + Die ersten 6–12 Betriebsmonate laufen unter Zielbetrieb. Entschärfung: Betriebsmittelreserve eingeplant, tilgungsfreie Anlaufjahre in der Finanzierung, konservative Anlaufkurve im Modell abgebildet. +
+
+ +
+ Hoch +
+ Baukostenüberschreitung + Umbau- und Haustechnikkosten können bei Bestandshallen erheblich abweichen. Entschärfung: 10–15 % Puffer im CAPEX-Plan, Festpreisverträge mit Generalunternehmer. +
+
+ +
+ Mittel +
+ Wettbewerbsrisiko / Angebotsübersättigung + Schnell wachsender Markt mit erhöhter Neueröffnungsaktivität. Entschärfung: Standortwahl in unterversorgtem Marktgebiet, differenziertes Serviceangebot. +
+
+ +
+ Mittel +
+ Zinsänderungsrisiko + Steigende Zinsen erhöhen den Kapitaldienst bei variabel verzinsten Darlehen. Entschärfung: Festzins-Option prüfen; Sensitivitätsanalyse zeigt Tragfähigkeit bis +150 Bp. +
+
+ +
+ Mittel +
+ Schlüsselpersonenrisiko + Ausfall des Gründers / Hallenleiters würde Betrieb kurzfristig belasten. Entschärfung: frühzeitige Einbindung eines erfahrenen Betriebsleiters, Kranken-/Unfallversicherung. +
+
+ +
+ Niedrig +
+ Markttrend-Risiko + Padel ist ein wachsendes Segment, aber kein strukturell gesicherter Markt. Entschärfung: Break-even bei {{ s.metrics.break_even_util }} Auslastung — weit unter typischer Marktauslastung; flexible Hallenkonzeption. +
+
+ +
+ Niedrig +
+ Regulatorische Risiken + Baugenehmigung, Nutzungsänderung, Lärmschutzauflagen. Entschärfung: Vorab-Gespräch mit Bauordnungsamt, Lärmschutzgutachten bereits in Planung. +
+
+
+ +{# ============================================================ #} +{# DISCLAIMER #} +{# ============================================================ #}
{{ s.labels.disclaimer }}