This commit is contained in:
2026-06-24 14:40:43 +00:00
parent 22b05a7a1b
commit 7058970f98
80 changed files with 6383 additions and 0 deletions
View File
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
def _group_thousands(value: str) -> str:
parts = []
while value:
parts.append(value[-3:])
value = value[:-3]
return ".".join(reversed(parts))
def number_de(value: Any, decimals: int = 1) -> str:
if value is None:
return "-"
try:
numeric = float(value)
except (TypeError, ValueError):
return "-"
sign = "-" if numeric < 0 else ""
numeric = abs(numeric)
raw = f"{numeric:.{decimals}f}"
integer, _, fraction = raw.partition(".")
grouped = _group_thousands(integer)
return f"{sign}{grouped},{fraction}" if decimals else f"{sign}{grouped}"
def currency_de(value: Any, currency: str = "EUR") -> str:
symbol = {"EUR": "", "USD": "$", "GBP": "£"}.get(currency, currency)
return f"{number_de(value, 2)} {symbol}"
def percent_de(value: Any, decimals: int = 1) -> str:
return f"{number_de(value, decimals)} %"
def delta_de(value: Any, decimals: int = 1, suffix: str = "%") -> str:
if value is None:
return "-"
numeric = float(value)
prefix = "+" if numeric > 0 else ""
return f"{prefix}{number_de(numeric, decimals)} {suffix}".strip()
def date_de(value: Any) -> str:
if not value:
return "-"
if isinstance(value, datetime):
dt = value
else:
try:
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return str(value)
return dt.strftime("%d.%m.%Y")
def clamp(value: Any, lower: float = 0, upper: float = 100) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return lower
return max(lower, min(upper, number))
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import os
from typing import Any
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from .renderer import ALLOWED_THEMES, pdf_filename, render_documents, render_pdf_with_gotenberg
from .theme_catalog import public_theme_catalog
from .validation import validate_report
app = FastAPI(
title="Monthly Report Renderer",
version="2.0.0",
description="Validiert Report-Payloads, rendert feste HTML-Templates und erzeugt PDFs via Gotenberg.",
)
@app.middleware("http")
async def limit_request_size(request: Request, call_next):
max_bytes = int(os.getenv("MAX_PAYLOAD_BYTES", "2097152"))
content_length = request.headers.get("content-length")
if content_length and int(content_length) > max_bytes:
return JSONResponse(status_code=413, content={"detail": "Payload too large"})
return await call_next(request)
@app.get("/health")
def health() -> dict[str, Any]:
return {"status": "ok", "service": "monthly-report-renderer", "version": "2.0.0", "theme_count": len(ALLOWED_THEMES)}
@app.get("/themes")
def themes() -> dict[str, Any]:
return {"themes": sorted(ALLOWED_THEMES), "catalog": public_theme_catalog()}
@app.post("/validate")
def validate(payload: dict[str, Any]) -> dict[str, Any]:
errors = validate_report(payload)
return {"valid": not errors, "errors": errors}
def _ensure_valid(payload: dict[str, Any]) -> None:
errors = validate_report(payload)
if errors:
raise HTTPException(status_code=422, detail={"message": "Invalid report payload", "errors": errors})
@app.post("/preview/html", response_class=HTMLResponse)
def preview_html(payload: dict[str, Any]) -> HTMLResponse:
_ensure_valid(payload)
html, _ = render_documents(payload)
return HTMLResponse(content=html, status_code=200)
@app.post("/render")
def render(payload: dict[str, Any]) -> Response:
_ensure_valid(payload)
try:
pdf = render_pdf_with_gotenberg(payload)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"PDF rendering failed: {exc}") from exc
filename = pdf_filename(payload)
return Response(
content=pdf,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Report-Version": payload["report"]["report_version"],
"X-Template-Version": payload["report"]["template_version"],
},
)
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import json
import os
from copy import deepcopy
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any
import requests
from jinja2 import Environment, FileSystemLoader, select_autoescape
from .formatting import clamp, currency_de, date_de, delta_de, number_de, percent_de
from .theme_catalog import THEMES, get_theme
BASE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = BASE_DIR / "templates"
ALLOWED_THEMES = set(THEMES)
jinja = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html", "xml", "j2"]),
trim_blocks=True,
lstrip_blocks=True,
)
jinja.filters["number_de"] = number_de
jinja.filters["currency_de"] = currency_de
jinja.filters["percent_de"] = percent_de
jinja.filters["delta_de"] = delta_de
jinja.filters["date_de"] = date_de
jinja.filters["clamp"] = clamp
def _prepare_trends(payload: dict[str, Any]) -> dict[str, Any]:
result = deepcopy(payload)
trends = result.get("trends", [])
max_revenue = max((float(item.get("revenue", 0)) for item in trends), default=1) or 1
for item in trends:
item["revenue_bar"] = round(clamp(float(item.get("revenue", 0)) / max_revenue * 100), 2)
item["occupancy_bar"] = round(clamp(item.get("occupancy_rate", 0)), 2)
return result
def render_documents(payload: dict[str, Any]) -> tuple[str, str]:
prepared = _prepare_trends(payload)
theme_id = prepared.get("design", {}).get("theme", "modern")
theme = get_theme(theme_id)
design = prepared.setdefault("design", {})
palette_mode = design.get("palette_mode")
if not palette_mode:
palette_mode = "custom" if theme.get("custom_palette_default") else "theme"
if palette_mode not in {"theme", "custom"}:
raise ValueError(f"Unknown palette mode: {palette_mode}")
if palette_mode == "theme":
design.update(theme["palette"])
design["palette_mode"] = palette_mode
prepared["_theme"] = theme
prepared["_theme"]["palette_mode"] = palette_mode
layout = theme["layout"]
context = {"data": prepared}
report_template = jinja.get_template(f"{layout}/report.html.j2")
local_footer = TEMPLATE_DIR / layout / "footer.html.j2"
footer_name = f"{layout}/footer.html.j2" if local_footer.exists() else "_shared/footer.html.j2"
footer_template = jinja.get_template(footer_name)
return report_template.render(**context), footer_template.render(**context)
def pdf_filename(payload: dict[str, Any]) -> str:
customer = payload["customer"]["id"].strip().lower().replace(" ", "-")
period = payload["report"]["period"]
return f"monatsreport-{customer}-{period['year']}-{period['month']:02d}.pdf"
def render_pdf_with_gotenberg(payload: dict[str, Any]) -> bytes:
index_html, footer_html = render_documents(payload)
gotenberg_url = os.getenv("GOTENBERG_URL", "http://gotenberg:3000").rstrip("/")
timeout = int(os.getenv("RENDER_TIMEOUT_SECONDS", "60"))
metadata = {
"Title": f"Monatsreport {payload['report']['period']['label']} - {payload['customer']['name']}",
"Author": payload["report"].get("publisher", "Monthly Report Kit"),
"Creator": "Monthly Report Kit / Gotenberg",
"Subject": "Automatisch erzeugter Monatsreport",
"Keywords": ["Monatsreport", payload["customer"]["id"], payload["report"]["period"]["label"]],
}
files = [
("files", ("index.html", index_html.encode("utf-8"), "text/html")),
("files", ("footer.html", footer_html.encode("utf-8"), "text/html")),
]
form = {
"printBackground": "true",
"preferCssPageSize": "true",
"generateDocumentOutline": "true",
"generateTaggedPdf": "true",
"failOnResourceLoadingFailed": "true",
"metadata": json.dumps(metadata, ensure_ascii=False),
}
response = requests.post(
f"{gotenberg_url}/forms/chromium/convert/html",
files=files,
data=form,
timeout=timeout,
)
if response.status_code != 200:
detail = response.text[:1000]
raise RuntimeError(f"Gotenberg returned HTTP {response.status_code}: {detail}")
return response.content
@@ -0,0 +1,131 @@
{
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "object",
"additionalProperties": false,
"properties": {
"headline": {
"type": "string",
"description": "Minimum characters: 8. Maximum characters: 110."
},
"text": {
"type": "string",
"description": "Minimum characters: 80. Maximum characters: 900."
},
"rating": {
"type": "string",
"enum": [
"positive",
"neutral",
"watch"
]
}
},
"required": [
"headline",
"text",
"rating"
]
},
"developments": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"description": "Minimum characters: 40. Maximum characters: 450."
},
"impact": {
"type": "string",
"enum": [
"positive",
"neutral",
"negative"
]
}
},
"required": [
"title",
"text",
"impact"
]
},
"description": "Minimum items: 2. Maximum items: 4."
},
"recommendations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"priority": {
"type": "integer",
"description": "Minimum value: 1. Maximum value: 3."
},
"category": {
"type": "string",
"description": "Minimum characters: 3. Maximum characters: 80."
},
"reason": {
"type": "string",
"description": "Minimum characters: 40. Maximum characters: 450."
},
"action": {
"type": "string",
"description": "Minimum characters: 30. Maximum characters: 450."
},
"expected_effect": {
"type": "string",
"description": "Minimum characters: 20. Maximum characters: 300."
}
},
"required": [
"priority",
"category",
"title",
"reason",
"action",
"expected_effect"
]
},
"description": "Minimum items: 1. Maximum items: 3."
},
"outlook": {
"type": "object",
"additionalProperties": false,
"properties": {
"headline": {
"type": "string",
"description": "Minimum characters: 5. Maximum characters: 100."
},
"text": {
"type": "string",
"description": "Minimum characters: 60. Maximum characters: 700."
}
},
"required": [
"headline",
"text"
]
},
"data_quality_notes": {
"type": "array",
"items": {
"type": "string",
"description": "Minimum characters: 5. Maximum characters: 240."
},
"description": "Maximum items: 4."
}
},
"required": [
"summary",
"developments",
"recommendations",
"outlook",
"data_quality_notes"
]
}
@@ -0,0 +1,157 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.invalid/schemas/claude-output.schema.json",
"title": "Claude Monthly Report Content",
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "object",
"additionalProperties": false,
"properties": {
"headline": {
"type": "string",
"minLength": 8,
"maxLength": 110
},
"text": {
"type": "string",
"minLength": 80,
"maxLength": 900
},
"rating": {
"type": "string",
"enum": [
"positive",
"neutral",
"watch"
]
}
},
"required": [
"headline",
"text",
"rating"
]
},
"developments": {
"type": "array",
"minItems": 2,
"maxItems": 4,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"minLength": 4,
"maxLength": 80
},
"text": {
"type": "string",
"minLength": 40,
"maxLength": 450
},
"impact": {
"type": "string",
"enum": [
"positive",
"neutral",
"negative"
]
}
},
"required": [
"title",
"text",
"impact"
]
}
},
"recommendations": {
"type": "array",
"minItems": 1,
"maxItems": 3,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"priority": {
"type": "integer",
"minimum": 1,
"maximum": 3
},
"category": {
"type": "string",
"minLength": 3,
"maxLength": 80
},
"title": {
"type": "string",
"minLength": 5,
"maxLength": 100
},
"reason": {
"type": "string",
"minLength": 40,
"maxLength": 450
},
"action": {
"type": "string",
"minLength": 30,
"maxLength": 450
},
"expected_effect": {
"type": "string",
"minLength": 20,
"maxLength": 300
}
},
"required": [
"priority",
"category",
"title",
"reason",
"action",
"expected_effect"
]
}
},
"outlook": {
"type": "object",
"additionalProperties": false,
"properties": {
"headline": {
"type": "string",
"minLength": 5,
"maxLength": 100
},
"text": {
"type": "string",
"minLength": 60,
"maxLength": 700
}
},
"required": [
"headline",
"text"
]
},
"data_quality_notes": {
"type": "array",
"maxItems": 4,
"items": {
"type": "string",
"minLength": 5,
"maxLength": 240
}
}
},
"required": [
"summary",
"developments",
"recommendations",
"outlook",
"data_quality_notes"
]
}
@@ -0,0 +1,508 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.invalid/schemas/report-payload.schema.json",
"title": "Monthly Report Render Payload",
"type": "object",
"additionalProperties": false,
"properties": {
"report": {
"type": "object",
"additionalProperties": false,
"properties": {
"report_version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+$"
},
"prompt_version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+$"
},
"template_version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+$"
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"publisher": {
"type": "string",
"minLength": 2,
"maxLength": 120
},
"period": {
"type": "object",
"additionalProperties": false,
"properties": {
"year": {
"type": "integer",
"minimum": 2020,
"maximum": 2100
},
"month": {
"type": "integer",
"minimum": 1,
"maximum": 12
},
"label": {
"type": "string",
"minLength": 4,
"maxLength": 40
},
"comparison_label": {
"type": "string",
"minLength": 4,
"maxLength": 60
}
},
"required": [
"year",
"month",
"label",
"comparison_label"
]
}
},
"required": [
"report_version",
"prompt_version",
"template_version",
"generated_at",
"publisher",
"period"
]
},
"customer": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{2,64}$"
},
"name": {
"type": "string",
"minLength": 2,
"maxLength": 160
},
"property_name": {
"type": "string",
"minLength": 2,
"maxLength": 160
},
"location": {
"type": "string",
"minLength": 2,
"maxLength": 160
},
"contact_line": {
"type": "string",
"minLength": 0,
"maxLength": 180
},
"logo_data_uri": {
"type": "string",
"pattern": "^data:image/(png|jpeg|svg\\+xml);base64,"
},
"logo_text": {
"type": "string",
"minLength": 1,
"maxLength": 5
}
},
"required": [
"id",
"name",
"property_name",
"location",
"contact_line",
"logo_text"
]
},
"design": {
"type": "object",
"additionalProperties": false,
"properties": {
"theme": {
"type": "string",
"enum": [
"alpine-sage",
"classic",
"editorial-ink",
"editorial-ivory",
"editorial-rose",
"executive-blue",
"executive-burgundy",
"executive-charcoal",
"forest-lodge",
"ledger-blue",
"ledger-green",
"ledger-sepia",
"magazine-citrus",
"magazine-cobalt",
"magazine-plum",
"midnight-gold",
"modern",
"nordic-light",
"nordic-night",
"ocean-breeze",
"poster-mono",
"poster-red",
"poster-yellow",
"soft-lavender",
"split-teal",
"split-terracotta",
"split-violet",
"sunset-coral"
]
},
"primary_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"secondary_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"accent_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"text_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"muted_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"surface_color": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"font_family": {
"type": "string",
"minLength": 3,
"maxLength": 100,
"pattern": "^[A-Za-z0-9 _,-]+$"
},
"palette_mode": {
"type": "string",
"enum": [
"theme",
"custom"
],
"description": "theme nutzt die feste Theme-Palette; custom nutzt die mitgelieferten Designfarben."
}
},
"required": [
"theme",
"primary_color",
"secondary_color",
"accent_color",
"text_color",
"muted_color",
"surface_color",
"font_family"
]
},
"metrics": {
"type": "object",
"additionalProperties": false,
"properties": {
"currency": {
"type": "string",
"enum": [
"EUR",
"USD",
"GBP"
]
},
"revenue": {
"type": "number",
"minimum": 0
},
"occupancy_rate": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"adr": {
"type": "number",
"minimum": 0
},
"revpar": {
"type": "number",
"minimum": 0
},
"bookings": {
"type": "integer",
"minimum": 0
},
"nights_sold": {
"type": "integer",
"minimum": 0
},
"average_stay": {
"type": "number",
"minimum": 0
},
"cancellation_rate": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"comparison": {
"type": "object",
"additionalProperties": false,
"properties": {
"revenue_delta_pct": {
"type": "number",
"minimum": -1000,
"maximum": 1000
},
"occupancy_delta_pp": {
"type": "number",
"minimum": -100,
"maximum": 100
},
"adr_delta_pct": {
"type": "number",
"minimum": -1000,
"maximum": 1000
},
"bookings_delta": {
"type": "integer",
"minimum": -100000,
"maximum": 100000
}
},
"required": [
"revenue_delta_pct",
"occupancy_delta_pp",
"adr_delta_pct",
"bookings_delta"
]
}
},
"required": [
"currency",
"revenue",
"occupancy_rate",
"adr",
"revpar",
"bookings",
"nights_sold",
"average_stay",
"cancellation_rate",
"comparison"
]
},
"trends": {
"type": "array",
"minItems": 2,
"maxItems": 12,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "string",
"minLength": 2,
"maxLength": 20
},
"revenue": {
"type": "number",
"minimum": 0
},
"occupancy_rate": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"adr": {
"type": "number",
"minimum": 0
}
},
"required": [
"label",
"revenue",
"occupancy_rate",
"adr"
]
}
},
"content": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.invalid/schemas/claude-output.schema.json",
"title": "Claude Monthly Report Content",
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "object",
"additionalProperties": false,
"properties": {
"headline": {
"type": "string",
"minLength": 8,
"maxLength": 110
},
"text": {
"type": "string",
"minLength": 80,
"maxLength": 900
},
"rating": {
"type": "string",
"enum": [
"positive",
"neutral",
"watch"
]
}
},
"required": [
"headline",
"text",
"rating"
]
},
"developments": {
"type": "array",
"minItems": 2,
"maxItems": 4,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"minLength": 4,
"maxLength": 80
},
"text": {
"type": "string",
"minLength": 40,
"maxLength": 450
},
"impact": {
"type": "string",
"enum": [
"positive",
"neutral",
"negative"
]
}
},
"required": [
"title",
"text",
"impact"
]
}
},
"recommendations": {
"type": "array",
"minItems": 1,
"maxItems": 3,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"priority": {
"type": "integer",
"minimum": 1,
"maximum": 3
},
"category": {
"type": "string",
"minLength": 3,
"maxLength": 80
},
"title": {
"type": "string",
"minLength": 5,
"maxLength": 100
},
"reason": {
"type": "string",
"minLength": 40,
"maxLength": 450
},
"action": {
"type": "string",
"minLength": 30,
"maxLength": 450
},
"expected_effect": {
"type": "string",
"minLength": 20,
"maxLength": 300
}
},
"required": [
"priority",
"category",
"title",
"reason",
"action",
"expected_effect"
]
}
},
"outlook": {
"type": "object",
"additionalProperties": false,
"properties": {
"headline": {
"type": "string",
"minLength": 5,
"maxLength": 100
},
"text": {
"type": "string",
"minLength": 60,
"maxLength": 700
}
},
"required": [
"headline",
"text"
]
},
"data_quality_notes": {
"type": "array",
"maxItems": 4,
"items": {
"type": "string",
"minLength": 5,
"maxLength": 240
}
}
},
"required": [
"summary",
"developments",
"recommendations",
"outlook",
"data_quality_notes"
]
},
"legal_note": {
"type": "string",
"minLength": 10,
"maxLength": 500
}
},
"required": [
"report",
"customer",
"design",
"metrics",
"trends",
"content",
"legal_note"
]
}
@@ -0,0 +1,3 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><style>
html{font-family:Arial,sans-serif;font-size:8px;color:{{ data.design.muted_color }};margin:0 14mm;-webkit-print-color-adjust:exact}body{margin:0}.footer{border-top:1px solid {{ data.design.primary_color }}33;padding-top:2.5mm;display:flex;justify-content:space-between}.page{font-weight:700;color:{{ data.design.primary_color }}}
</style></head><body><div class="footer"><span>{{ data.customer.name }} · {{ data.report.period.label }} · {{ data._theme.name }}</span><span class="page">Seite <span class="pageNumber"></span> / <span class="totalPages"></span></span></div></body></html>
@@ -0,0 +1,68 @@
{% macro logo(data, class_name='logo') -%}
<div class="{{ class_name }}">
{% if data.customer.logo_data_uri %}<img src="{{ data.customer.logo_data_uri }}" alt="Logo">{% else %}{{ data.customer.logo_text }}{% endif %}
</div>
{%- endmacro %}
{% macro kpis(data) -%}
<div class="kpis">
<article class="kpi"><div class="kpi-label">Umsatz</div><div class="kpi-value">{{ data.metrics.revenue|currency_de(data.metrics.currency) }}</div><div class="kpi-delta {{ 'up' if data.metrics.comparison.revenue_delta_pct >= 0 else 'down' }}">{{ data.metrics.comparison.revenue_delta_pct|delta_de(1, '%') }}</div></article>
<article class="kpi"><div class="kpi-label">Auslastung</div><div class="kpi-value">{{ data.metrics.occupancy_rate|percent_de }}</div><div class="kpi-delta {{ 'up' if data.metrics.comparison.occupancy_delta_pp >= 0 else 'down' }}">{{ data.metrics.comparison.occupancy_delta_pp|delta_de(1, 'PP') }}</div></article>
<article class="kpi"><div class="kpi-label">Ø Tagespreis</div><div class="kpi-value">{{ data.metrics.adr|currency_de(data.metrics.currency) }}</div><div class="kpi-delta {{ 'up' if data.metrics.comparison.adr_delta_pct >= 0 else 'down' }}">{{ data.metrics.comparison.adr_delta_pct|delta_de(1, '%') }}</div></article>
<article class="kpi"><div class="kpi-label">Buchungen</div><div class="kpi-value">{{ data.metrics.bookings|number_de(0) }}</div><div class="kpi-delta {{ 'up' if data.metrics.comparison.bookings_delta >= 0 else 'down' }}">{{ data.metrics.comparison.bookings_delta|delta_de(0, '') }} Buchungen</div></article>
</div>
{%- endmacro %}
{% macro facts(data) -%}
<div class="facts">
<div class="fact"><span>RevPAR</span><strong>{{ data.metrics.revpar|currency_de(data.metrics.currency) }}</strong></div>
<div class="fact"><span>Verkaufte Nächte</span><strong>{{ data.metrics.nights_sold|number_de(0) }}</strong></div>
<div class="fact"><span>Ø Aufenthalt</span><strong>{{ data.metrics.average_stay|number_de(1) }} Nächte</strong></div>
<div class="fact"><span>Stornoquote</span><strong>{{ data.metrics.cancellation_rate|percent_de }}</strong></div>
</div>
{%- endmacro %}
{% macro trend_bars(data) -%}
<div class="trend-bars">
{% for item in data.trends %}
<div class="trend-row">
<div class="trend-label">{{ item.label }}</div>
<div class="trend-track"><div class="trend-revenue" style="width:{{ item.revenue_bar }}%"></div><div class="trend-occ" style="width:{{ item.occupancy_bar }}%"></div></div>
<div class="trend-num">{{ item.revenue|currency_de(data.metrics.currency) }}</div>
<div class="trend-num">{{ item.occupancy_rate|percent_de }}</div>
</div>
{% endfor %}
</div>
{%- endmacro %}
{% macro trend_table(data) -%}
<table class="trend-table">
<thead><tr><th>Monat</th><th>Umsatz</th><th>Auslastung</th><th>Ø Tagespreis</th></tr></thead>
<tbody>{% for item in data.trends %}<tr><td>{{ item.label }}</td><td>{{ item.revenue|currency_de(data.metrics.currency) }}</td><td>{{ item.occupancy_rate|percent_de }}</td><td>{{ item.adr|currency_de(data.metrics.currency) }}</td></tr>{% endfor %}</tbody>
</table>
{%- endmacro %}
{% macro developments(data) -%}
<div class="developments">
{% for item in data.content.developments %}
<article class="development {{ item.impact }}"><div class="impact"></div><div><h3>{{ item.title }}</h3><p>{{ item.text }}</p></div></article>
{% endfor %}
</div>
{%- endmacro %}
{% macro recommendations(data) -%}
<div class="recommendations">
{% for item in data.content.recommendations|sort(attribute='priority') %}
<article class="recommendation"><div class="priority">{{ item.priority }}</div><div><div class="category">{{ item.category }}</div><h3>{{ item.title }}</h3><p><strong>Begründung:</strong> {{ item.reason }}</p><p><strong>Massnahme:</strong> {{ item.action }}</p><p class="effect"><strong>Erwarteter Effekt:</strong> {{ item.expected_effect }}</p></div></article>
{% endfor %}
</div>
{%- endmacro %}
{% macro outlook(data) -%}
<section class="outlook"><div class="kicker">Ausblick</div><h2>{{ data.content.outlook.headline }}</h2><p>{{ data.content.outlook.text }}</p></section>
{%- endmacro %}
{% macro notes(data) -%}
{% if data.content.data_quality_notes %}<section class="notes"><strong>Hinweise zur Datengrundlage</strong><ul>{% for note in data.content.data_quality_notes %}<li>{{ note }}</li>{% endfor %}</ul></section>{% endif %}
<div class="legal">{{ data.legal_note }}</div>
{%- endmacro %}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
html { font-family: Arial, sans-serif; font-size: 8px; color: #737b80; margin: 0 16mm; -webkit-print-color-adjust: exact; }
body { margin: 0; }
.footer { border-top: 1px solid #cfd6da; padding-top: 3mm; display: flex; justify-content: space-between; }
.page { color: {{ data.design.primary_color }}; font-weight: 700; }
</style>
</head>
<body><div class="footer"><span>{{ data.customer.name }} · Vertraulich</span><span class="page">{{ data.report.period.label }} · <span class="pageNumber"></span>/<span class="totalPages"></span></span></div></body>
</html>
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Monatsreport {{ data.report.period.label }} - {{ data.customer.name }}</title>
<style>
:root {
--primary: {{ data.design.primary_color }};
--accent: {{ data.design.accent_color }};
--text: {{ data.design.text_color }};
--muted: {{ data.design.muted_color }};
--surface: {{ data.design.surface_color }};
--line: #cfd6da;
--positive: #236a4f;
--negative: #9d3e3e;
}
@page { size: A4; margin: 15mm 16mm 19mm; }
* { box-sizing: border-box; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { margin: 0; font-family: {{ data.design.font_family }}, Georgia, serif; color: var(--text); font-size: 9.5pt; line-height: 1.5; }
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: 25pt; font-weight: 500; line-height: 1.12; margin-bottom: 2mm; }
h2 { font-size: 15pt; font-weight: 600; color: var(--primary); margin-bottom: 3mm; }
h3 { font-size: 10.5pt; margin-bottom: 1mm; }
p { margin-bottom: 3mm; }
.sans { font-family: Arial, sans-serif; }
.header { border-top: 2.5mm solid var(--primary); padding-top: 7mm; margin-bottom: 8mm; display: grid; grid-template-columns: 1fr auto; gap: 8mm; }
.brand { text-align: right; font-family: Arial, sans-serif; font-size: 8pt; color: var(--muted); }
.brand strong { display: block; color: var(--primary); font-size: 10pt; margin-bottom: 1mm; }
.period { font-family: Arial, sans-serif; text-transform: uppercase; letter-spacing: 1.4px; color: var(--accent); font-size: 8pt; font-weight: 700; }
.meta { display: flex; gap: 6mm; color: var(--muted); font-family: Arial, sans-serif; font-size: 7.5pt; }
.rule-heading { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 4mm; margin: 7mm 0 3mm; }
.rule-heading h2 { margin: 0; white-space: nowrap; }
.rule { height: .25mm; background: var(--line); }
.summary { padding: 5mm 0 5mm 5mm; border-left: 1.2mm solid var(--accent); break-inside: avoid; }
.summary h2 { color: var(--text); }
.summary p { margin: 0; }
.kpis { display: grid; grid-template-columns: repeat(4,1fr); border: .25mm solid var(--line); break-inside: avoid; }
.kpi { padding: 4mm; border-right: .25mm solid var(--line); min-height: 28mm; }
.kpi:last-child { border-right: 0; }
.kpi-label { font-family: Arial, sans-serif; font-size: 7pt; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); }
.kpi-value { color: var(--primary); font-size: 15pt; font-weight: 600; margin: 2.5mm 0 1mm; white-space: nowrap; }
.kpi-delta { font-family: Arial, sans-serif; font-size: 7pt; color: var(--muted); }
.facts { display: grid; grid-template-columns: repeat(4,1fr); background: var(--surface); padding: 3mm 4mm; font-family: Arial, sans-serif; font-size: 7.5pt; }
.facts strong { display: block; color: var(--primary); font-size: 9pt; margin-top: .5mm; }
table { width: 100%; border-collapse: collapse; break-inside: avoid; font-family: Arial, sans-serif; font-size: 7.6pt; }
th { text-align: left; color: var(--muted); font-size: 6.8pt; text-transform: uppercase; letter-spacing: .7px; padding: 2.5mm; border-bottom: .5mm solid var(--primary); }
td { padding: 2.5mm; border-bottom: .25mm solid var(--line); }
td.num, th.num { text-align: right; }
.developments { columns: 2; column-gap: 6mm; }
.development { break-inside: avoid; padding: 0 0 3mm 4mm; margin-bottom: 3mm; border-left: .8mm solid var(--line); }
.development.positive { border-left-color: var(--positive); }
.development.negative { border-left-color: var(--negative); }
.development p { margin: 0; }
.recommendation { break-inside: avoid; display: grid; grid-template-columns: 10mm 1fr; gap: 4mm; padding: 4mm 0; border-bottom: .25mm solid var(--line); }
.recommendation:last-child { border-bottom: 0; }
.number { width: 9mm; height: 9mm; border: .4mm solid var(--primary); color: var(--primary); display: flex; align-items: center; justify-content: center; align-self: start; border-radius: 50%; font-family: Arial, sans-serif; font-weight: 700; }
.category { color: var(--accent); font-family: Arial, sans-serif; font-size: 7pt; text-transform: uppercase; letter-spacing: .8px; font-weight: 700; }
.recommendation p { margin-bottom: 1.7mm; }
.outlook { margin-top: 6mm; padding: 5mm; background: var(--surface); border-top: 1mm solid var(--primary); break-inside: avoid; }
.outlook p { margin: 0; }
.notes { font-family: Arial, sans-serif; font-size: 7pt; color: var(--muted); margin-top: 4mm; }
.notes ul { margin: 1mm 0; padding-left: 4mm; }
.legal { font-family: Arial, sans-serif; font-size: 6.6pt; color: var(--muted); margin-top: 4mm; }
</style>
</head>
<body>
<header class="header">
<div>
<div class="period">{{ data.report.period.label }}</div>
<h1>Monatsreport</h1>
<div class="meta"><span>{{ data.customer.property_name }}</span><span>{{ data.customer.location }}</span><span>{{ data.report.generated_at|date_de }}</span></div>
</div>
<div class="brand"><strong>{{ data.customer.name }}</strong>{{ data.customer.contact_line }}</div>
</header>
<section class="summary">
<div class="period">Kurzfazit</div>
<h2>{{ data.content.summary.headline }}</h2>
<p>{{ data.content.summary.text }}</p>
</section>
<div class="rule-heading"><h2>Kennzahlen</h2><div class="rule"></div></div>
<section class="kpis">
<div class="kpi"><div class="kpi-label">Umsatz</div><div class="kpi-value">{{ data.metrics.revenue|currency_de(data.metrics.currency) }}</div><div class="kpi-delta">{{ data.metrics.comparison.revenue_delta_pct|delta_de(1, '%') }}</div></div>
<div class="kpi"><div class="kpi-label">Auslastung</div><div class="kpi-value">{{ data.metrics.occupancy_rate|percent_de }}</div><div class="kpi-delta">{{ data.metrics.comparison.occupancy_delta_pp|delta_de(1, 'PP') }}</div></div>
<div class="kpi"><div class="kpi-label">Ø Tagespreis</div><div class="kpi-value">{{ data.metrics.adr|currency_de(data.metrics.currency) }}</div><div class="kpi-delta">{{ data.metrics.comparison.adr_delta_pct|delta_de(1, '%') }}</div></div>
<div class="kpi"><div class="kpi-label">Buchungen</div><div class="kpi-value">{{ data.metrics.bookings|number_de(0) }}</div><div class="kpi-delta">{{ data.metrics.comparison.bookings_delta|delta_de(0, '') }} Buchungen</div></div>
</section>
<section class="facts">
<div>RevPAR<strong>{{ data.metrics.revpar|currency_de(data.metrics.currency) }}</strong></div>
<div>Verkaufte Nächte<strong>{{ data.metrics.nights_sold|number_de(0) }}</strong></div>
<div>Ø Aufenthalt<strong>{{ data.metrics.average_stay|number_de(1) }} Nächte</strong></div>
<div>Stornoquote<strong>{{ data.metrics.cancellation_rate|percent_de }}</strong></div>
</section>
<div class="rule-heading"><h2>Monatsverlauf</h2><div class="rule"></div></div>
<table>
<thead><tr><th>Monat</th><th class="num">Umsatz</th><th class="num">Auslastung</th><th class="num">Ø Tagespreis</th></tr></thead>
<tbody>
{% for item in data.trends %}<tr><td>{{ item.label }}</td><td class="num">{{ item.revenue|currency_de(data.metrics.currency) }}</td><td class="num">{{ item.occupancy_rate|percent_de }}</td><td class="num">{{ item.adr|currency_de(data.metrics.currency) }}</td></tr>{% endfor %}
</tbody>
</table>
<div class="rule-heading"><h2>Wichtige Entwicklungen</h2><div class="rule"></div></div>
<section class="developments">
{% for item in data.content.developments %}<article class="development {{ item.impact }}"><h3>{{ item.title }}</h3><p>{{ item.text }}</p></article>{% endfor %}
</section>
<div class="rule-heading"><h2>Empfehlungen</h2><div class="rule"></div></div>
<section>
{% for item in data.content.recommendations|sort(attribute='priority') %}
<article class="recommendation">
<div class="number">{{ item.priority }}</div>
<div><div class="category">{{ item.category }}</div><h3>{{ item.title }}</h3><p><strong>Begründung:</strong> {{ item.reason }}</p><p><strong>Massnahme:</strong> {{ item.action }}</p><p><strong>Erwarteter Effekt:</strong> {{ item.expected_effect }}</p></div>
</article>
{% endfor %}
</section>
<section class="outlook"><div class="period">Ausblick</div><h2>{{ data.content.outlook.headline }}</h2><p>{{ data.content.outlook.text }}</p></section>
{% if data.content.data_quality_notes %}<section class="notes"><strong>Hinweise zur Datengrundlage</strong><ul>{% for note in data.content.data_quality_notes %}<li>{{ note }}</li>{% endfor %}</ul></section>{% endif %}
<div class="legal">{{ data.legal_note }}</div>
</body>
</html>
@@ -0,0 +1,10 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#d8e0e5;--pos:#25745b;--neg:#a84444}@page{size:A4;margin:12mm 13mm 19mm}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:9pt/1.46 {{data.design.font_family}},Arial,sans-serif;background:#fff}h1,h2,h3,p{margin-top:0}h1{font-size:24pt;margin:0;line-height:1.05}h2{font-size:13.5pt;margin-bottom:3mm}h3{font-size:10pt;margin-bottom:1mm}p{margin-bottom:2.5mm}.header{background:linear-gradient(125deg,var(--p),var(--s));color:#fff;border-radius:3mm;padding:7mm;margin-bottom:5mm;display:grid;grid-template-columns:1fr auto;gap:8mm;break-inside:avoid}.header-left{display:flex;gap:4mm;align-items:center}.logo{width:14mm;height:14mm;background:#fff;color:var(--p);border-radius:2mm;display:flex;align-items:center;justify-content:center;font-weight:900;overflow:hidden}.logo img{width:100%;height:100%;object-fit:contain;padding:1mm}.brand{font-weight:700}.sub{font-size:7.5pt;opacity:.78}.header-right{text-align:right}.period{font-size:11pt;font-weight:800}.kicker,.kpi-label,.category{font-size:6.8pt;text-transform:uppercase;letter-spacing:.9px;font-weight:800;color:var(--m)}.header .kicker{color:#ffffffb8}.summary{display:grid;grid-template-columns:1fr 42mm;gap:4mm;margin-bottom:5mm}.summary-main{padding:5mm;border:1px solid var(--line);border-radius:2mm}.summary-main h2{font-size:15pt}.summary-score{background:var(--bg);border-radius:2mm;padding:4mm;display:flex;flex-direction:column;justify-content:center;text-align:center}.summary-score strong{font-size:20pt;color:var(--p)}.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:3mm;margin-bottom:3mm}.kpi{padding:4mm;background:var(--bg);border-top:1.2mm solid var(--p);min-height:29mm;break-inside:avoid}.kpi-value{font-size:15pt;font-weight:800;color:var(--p);margin:2mm 0;white-space:nowrap}.kpi-delta{font-size:7pt}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:grid;grid-template-columns:repeat(4,1fr);gap:3mm;margin-bottom:6mm}.fact{border:1px solid var(--line);padding:2.5mm 3mm}.fact span{display:block;font-size:6.8pt;color:var(--m)}.fact strong{display:block;margin-top:.5mm}.grid{display:grid;grid-template-columns:1.18fr .82fr;gap:5mm;align-items:start;break-before:page;break-inside:avoid}.panel{border:1px solid var(--line);border-radius:2mm;padding:4mm;margin-bottom:5mm;break-inside:avoid}.panel-head{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding-bottom:2mm;margin-bottom:3mm}.panel-head h2{margin:0}.trend-bars{display:grid;gap:2mm}.trend-row{display:grid;grid-template-columns:17mm 1fr 27mm 21mm;gap:2mm;align-items:center;font-size:7.2pt}.trend-label{font-weight:700}.trend-track{height:4mm;background:#e7ecef;position:relative;border-radius:1mm;overflow:hidden}.trend-revenue{height:100%;background:var(--p)}.trend-occ{height:1.2mm;background:var(--a);position:absolute;bottom:0;left:0}.trend-num{text-align:right}.developments{display:grid;gap:2.5mm}.development{display:grid;grid-template-columns:2mm 1fr;gap:2.5mm;padding-bottom:2.5mm;border-bottom:1px solid var(--line);break-inside:avoid}.impact{background:var(--m)}.positive .impact{background:var(--pos)}.negative .impact{background:var(--neg)}.development p{margin:0}.recommendations{display:grid;gap:3mm}.recommendation{display:grid;grid-template-columns:8mm 1fr;gap:3mm;background:var(--bg);padding:3.5mm;break-inside:avoid}.priority{width:7mm;height:7mm;border-radius:50%;background:var(--p);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800}.recommendation p{margin-bottom:1.4mm}.effect{border-top:1px solid #00000010;padding-top:1.5mm}.outlook{padding:5mm;background:var(--p);color:#fff;border-radius:2mm;break-inside:avoid}.outlook .kicker{color:#ffffffb8}.outlook h2{margin-bottom:1.5mm}.outlook p{margin:0}.notes{font-size:6.8pt;color:var(--m);margin-top:4mm}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font-size:6.3pt;color:var(--m);margin-top:2mm}
</style></head><body>
<header class="header"><div class="header-left">{{ui.logo(data)}}<div><div class="brand">{{data.customer.name}}</div><div class="sub">{{data.customer.property_name}} · {{data.customer.location}}</div></div></div><div class="header-right"><div class="kicker">Performance Dashboard</div><div class="period">{{data.report.period.label}}</div><div class="sub">Erstellt {{data.report.generated_at|date_de}}</div></div></header>
<section class="summary"><div class="summary-main"><div class="kicker">Management Summary</div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></div><div class="summary-score"><span class="kicker">Status</span><strong>{% if data.content.summary.rating=='positive' %}↑{% elif data.content.summary.rating=='watch' %}!{% else %}•{% endif %}</strong><span>{{data.content.summary.rating}}</span></div></section>
{{ui.kpis(data)}}{{ui.facts(data)}}
<div class="grid"><div><section class="panel"><div class="panel-head"><h2>Monatsverlauf</h2><span class="kicker">{{data.report.period.comparison_label}}</span></div>{{ui.trend_bars(data)}}</section><section class="panel"><div class="panel-head"><h2>Wichtige Entwicklungen</h2></div>{{ui.developments(data)}}</section></div><div><section class="panel"><div class="panel-head"><h2>Prioritäten</h2></div>{{ui.recommendations(data)}}</section>{{ui.outlook(data)}}</div></div>
{{ui.notes(data)}}
</body></html>
@@ -0,0 +1,11 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#cfcac1;--pos:#2e7259;--neg:#9a4747}@page{size:A4;margin:14mm 16mm 19mm}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:9.6pt/1.55 {{data.design.font_family}},Georgia,serif}h1,h2,h3,p{margin-top:0}h1{font-size:38pt;line-height:.94;font-weight:500;letter-spacing:-1.5px;margin:0}h2{font-size:17pt;font-weight:500;margin-bottom:3mm}h3{font-size:11pt;margin-bottom:1mm}p{margin-bottom:3mm}.mast{display:grid;grid-template-columns:1fr auto;align-items:center;border-top:2mm solid var(--p);border-bottom:1px solid var(--line);padding:4mm 0;margin-bottom:9mm}.brand{font:700 8pt Arial,sans-serif;text-transform:uppercase;letter-spacing:1.2px}.issue{text-align:right;font:7pt Arial,sans-serif;color:var(--m)}.issue strong{display:block;color:var(--p);font-size:10pt}.hero{display:grid;grid-template-columns:1.1fr .9fr;gap:10mm;align-items:end;margin-bottom:8mm}.logo{width:13mm;height:13mm;border:1px solid var(--p);float:left;margin-right:3mm;display:flex;align-items:center;justify-content:center;font:800 9pt Arial,sans-serif;color:var(--p);overflow:hidden}.logo img{width:100%;height:100%;object-fit:contain;padding:1mm}.deck{border-top:1mm solid var(--a);padding-top:4mm}.deck .kicker,.kicker,.kpi-label,.category{font:700 6.8pt/1.2 Arial,sans-serif;text-transform:uppercase;letter-spacing:1px;color:var(--m)}.deck h2{font-size:16pt}.deck p{margin:0}.kpis{display:grid;grid-template-columns:repeat(4,1fr);margin:8mm 0 4mm;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.kpi{padding:4mm 4mm 4mm 0}.kpi+.kpi{border-left:1px solid var(--line);padding-left:4mm}.kpi-value{font-size:16pt;color:var(--p);margin:2mm 0;white-space:nowrap}.kpi-delta{font:7pt Arial,sans-serif}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:flex;justify-content:space-between;gap:4mm;font:7pt Arial,sans-serif;color:var(--m);margin-bottom:9mm}.fact strong{color:var(--t);margin-left:1mm}.headline{display:flex;align-items:center;gap:4mm;margin:7mm 0 4mm}.headline h2{margin:0;white-space:nowrap}.headline:after{content:"";height:1px;background:var(--line);flex:1}.trend-table{width:100%;border-collapse:collapse;font:7.5pt Arial,sans-serif;break-inside:avoid}.trend-table th{text-align:left;text-transform:uppercase;letter-spacing:.7px;color:var(--m);font-size:6.5pt;border-bottom:1mm solid var(--p);padding:2mm}.trend-table td{padding:2.2mm;border-bottom:1px solid var(--line)}.trend-table th:not(:first-child),.trend-table td:not(:first-child){text-align:right}.editorial-grid{display:grid;grid-template-columns:1fr 1fr;gap:8mm;align-items:start;break-before:page;break-inside:avoid}.developments{columns:1}.development{display:grid;grid-template-columns:2mm 1fr;gap:3mm;margin-bottom:4mm;break-inside:avoid}.impact{background:var(--m)}.positive .impact{background:var(--pos)}.negative .impact{background:var(--neg)}.development p{margin:0}.recommendations{display:grid;gap:4mm}.recommendation{border-top:1px solid var(--line);padding-top:3mm;display:grid;grid-template-columns:8mm 1fr;gap:3mm;break-inside:avoid}.priority{font-size:20pt;color:var(--a);line-height:1}.recommendation p{margin-bottom:1.4mm}.effect{font-style:italic}.outlook{margin-top:8mm;background:var(--bg);padding:6mm;border-left:1.2mm solid var(--p);break-inside:avoid}.outlook h2{margin-bottom:2mm}.outlook p{margin:0}.notes{font:6.8pt Arial,sans-serif;color:var(--m);margin-top:4mm}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font:6.3pt Arial,sans-serif;color:var(--m);margin-top:2mm}
</style></head><body>
<header class="mast"><div class="brand">{{data.customer.name}} · Monthly Review</div><div class="issue"><strong>{{data.report.period.label}}</strong>{{data.customer.location}}</div></header>
<section class="hero"><div><div>{{ui.logo(data)}}<div class="kicker">{{data.customer.property_name}}</div></div><h1>Monatsreport</h1></div><div class="deck"><div class="kicker">Analyse des Monats</div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></div></section>
{{ui.kpis(data)}}{{ui.facts(data)}}
<div class="headline"><h2>Zahlen im Verlauf</h2></div>{{ui.trend_table(data)}}
<div class="editorial-grid"><section><div class="headline"><h2>Entwicklungen</h2></div>{{ui.developments(data)}}</section><section><div class="headline"><h2>Empfehlungen</h2></div>{{ui.recommendations(data)}}</section></div>
{{ui.outlook(data)}}{{ui.notes(data)}}
</body></html>
@@ -0,0 +1,12 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#cbd2ce;--pos:#247159;--neg:#9e4545}@page{size:A4;margin:13mm 14mm 19mm}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:8.7pt/1.45 {{data.design.font_family}},"Courier New",monospace}h1,h2,h3,p{margin-top:0}h1{font-size:24pt;letter-spacing:-1px;margin:0}h2{font-size:12pt;margin-bottom:3mm;text-transform:uppercase;letter-spacing:.6px}h3{font-size:9.3pt;margin-bottom:1mm}p{margin-bottom:2.3mm}.header{border:1px solid var(--p);display:grid;grid-template-columns:1fr 62mm;margin-bottom:5mm}.header-main{padding:5mm}.header-meta{background:var(--p);color:#fff;padding:5mm}.brandline{display:flex;align-items:center;gap:3mm;margin-bottom:4mm}.logo{width:10mm;height:10mm;border:1px solid var(--p);display:flex;align-items:center;justify-content:center;font-weight:800;overflow:hidden}.logo img{width:100%;height:100%;object-fit:contain;padding:.8mm}.label,.kicker,.kpi-label,.category{font-size:6.5pt;text-transform:uppercase;letter-spacing:.8px;color:var(--m);font-weight:700}.header-meta .label{color:#ffffffad}.header-meta strong{display:block;font-size:13pt;margin:1mm 0 4mm}.summary{border:1px solid var(--line);padding:4mm;margin-bottom:4mm;display:grid;grid-template-columns:26mm 1fr;gap:4mm}.summary-code{font-size:18pt;color:var(--a);font-weight:800}.summary h2{font-size:13pt;text-transform:none}.summary p{margin:0}.kpis{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);margin-bottom:0}.kpi{padding:3.5mm;border-right:1px solid var(--line)}.kpi:last-child{border-right:0}.kpi-value{font-size:13pt;font-weight:800;color:var(--p);margin:2mm 0;white-space:nowrap}.kpi-delta{font-size:6.7pt}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);border-top:0;margin-bottom:6mm}.fact{padding:2.5mm 3.5mm;border-right:1px solid var(--line)}.fact:last-child{border-right:0}.fact span{display:block;font-size:6.4pt;color:var(--m)}.fact strong{display:block;margin-top:.5mm}.block{margin-bottom:6mm}.block-head{display:flex;justify-content:space-between;align-items:center;background:var(--bg);border-left:1.2mm solid var(--p);padding:2.5mm 3mm;margin-bottom:2mm}.block-head h2{margin:0}.trend-table{width:100%;border-collapse:collapse;font-size:7.3pt}.trend-table th,.trend-table td{padding:2mm;border:1px solid var(--line)}.trend-table th{text-align:left;background:var(--bg);color:var(--m);font-size:6.3pt;text-transform:uppercase}.trend-table th:not(:first-child),.trend-table td:not(:first-child){text-align:right}.analysis-table{width:100%;border-collapse:collapse}.analysis-table td{border-bottom:1px solid var(--line);padding:3mm 2mm;vertical-align:top}.analysis-table td:first-child{width:7mm;font-weight:800;color:var(--a)}.analysis-table td:nth-child(2){width:42mm;font-weight:800}.analysis-table p{margin:0}.recommendations{display:grid;gap:0;border-top:1px solid var(--line)}.recommendation{display:grid;grid-template-columns:10mm 1fr;gap:3mm;border-bottom:1px solid var(--line);padding:3mm 0;break-inside:avoid}.priority{font-size:14pt;color:var(--p);font-weight:800}.recommendation p{margin-bottom:1.2mm}.effect{background:var(--bg);padding:1.5mm}.outlook{border:1px solid var(--p);padding:4mm;break-inside:avoid}.outlook h2{text-transform:none}.outlook p{margin:0}.notes{font-size:6.5pt;color:var(--m);margin-top:3mm}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font-size:6pt;color:var(--m);margin-top:2mm}.developments{display:none}
</style></head><body>
<header class="header"><div class="header-main"><div class="brandline">{{ui.logo(data)}}<div><strong>{{data.customer.name}}</strong><div class="label">{{data.customer.property_name}} / {{data.customer.location}}</div></div></div><h1>MONATSREPORT</h1></div><div class="header-meta"><div class="label">Berichtsperiode</div><strong>{{data.report.period.label}}</strong><div class="label">Erstellt</div><div>{{data.report.generated_at|date_de}}</div><div class="label" style="margin-top:3mm">Template</div><div>{{data._theme.id}}</div></div></header>
<section class="summary"><div class="summary-code">00</div><div><div class="kicker">Executive Summary</div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></div></section>
{{ui.kpis(data)}}{{ui.facts(data)}}
<section class="block"><div class="block-head"><h2>01 / Monatsverlauf</h2><span class="label">Vergleich {{data.report.period.comparison_label}}</span></div>{{ui.trend_table(data)}}</section>
<section class="block"><div class="block-head"><h2>02 / Entwicklungen</h2><span class="label">Analyse</span></div><table class="analysis-table">{% for item in data.content.developments %}<tr><td>{{'%02d'|format(loop.index)}}</td><td>{{item.title}}</td><td><p>{{item.text}}</p></td></tr>{% endfor %}</table></section>
<section class="block"><div class="block-head"><h2>03 / Massnahmenliste</h2><span class="label">Priorisiert</span></div>{{ui.recommendations(data)}}</section>
{{ui.outlook(data)}}{{ui.notes(data)}}
</body></html>
@@ -0,0 +1,11 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#d7dce2;--pos:#24725a;--neg:#a24048}@page{size:A4;margin:12mm 14mm 19mm}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:9.1pt/1.47 {{data.design.font_family}},Arial,sans-serif}h1,h2,h3,p{margin-top:0}h1{font-size:39pt;line-height:.88;letter-spacing:-2px;margin:0;text-transform:uppercase}h2{font-size:16pt;line-height:1.08;margin-bottom:3mm}h3{font-size:10.5pt;margin-bottom:1mm}p{margin-bottom:2.6mm}.cover{display:grid;grid-template-columns:1fr 53mm;min-height:73mm;margin-bottom:6mm;break-inside:avoid}.cover-main{background:var(--p);color:#fff;padding:7mm;position:relative;overflow:hidden}.cover-main:after{content:"";position:absolute;width:60mm;height:60mm;border:10mm solid #ffffff0f;border-radius:50%;right:-24mm;bottom:-30mm}.cover-side{background:var(--a);padding:6mm 5mm;display:flex;flex-direction:column;justify-content:space-between;color:var(--t)}.logo{width:14mm;height:14mm;background:#fff;color:var(--p);display:flex;align-items:center;justify-content:center;font-weight:900;overflow:hidden}.logo img{width:100%;height:100%;object-fit:contain;padding:1mm}.brand{margin-top:4mm;font-weight:800}.location{font-size:7.5pt;opacity:.76}.cover-title{position:absolute;left:7mm;bottom:7mm;z-index:1}.cover-title .kicker{color:#ffffffb8}.issue-num{font-size:36pt;font-weight:900;line-height:1;color:var(--p)}.issue-label{font-size:7pt;text-transform:uppercase;letter-spacing:1px;font-weight:800}.kicker,.kpi-label,.category{font-size:6.7pt;text-transform:uppercase;letter-spacing:1.1px;font-weight:900;color:var(--m)}.summary{display:grid;grid-template-columns:52mm 1fr;gap:6mm;padding:5mm 0;border-bottom:1px solid var(--line);margin-bottom:5mm}.summary-mark{font-size:22pt;font-weight:900;color:var(--a);line-height:1}.summary h2{font-size:17pt}.summary p{margin:0}.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:2mm;margin-bottom:3mm}.kpi{padding:4mm;background:var(--bg);border-bottom:1.2mm solid var(--p);min-height:29mm}.kpi:nth-child(2n){border-bottom-color:var(--a)}.kpi-value{font-size:16pt;font-weight:900;margin:2mm 0;color:var(--p);white-space:nowrap}.kpi-delta{font-size:7pt}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:grid;grid-template-columns:repeat(4,1fr);gap:2mm;margin-bottom:7mm}.fact{padding:2.2mm 3mm;border:1px solid var(--line)}.fact span{display:block;font-size:6.7pt;color:var(--m)}.fact strong{display:block;margin-top:.7mm}.section-head{display:grid;grid-template-columns:13mm 1fr;gap:3mm;align-items:center;margin:7mm 0 3mm}.section-no{font-size:18pt;font-weight:900;color:var(--a)}.section-head h2{margin:0;border-bottom:1px solid var(--line);padding-bottom:2mm}.trend-bars{display:grid;gap:2mm}.trend-row{display:grid;grid-template-columns:18mm 1fr 28mm 22mm;gap:2.5mm;align-items:center;font-size:7.4pt}.trend-label{font-weight:800}.trend-track{height:5mm;background:var(--bg);position:relative;overflow:hidden}.trend-revenue{height:100%;background:var(--p)}.trend-occ{height:1.3mm;background:var(--a);position:absolute;left:0;bottom:0}.trend-num{text-align:right}.feature-grid{display:grid;grid-template-columns:1fr 1fr;gap:5mm;break-before:page;break-inside:avoid}.developments,.recommendations{display:grid;gap:3mm}.development{background:var(--bg);padding:3.5mm;break-inside:avoid;border-top:1mm solid var(--m)}.development.positive{border-top-color:var(--pos)}.development.negative{border-top-color:var(--neg)}.development{display:grid;grid-template-columns:0 1fr;gap:0}.development .impact{display:none}.development p{margin:0}.recommendation{display:grid;grid-template-columns:9mm 1fr;gap:3mm;border:1px solid var(--line);padding:3.5mm;break-inside:avoid}.priority{font-size:19pt;font-weight:900;color:var(--a)}.recommendation p{margin-bottom:1.3mm}.effect{color:var(--p);font-weight:600}.outlook{margin-top:7mm;background:linear-gradient(110deg,var(--p),var(--s));color:#fff;padding:6mm;break-inside:avoid}.outlook .kicker{color:#ffffffb8}.outlook p{margin:0}.notes{font-size:6.8pt;color:var(--m);margin-top:4mm}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font-size:6.2pt;color:var(--m);margin-top:2mm}
</style></head><body>
<section class="cover"><div class="cover-main">{{ui.logo(data)}}<div class="brand">{{data.customer.name}}</div><div class="location">{{data.customer.property_name}} · {{data.customer.location}}</div><div class="cover-title"><div class="kicker">Performance / Strategie / Ausblick</div><h1>Monats<br>report</h1></div></div><div class="cover-side"><div><div class="issue-label">Ausgabe</div><div class="issue-num">{{'%02d'|format(data.report.period.month)}}</div></div><div><div class="issue-label">Zeitraum</div><strong>{{data.report.period.label}}</strong><div class="location">{{data.report.generated_at|date_de}}</div></div></div></section>
<section class="summary"><div><div class="summary-mark">“</div><div class="kicker">Kurzfazit</div></div><div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></div></section>
{{ui.kpis(data)}}{{ui.facts(data)}}
<div class="section-head"><div class="section-no">01</div><h2>Monatsverlauf</h2></div>{{ui.trend_bars(data)}}
<div class="feature-grid"><section><div class="section-head"><div class="section-no">02</div><h2>Entwicklungen</h2></div>{{ui.developments(data)}}</section><section><div class="section-head"><div class="section-no">03</div><h2>Empfehlungen</h2></div>{{ui.recommendations(data)}}</section></div>
{{ui.outlook(data)}}{{ui.notes(data)}}
</body></html>
@@ -0,0 +1,11 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#d9dfdf;--pos:#27745b;--neg:#a54a4a}@page{size:A4;margin:14mm 15mm 19mm}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:9.3pt/1.52 {{data.design.font_family}},Arial,sans-serif}h1,h2,h3,p{margin-top:0}h1{font-size:36pt;line-height:.98;letter-spacing:-1.7px;margin:0}h2{font-size:15pt;line-height:1.15;margin-bottom:3mm}h3{font-size:10.5pt;margin-bottom:1mm}p{margin-bottom:3mm}.top{display:flex;justify-content:space-between;align-items:flex-start;border-top:1.8mm solid var(--p);padding-top:6mm;margin-bottom:12mm}.brand{display:flex;gap:3mm;align-items:center}.logo{width:11mm;height:11mm;border:1px solid var(--p);display:flex;align-items:center;justify-content:center;font-weight:800;color:var(--p);overflow:hidden}.logo img{width:100%;height:100%;object-fit:contain;padding:1mm}.brand strong{display:block}.micro,.kicker,.kpi-label,.category{font:700 7pt/1.2 Arial,sans-serif;text-transform:uppercase;letter-spacing:1.1px;color:var(--m)}.period{text-align:right;color:var(--p);font-weight:700}.hero{display:grid;grid-template-columns:1fr 68mm;gap:10mm;align-items:end;margin-bottom:11mm}.hero-note{border-left:1mm solid var(--a);padding-left:5mm}.hero-note h2{font-size:14pt}.hero-note p{margin:0}.kpis{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:5mm}.kpi{padding:5mm 4mm 4mm 0}.kpi+.kpi{border-left:1px solid var(--line);padding-left:4mm}.kpi-value{font-size:16pt;font-weight:700;color:var(--p);white-space:nowrap;margin:2mm 0}.kpi-delta{font-size:7.5pt;color:var(--m)}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:grid;grid-template-columns:repeat(4,1fr);gap:6mm;margin-bottom:10mm}.fact span{display:block;color:var(--m);font-size:7.3pt}.fact strong{display:block;margin-top:1mm}.section{margin-bottom:9mm}.section-title{display:grid;grid-template-columns:auto 1fr;gap:4mm;align-items:center;margin-bottom:4mm}.section-title h2{margin:0}.rule{height:1px;background:var(--line)}.trend-bars{border-top:1px solid var(--line)}.trend-row{display:grid;grid-template-columns:18mm 1fr 28mm 22mm;gap:3mm;align-items:center;padding:2.2mm 0;border-bottom:1px solid var(--line);font-size:7.7pt}.trend-label{font-weight:700}.trend-track{height:3.5mm;background:var(--bg);position:relative}.trend-revenue{height:100%;background:var(--p)}.trend-occ{height:1mm;background:var(--a);position:absolute;bottom:0;left:0}.trend-num{text-align:right}.content-grid{display:grid;grid-template-columns:1fr 1.1fr;gap:9mm;break-before:page;break-inside:avoid}.developments,.recommendations{display:grid;gap:4mm}.development{display:grid;grid-template-columns:2mm 1fr;gap:3mm;break-inside:avoid}.impact{width:1.3mm;background:var(--m)}.positive .impact{background:var(--pos)}.negative .impact{background:var(--neg)}.development p{margin:0}.recommendation{display:grid;grid-template-columns:8mm 1fr;gap:3mm;break-inside:avoid;padding-bottom:4mm;border-bottom:1px solid var(--line)}.priority{font-size:17pt;color:var(--a);font-weight:700}.recommendation p{margin-bottom:1.5mm}.effect{color:var(--p)}.outlook{margin-top:8mm;padding:6mm 0;border-top:1.2mm solid var(--p);border-bottom:1px solid var(--line);break-inside:avoid}.outlook p{margin:0}.notes{margin-top:4mm;font-size:7pt;color:var(--m)}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font-size:6.5pt;color:var(--m);margin-top:3mm}
</style></head><body>
<header class="top"><div class="brand">{{ui.logo(data)}}<div><strong>{{data.customer.name}}</strong><div class="micro">{{data.customer.property_name}}</div></div></div><div class="period">{{data.report.period.label}}<div class="micro">{{data.customer.location}}</div></div></header>
<section class="hero"><div><div class="kicker">Monatliche Performance</div><h1>Monats-<br>report</h1></div><div class="hero-note"><div class="kicker">Kurzfazit</div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></div></section>
{{ui.kpis(data)}}{{ui.facts(data)}}
<section class="section"><div class="section-title"><h2>Monatsverlauf</h2><div class="rule"></div></div>{{ui.trend_bars(data)}}</section>
<div class="content-grid"><section><div class="section-title"><h2>Entwicklungen</h2><div class="rule"></div></div>{{ui.developments(data)}}</section><section><div class="section-title"><h2>Empfehlungen</h2><div class="rule"></div></div>{{ui.recommendations(data)}}</section></div>
{{ui.outlook(data)}}{{ui.notes(data)}}
</body></html>
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
html { font-family: Arial, sans-serif; font-size: 8px; color: #718087; margin: 0 14mm; -webkit-print-color-adjust: exact; }
body { margin: 0; }
.footer { width: 100%; padding-top: 3mm; border-top: 1px solid #dfe5e8; display: flex; justify-content: space-between; align-items: center; }
.page { font-weight: 700; color: {{ data.design.primary_color }}; }
</style>
</head>
<body>
<div class="footer">
<span>{{ data.customer.name }} · {{ data.report.period.label }} · Template {{ data.report.template_version }}</span>
<span class="page">Seite <span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>
</body>
</html>
@@ -0,0 +1,373 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Monatsreport {{ data.report.period.label }} - {{ data.customer.name }}</title>
<style>
:root {
--primary: {{ data.design.primary_color }};
--secondary: {{ data.design.secondary_color }};
--accent: {{ data.design.accent_color }};
--text: {{ data.design.text_color }};
--muted: {{ data.design.muted_color }};
--surface: {{ data.design.surface_color }};
--line: #dfe5e8;
--positive: #207a5a;
--neutral: #667680;
--negative: #b14949;
--watch: #9a6815;
}
@page {
size: A4;
margin: 13mm 14mm 18mm 14mm;
}
* { box-sizing: border-box; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
margin: 0;
color: var(--text);
font-family: {{ data.design.font_family }}, Arial, sans-serif;
font-size: 9.3pt;
line-height: 1.48;
background: #fff;
}
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: 27pt; line-height: 1.08; letter-spacing: -0.6px; margin-bottom: 2mm; }
h2 { font-size: 14.5pt; line-height: 1.2; margin-bottom: 4mm; }
h3 { font-size: 10.8pt; line-height: 1.25; margin-bottom: 1.5mm; }
p { margin-bottom: 3mm; }
.cover {
position: relative;
overflow: hidden;
min-height: 67mm;
padding: 9mm;
border-radius: 5mm;
color: #fff;
background: linear-gradient(128deg, var(--primary), var(--secondary));
margin-bottom: 7mm;
break-inside: avoid;
}
.cover::after {
content: "";
position: absolute;
width: 92mm;
height: 92mm;
right: -27mm;
top: -42mm;
border: 12mm solid rgba(255,255,255,.08);
border-radius: 50%;
}
.cover-top { display: flex; align-items: center; justify-content: space-between; gap: 8mm; position: relative; z-index: 1; }
.brand { display: flex; align-items: center; gap: 4mm; }
.logo {
width: 15mm; height: 15mm; border-radius: 4mm;
background: rgba(255,255,255,.96); color: var(--primary);
display: flex; align-items: center; justify-content: center; font-size: 15pt; font-weight: 800;
box-shadow: 0 2mm 5mm rgba(0,0,0,.12);
overflow: hidden;
}
.logo img { width: 100%; height: 100%; object-fit: contain; padding: 1.6mm; }
.brand-name { font-size: 11pt; font-weight: 700; }
.brand-sub { font-size: 8pt; opacity: .82; }
.period-chip {
padding: 2.2mm 4mm; border-radius: 99mm;
border: .25mm solid rgba(255,255,255,.38);
background: rgba(255,255,255,.12); font-size: 8.3pt; font-weight: 700;
}
.cover-main { position: relative; z-index: 1; margin-top: 13mm; max-width: 142mm; }
.eyebrow { text-transform: uppercase; letter-spacing: 1.7px; font-size: 7.2pt; font-weight: 800; opacity: .78; margin-bottom: 2.2mm; }
.cover-meta { display: flex; flex-wrap: wrap; gap: 3mm 8mm; font-size: 8.4pt; opacity: .88; }
.section { margin-bottom: 7mm; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 5mm; margin-bottom: 3.5mm; }
.section-heading h2 { margin: 0; }
.section-kicker { font-size: 7.3pt; font-weight: 800; color: var(--muted); text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 1mm; }
.summary {
display: grid; grid-template-columns: 10mm 1fr; gap: 4mm;
padding: 5mm; border-radius: 3mm; background: var(--surface);
border-left: 1.5mm solid var(--accent); break-inside: avoid;
}
.summary-icon {
width: 9mm; height: 9mm; border-radius: 50%;
display: flex; align-items: center; justify-content: center; align-self: start; color: #fff; font-weight: 900; margin-top: .5mm;
}
.summary-icon.positive { background: var(--positive); }
.summary-icon.neutral { background: var(--neutral); }
.summary-icon.watch { background: var(--watch); }
.summary h2 { font-size: 14pt; margin-bottom: 2mm; }
.summary p { margin-bottom: 0; }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3mm; }
.kpi-card {
min-height: 31mm; padding: 4mm; border: .25mm solid var(--line);
border-radius: 3mm; break-inside: avoid; background: #fff;
}
.kpi-label { font-size: 7.3pt; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); font-weight: 800; }
.kpi-value { font-size: 16pt; font-weight: 800; color: var(--primary); line-height: 1.15; margin: 3mm 0 1.5mm; white-space: nowrap; }
.kpi-delta { font-size: 7.6pt; color: var(--muted); }
.delta-positive { color: var(--positive); font-weight: 700; }
.delta-negative { color: var(--negative); font-weight: 700; }
.detail-strip {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 0;
border: .25mm solid var(--line); border-radius: 3mm; overflow: hidden; margin-top: 3mm;
}
.detail-item { padding: 3mm 4mm; border-right: .25mm solid var(--line); }
.detail-item:last-child { border-right: 0; }
.detail-label { font-size: 7.2pt; color: var(--muted); }
.detail-value { font-weight: 800; margin-top: .8mm; }
.trend-panel {
border: .25mm solid var(--line); border-radius: 3mm; padding: 5mm;
break-inside: avoid;
}
.trend-legend { display: flex; gap: 5mm; font-size: 7.4pt; color: var(--muted); }
.legend-dot { width: 2.2mm; height: 2.2mm; border-radius: 50%; display: inline-block; margin-right: 1.2mm; }
.legend-revenue { background: var(--primary); }
.legend-occ { background: var(--accent); }
.trend-grid { display: block; margin-top: 3mm; }
.trend-row { display: grid; grid-template-columns: 20mm 1fr 25mm 25mm; align-items: center; gap: 2mm; margin-top: 2mm; }
.trend-label { font-weight: 700; font-size: 8pt; }
.bar-track { height: 4mm; border-radius: 99mm; background: #edf1f3; overflow: hidden; position: relative; }
.bar { height: 100%; border-radius: inherit; }
.bar-revenue { background: var(--primary); }
.bar-occ { background: var(--accent); height: 1.5mm; position: absolute; left: 0; bottom: 0; }
.trend-number { text-align: right; font-size: 7.7pt; font-variant-numeric: tabular-nums; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 5mm; align-items: start; }
.development-list { display: grid; gap: 3mm; }
.development {
border: .25mm solid var(--line); border-radius: 3mm; padding: 4mm;
break-inside: avoid;
}
.development-top { display: flex; align-items: center; gap: 2mm; margin-bottom: 1.5mm; }
.impact-dot { width: 2.5mm; height: 2.5mm; border-radius: 50%; flex: 0 0 auto; }
.impact-dot.positive { background: var(--positive); }
.impact-dot.neutral { background: var(--neutral); }
.impact-dot.negative { background: var(--negative); }
.development p { margin: 0; color: #35434a; }
.recommendations { display: grid; gap: 3.5mm; }
.recommendation {
display: grid; grid-template-columns: 11mm 1fr; gap: 4mm;
padding: 4.5mm; border-radius: 3mm; background: var(--surface);
border: .25mm solid #d7e1e2;
break-inside: avoid;
}
.priority {
width: 10mm; height: 10mm; border-radius: 3mm; background: var(--primary); color: #fff;
display: flex; align-items: center; justify-content: center; align-self: start; font-weight: 900; font-size: 12pt;
}
.category { color: var(--primary); font-size: 7.2pt; font-weight: 900; text-transform: uppercase; letter-spacing: .8px; }
.recommendation h3 { font-size: 11.3pt; margin: .8mm 0 2mm; }
.recommendation p { margin-bottom: 2mm; }
.recommendation p:last-child { margin-bottom: 0; }
.action-line { padding-top: 2mm; border-top: .25mm solid rgba(0,0,0,.08); }
.outlook {
padding: 5mm; color: #fff; border-radius: 3mm;
background: linear-gradient(115deg, var(--primary), var(--secondary));
break-inside: avoid;
}
.outlook h2 { margin-bottom: 2mm; }
.outlook p { margin: 0; opacity: .94; }
.notes {
margin-top: 4mm; padding: 3.5mm 4mm; border-radius: 2mm;
background: #f7f8f9; color: var(--muted); font-size: 7.5pt;
break-inside: avoid;
}
.notes ul { margin: 1.5mm 0 0; padding-left: 4mm; }
.notes li { margin-bottom: .8mm; }
.legal { margin-top: 4mm; color: var(--muted); font-size: 6.9pt; }
.page-break { break-before: page; }
.avoid { break-inside: avoid; }
@media print {
.section:last-child { margin-bottom: 0; }
}
</style>
</head>
<body>
<main>
<section class="cover">
<div class="cover-top">
<div class="brand">
<div class="logo">
{% if data.customer.logo_data_uri %}
<img src="{{ data.customer.logo_data_uri }}" alt="Logo">
{% else %}
{{ data.customer.logo_text }}
{% endif %}
</div>
<div>
<div class="brand-name">{{ data.customer.name }}</div>
<div class="brand-sub">{{ data.customer.property_name }}</div>
</div>
</div>
<div class="period-chip">{{ data.report.period.label }}</div>
</div>
<div class="cover-main">
<div class="eyebrow">Performance & Handlungsempfehlungen</div>
<h1>Monatsreport</h1>
<div class="cover-meta">
<span>{{ data.customer.location }}</span>
<span>Erstellt am {{ data.report.generated_at|date_de }}</span>
<span>Version {{ data.report.report_version }}</span>
</div>
</div>
</section>
<section class="section">
<div class="summary">
<div class="summary-icon {{ data.content.summary.rating }}">
{% if data.content.summary.rating == 'positive' %}↑{% elif data.content.summary.rating == 'watch' %}!{% else %}•{% endif %}
</div>
<div>
<div class="section-kicker">Kurzfazit</div>
<h2>{{ data.content.summary.headline }}</h2>
<p>{{ data.content.summary.text }}</p>
</div>
</div>
</section>
<section class="section">
<div class="section-heading">
<div>
<div class="section-kicker">Kennzahlen</div>
<h2>Der Monat auf einen Blick</h2>
</div>
<div class="section-kicker">Vergleich: {{ data.report.period.comparison_label }}</div>
</div>
<div class="kpi-grid">
<article class="kpi-card">
<div class="kpi-label">Umsatz</div>
<div class="kpi-value">{{ data.metrics.revenue|currency_de(data.metrics.currency) }}</div>
<div class="kpi-delta {% if data.metrics.comparison.revenue_delta_pct >= 0 %}delta-positive{% else %}delta-negative{% endif %}">
{{ data.metrics.comparison.revenue_delta_pct|delta_de(1, '%') }}
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">Auslastung</div>
<div class="kpi-value">{{ data.metrics.occupancy_rate|percent_de }}</div>
<div class="kpi-delta {% if data.metrics.comparison.occupancy_delta_pp >= 0 %}delta-positive{% else %}delta-negative{% endif %}">
{{ data.metrics.comparison.occupancy_delta_pp|delta_de(1, 'PP') }}
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">Ø Tagespreis</div>
<div class="kpi-value">{{ data.metrics.adr|currency_de(data.metrics.currency) }}</div>
<div class="kpi-delta {% if data.metrics.comparison.adr_delta_pct >= 0 %}delta-positive{% else %}delta-negative{% endif %}">
{{ data.metrics.comparison.adr_delta_pct|delta_de(1, '%') }}
</div>
</article>
<article class="kpi-card">
<div class="kpi-label">Buchungen</div>
<div class="kpi-value">{{ data.metrics.bookings|number_de(0) }}</div>
<div class="kpi-delta {% if data.metrics.comparison.bookings_delta >= 0 %}delta-positive{% else %}delta-negative{% endif %}">
{{ data.metrics.comparison.bookings_delta|delta_de(0, '') }} Buchungen
</div>
</article>
</div>
<div class="detail-strip">
<div class="detail-item"><div class="detail-label">RevPAR</div><div class="detail-value">{{ data.metrics.revpar|currency_de(data.metrics.currency) }}</div></div>
<div class="detail-item"><div class="detail-label">Verkaufte Nächte</div><div class="detail-value">{{ data.metrics.nights_sold|number_de(0) }}</div></div>
<div class="detail-item"><div class="detail-label">Ø Aufenthaltsdauer</div><div class="detail-value">{{ data.metrics.average_stay|number_de(1) }} Nächte</div></div>
<div class="detail-item"><div class="detail-label">Stornoquote</div><div class="detail-value">{{ data.metrics.cancellation_rate|percent_de }}</div></div>
</div>
</section>
<section class="section">
<div class="section-heading">
<div>
<div class="section-kicker">Verlauf</div>
<h2>Entwicklung der letzten Monate</h2>
</div>
<div class="trend-legend">
<span><i class="legend-dot legend-revenue"></i> Umsatz</span>
<span><i class="legend-dot legend-occ"></i> Auslastung</span>
</div>
</div>
<div class="trend-panel">
<div class="trend-grid">
{% for item in data.trends %}
<div class="trend-row">
<div class="trend-label">{{ item.label }}</div>
<div class="bar-track">
<div class="bar bar-revenue" style="width: {{ item.revenue_bar }}%"></div>
<div class="bar bar-occ" style="width: {{ item.occupancy_bar }}%"></div>
</div>
<div class="trend-number">{{ item.revenue|currency_de(data.metrics.currency) }}</div>
<div class="trend-number">{{ item.occupancy_rate|percent_de }}</div>
</div>
{% endfor %}
</div>
</div>
</section>
<section class="section">
<div class="section-heading">
<div>
<div class="section-kicker">Analyse</div>
<h2>Wichtige Entwicklungen</h2>
</div>
</div>
<div class="two-col">
{% for item in data.content.developments %}
<article class="development">
<div class="development-top"><span class="impact-dot {{ item.impact }}"></span><h3>{{ item.title }}</h3></div>
<p>{{ item.text }}</p>
</article>
{% endfor %}
</div>
</section>
<section class="section">
<div class="section-heading">
<div>
<div class="section-kicker">Naechste Schritte</div>
<h2>Priorisierte Empfehlungen</h2>
</div>
</div>
<div class="recommendations">
{% for item in data.content.recommendations|sort(attribute='priority') %}
<article class="recommendation">
<div class="priority">{{ item.priority }}</div>
<div>
<div class="category">{{ item.category }}</div>
<h3>{{ item.title }}</h3>
<p><strong>Warum:</strong> {{ item.reason }}</p>
<p class="action-line"><strong>Massnahme:</strong> {{ item.action }}</p>
<p><strong>Erwarteter Effekt:</strong> {{ item.expected_effect }}</p>
</div>
</article>
{% endfor %}
</div>
</section>
<section class="section">
<div class="outlook">
<div class="section-kicker" style="color: rgba(255,255,255,.7)">Ausblick</div>
<h2>{{ data.content.outlook.headline }}</h2>
<p>{{ data.content.outlook.text }}</p>
</div>
{% if data.content.data_quality_notes %}
<div class="notes">
<strong>Hinweise zur Datengrundlage</strong>
<ul>
{% for note in data.content.data_quality_notes %}<li>{{ note }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<div class="legal">{{ data.legal_note }}</div>
</section>
</main>
</body>
</html>
@@ -0,0 +1,11 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#d6d6d6;--pos:#25745a;--neg:#a43d43}@page{size:A4;margin:11mm 13mm 19mm}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:9pt/1.42 {{data.design.font_family}},Arial,sans-serif}h1,h2,h3,p{margin-top:0}h1{font-size:43pt;line-height:.8;letter-spacing:-2.8px;text-transform:uppercase;margin:0}h2{font-size:17pt;line-height:1.05;margin-bottom:3mm}h3{font-size:10pt;margin-bottom:1mm}p{margin-bottom:2.5mm}.cover{background:var(--p);color:#fff;min-height:79mm;padding:7mm;position:relative;overflow:hidden;margin-bottom:4mm;break-inside:avoid}.cover:after{content:"{{'%02d'|format(data.report.period.month)}}";position:absolute;right:-4mm;bottom:-17mm;font-size:90pt;line-height:1;font-weight:900;color:#ffffff10}.top{display:flex;justify-content:space-between;align-items:flex-start;position:relative;z-index:1}.brand{display:flex;gap:3mm;align-items:center}.logo{width:13mm;height:13mm;background:#fff;color:var(--p);display:flex;align-items:center;justify-content:center;font-weight:900;overflow:hidden}.logo img{width:100%;height:100%;object-fit:contain;padding:1mm}.brand-name{font-weight:900}.small{font-size:7pt;opacity:.74}.period{background:var(--a);color:var(--t);padding:2.5mm 4mm;font-weight:900}.title{position:absolute;left:7mm;bottom:7mm;z-index:1}.kicker,.kpi-label,.category{font-size:6.6pt;text-transform:uppercase;letter-spacing:1px;font-weight:900;color:var(--m)}.cover .kicker{color:#ffffffad}.summary{display:grid;grid-template-columns:48mm 1fr;background:var(--a);padding:5mm;margin-bottom:4mm}.summary-label{font-size:22pt;font-weight:900;text-transform:uppercase;line-height:.9}.summary h2{font-size:16pt}.summary p{margin:0}.kpis{display:grid;grid-template-columns:1fr 1fr;gap:3mm;margin-bottom:3mm}.kpi{min-height:34mm;padding:4mm;border:1px solid var(--t);position:relative}.kpi:nth-child(1),.kpi:nth-child(4){background:var(--t);color:#fff}.kpi:nth-child(1) .kpi-label,.kpi:nth-child(4) .kpi-label{color:#ffffffaa}.kpi-value{font-size:25pt;font-weight:900;line-height:1;color:var(--p);margin:2mm 0}.kpi:nth-child(1) .kpi-value,.kpi:nth-child(4) .kpi-value{color:var(--a)}.kpi-delta{font-size:7pt}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:grid;grid-template-columns:repeat(4,1fr);gap:0;background:var(--bg);margin-bottom:6mm}.fact{padding:3mm;border-right:1px solid var(--line)}.fact:last-child{border-right:0}.fact span{display:block;font-size:6.5pt;color:var(--m)}.fact strong{display:block;margin-top:.5mm}.section-title{display:flex;align-items:baseline;gap:4mm;border-bottom:2mm solid var(--p);margin:7mm 0 3mm}.section-title .num{font-size:24pt;font-weight:900;color:var(--a)}.section-title h2{margin:0 0 1mm;text-transform:uppercase}.trend-bars{display:grid;gap:2mm}.trend-row{display:grid;grid-template-columns:17mm 1fr 28mm 22mm;gap:2.5mm;align-items:center;font-size:7.2pt}.trend-label{font-weight:900}.trend-track{height:5mm;background:var(--bg);position:relative;border:1px solid var(--t)}.trend-revenue{height:100%;background:var(--p)}.trend-occ{height:1.4mm;background:var(--a);position:absolute;left:0;bottom:0}.trend-num{text-align:right}.content{display:grid;grid-template-columns:1fr 1fr;gap:5mm;break-before:page;break-inside:avoid}.developments,.recommendations{display:grid;gap:3mm}.development{display:grid;grid-template-columns:3mm 1fr;gap:3mm;border-bottom:1px solid var(--line);padding-bottom:3mm;break-inside:avoid}.impact{background:var(--m)}.positive .impact{background:var(--pos)}.negative .impact{background:var(--neg)}.development p{margin:0}.recommendation{display:grid;grid-template-columns:10mm 1fr;gap:3mm;border:1px solid var(--t);padding:3mm;break-inside:avoid}.priority{font-size:22pt;font-weight:900;color:var(--a)}.recommendation p{margin-bottom:1.2mm}.effect{border-top:1px solid var(--line);padding-top:1.3mm}.outlook{margin-top:6mm;background:var(--t);color:#fff;padding:5mm;break-inside:avoid}.outlook .kicker{color:var(--a)}.outlook p{margin:0}.notes{font-size:6.7pt;color:var(--m);margin-top:3mm}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font-size:6.1pt;color:var(--m);margin-top:2mm}
</style></head><body>
<section class="cover"><div class="top"><div class="brand">{{ui.logo(data)}}<div><div class="brand-name">{{data.customer.name}}</div><div class="small">{{data.customer.property_name}} · {{data.customer.location}}</div></div></div><div class="period">{{data.report.period.label}}</div></div><div class="title"><div class="kicker">Performance / Analyse / Aktionen</div><h1>Monats<br>report</h1></div></section>
<section class="summary"><div class="summary-label">Der<br>Monat</div><div><div class="kicker">Kurzfazit</div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></div></section>
{{ui.kpis(data)}}{{ui.facts(data)}}
<div class="section-title"><span class="num">01</span><h2>Monatsverlauf</h2></div>{{ui.trend_bars(data)}}
<div class="content"><section><div class="section-title"><span class="num">02</span><h2>Entwicklungen</h2></div>{{ui.developments(data)}}</section><section><div class="section-title"><span class="num">03</span><h2>Aktionen</h2></div>{{ui.recommendations(data)}}</section></div>
{{ui.outlook(data)}}{{ui.notes(data)}}
</body></html>
@@ -0,0 +1,7 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#d8e0df;--pos:#28765b;--neg:#a54b4b}@page{size:A4;margin:0 13mm 18mm 0}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:9.1pt/1.48 {{data.design.font_family}},Arial,sans-serif}h1,h2,h3,p{margin-top:0}h1{font-size:25pt;line-height:1.05;margin-bottom:2mm}h2{font-size:14pt;margin-bottom:3mm}h3{font-size:10.2pt;margin-bottom:1mm}p{margin-bottom:2.7mm}.page{display:grid;grid-template-columns:45mm 1fr;min-height:279mm}.side{background:linear-gradient(180deg,var(--p),var(--s));color:#fff;padding:13mm 7mm 10mm 8mm}.main{padding:13mm 0 0 9mm}.logo{width:15mm;height:15mm;background:#fff;color:var(--p);border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:900;overflow:hidden;margin-bottom:5mm}.logo img{width:100%;height:100%;object-fit:contain;padding:1.3mm}.brand{font-weight:800;font-size:11pt}.side-sub{font-size:7.5pt;opacity:.78;margin-top:1mm}.side-period{margin:18mm 0 10mm}.side-period strong{font-size:18pt;display:block;line-height:1.05}.kicker,.kpi-label,.category{font:800 6.8pt/1.2 Arial,sans-serif;text-transform:uppercase;letter-spacing:1px;color:var(--m)}.side .kicker{color:#ffffffb0}.side-facts{display:grid;gap:4mm;border-top:1px solid #ffffff40;padding-top:5mm}.side-fact span{font-size:7pt;opacity:.7;display:block}.side-fact strong{font-size:11pt}.side-outlook{margin-top:13mm;border-top:1px solid #ffffff40;padding-top:5mm}.side-outlook h3{font-size:11pt}.side-outlook p{font-size:7.6pt;opacity:.88}.intro{margin-bottom:6mm}.intro h1{color:var(--p)}.summary{border-left:1.4mm solid var(--a);padding:1mm 0 1mm 5mm;margin:5mm 0 6mm}.summary h2{font-size:14.5pt}.summary p{margin:0}.kpis{display:grid;grid-template-columns:1fr 1fr;gap:3mm;margin-bottom:5mm}.kpi{background:var(--bg);padding:4mm;border-radius:2mm;break-inside:avoid}.kpi-value{font-size:17pt;font-weight:800;color:var(--p);margin:2mm 0}.kpi-delta{font-size:7pt}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:none}.section{margin:6mm 0}.section-head{display:flex;align-items:center;gap:3mm;margin-bottom:3mm}.section-head h2{margin:0;white-space:nowrap}.line{height:1px;background:var(--line);width:100%}.trend-bars{display:grid;gap:2mm}.trend-row{display:grid;grid-template-columns:16mm 1fr 27mm 21mm;gap:2mm;align-items:center;font-size:7.2pt}.trend-label{font-weight:700}.trend-track{height:3.7mm;background:var(--bg);position:relative}.trend-revenue{height:100%;background:var(--p)}.trend-occ{height:1mm;background:var(--a);position:absolute;bottom:0;left:0}.trend-num{text-align:right}.developments{columns:2;column-gap:5mm}.development{display:grid;grid-template-columns:2mm 1fr;gap:2.5mm;break-inside:avoid;margin-bottom:3mm}.impact{background:var(--m)}.positive .impact{background:var(--pos)}.negative .impact{background:var(--neg)}.development p{margin:0}.recommendations{display:grid;gap:3mm}.recommendation{display:grid;grid-template-columns:9mm 1fr;gap:3mm;padding:3.5mm 0;border-top:1px solid var(--line);break-inside:avoid}.priority{font-size:18pt;font-weight:800;color:var(--a)}.recommendation p{margin-bottom:1.3mm}.effect{color:var(--p)}.outlook{display:none}.notes{font-size:6.8pt;color:var(--m);margin-top:4mm}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font-size:6.2pt;color:var(--m);margin-top:2mm}
</style></head><body><div class="page">
<aside class="side">{{ui.logo(data)}}<div class="brand">{{data.customer.name}}</div><div class="side-sub">{{data.customer.property_name}}<br>{{data.customer.location}}</div><div class="side-period"><div class="kicker">Monatsreport</div><strong>{{data.report.period.label}}</strong><div class="side-sub">Version {{data.report.report_version}}</div></div><div class="side-facts"><div class="side-fact"><span>RevPAR</span><strong>{{data.metrics.revpar|currency_de(data.metrics.currency)}}</strong></div><div class="side-fact"><span>Verkaufte Nächte</span><strong>{{data.metrics.nights_sold|number_de(0)}}</strong></div><div class="side-fact"><span>Ø Aufenthalt</span><strong>{{data.metrics.average_stay|number_de(1)}}</strong></div><div class="side-fact"><span>Stornoquote</span><strong>{{data.metrics.cancellation_rate|percent_de}}</strong></div></div><div class="side-outlook"><div class="kicker">Ausblick</div><h3>{{data.content.outlook.headline}}</h3><p>{{data.content.outlook.text}}</p></div></aside>
<main class="main"><section class="intro"><div class="kicker">Performance & Handlungsempfehlungen</div><h1>Der Monat<br>im Überblick</h1></section><section class="summary"><div class="kicker">Kurzfazit</div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></section>{{ui.kpis(data)}}
<section class="section"><div class="section-head"><h2>Monatsverlauf</h2><div class="line"></div></div>{{ui.trend_bars(data)}}</section><section class="section"><div class="section-head"><h2>Entwicklungen</h2><div class="line"></div></div>{{ui.developments(data)}}</section><section class="section"><div class="section-head"><h2>Empfehlungen</h2><div class="line"></div></div>{{ui.recommendations(data)}}</section>{{ui.notes(data)}}</main></div></body></html>
@@ -0,0 +1,11 @@
{% import '_shared/macros.html.j2' as ui %}
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{{ data._theme.name }} - {{ data.customer.name }}</title><style>
:root{--p:{{data.design.primary_color}};--s:{{data.design.secondary_color}};--a:{{data.design.accent_color}};--t:{{data.design.text_color}};--m:{{data.design.muted_color}};--bg:{{data.design.surface_color}};--line:#d9dfdf;--pos:#28745a;--neg:#a74646}@page{size:A4;margin:12mm 14mm 19mm}*{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact}body{margin:0;color:var(--t);font:9.2pt/1.48 {{data.design.font_family}},Arial,sans-serif}h1,h2,h3,p{margin-top:0}h1{font-size:31pt;line-height:.98;margin:0}h2{font-size:15pt;margin-bottom:3mm}h3{font-size:10.3pt;margin-bottom:1mm}p{margin-bottom:2.6mm}.cover{display:grid;grid-template-columns:1fr 1fr;min-height:68mm;margin-bottom:6mm;break-inside:avoid}.cover-left{background:var(--p);color:#fff;padding:7mm;display:flex;flex-direction:column;justify-content:space-between}.cover-right{background:var(--bg);padding:7mm;display:flex;flex-direction:column;justify-content:space-between}.brand{display:flex;align-items:center;gap:3mm}.logo{width:13mm;height:13mm;background:#fff;color:var(--p);display:flex;align-items:center;justify-content:center;font-weight:900;overflow:hidden}.logo img{width:100%;height:100%;object-fit:contain;padding:1mm}.brand-name{font-weight:800}.small{font-size:7.3pt;opacity:.76}.kicker,.kpi-label,.category{font-size:6.7pt;text-transform:uppercase;letter-spacing:1px;font-weight:800;color:var(--m)}.cover-left .kicker{color:#ffffffb8}.period{font-size:18pt;color:var(--p);font-weight:800}.summary h2{font-size:15pt}.summary p{margin:0}.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:3mm;margin-bottom:3mm}.kpi{padding:4mm;border-left:1mm solid var(--p);background:#fff;border-top:1px solid var(--line);border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.kpi:nth-child(even){border-left-color:var(--a)}.kpi-value{font-size:15.5pt;font-weight:800;color:var(--p);margin:2mm 0;white-space:nowrap}.kpi-delta{font-size:7pt}.up{color:var(--pos)}.down{color:var(--neg)}.facts{display:grid;grid-template-columns:repeat(4,1fr);background:var(--bg);padding:3mm 4mm;margin-bottom:7mm}.fact span{display:block;font-size:6.7pt;color:var(--m)}.fact strong{display:block;margin-top:.5mm}.band{display:grid;grid-template-columns:40mm 1fr;gap:6mm;margin:7mm 0;break-inside:avoid}.band-title{background:var(--p);color:#fff;padding:5mm;min-height:30mm}.band-title.alt{background:var(--a);color:var(--t)}.band-title h2{margin:0}.trend-bars{display:grid;gap:2mm}.trend-row{display:grid;grid-template-columns:18mm 1fr 28mm 22mm;gap:2.5mm;align-items:center;font-size:7.3pt}.trend-label{font-weight:700}.trend-track{height:4mm;background:var(--bg);position:relative}.trend-revenue{height:100%;background:var(--p)}.trend-occ{height:1.2mm;background:var(--a);position:absolute;bottom:0;left:0}.trend-num{text-align:right}.developments{display:grid;grid-template-columns:1fr 1fr;gap:3mm}.development{display:grid;grid-template-columns:2mm 1fr;gap:3mm;border:1px solid var(--line);padding:3.5mm;break-inside:avoid}.impact{background:var(--m)}.positive .impact{background:var(--pos)}.negative .impact{background:var(--neg)}.development p{margin:0}.recommendations{display:grid;gap:3mm}.recommendation{display:grid;grid-template-columns:9mm 1fr;gap:3mm;border-bottom:1px solid var(--line);padding:0 0 3mm;break-inside:avoid}.priority{font-size:18pt;font-weight:800;color:var(--a)}.recommendation p{margin-bottom:1.3mm}.effect{color:var(--p)}.outlook{margin-top:7mm;display:grid;grid-template-columns:38mm 1fr;background:var(--bg);padding:5mm;break-inside:avoid}.outlook .kicker{align-self:start;color:var(--p)}.outlook h2{margin-bottom:2mm}.outlook p{margin:0}.notes{font-size:6.8pt;color:var(--m);margin-top:4mm}.notes ul{margin:1mm 0;padding-left:4mm}.legal{font-size:6.2pt;color:var(--m);margin-top:2mm}
</style></head><body>
<section class="cover"><div class="cover-left"><div class="brand">{{ui.logo(data)}}<div><div class="brand-name">{{data.customer.name}}</div><div class="small">{{data.customer.property_name}}</div></div></div><div><div class="kicker">Performance & Strategie</div><h1>Monatsreport</h1></div></div><div class="cover-right"><div><div class="kicker">Berichtszeitraum</div><div class="period">{{data.report.period.label}}</div><div class="small">{{data.customer.location}} · {{data.report.generated_at|date_de}}</div></div><div class="summary"><div class="kicker">Kurzfazit</div><h2>{{data.content.summary.headline}}</h2><p>{{data.content.summary.text}}</p></div></div></section>
{{ui.kpis(data)}}{{ui.facts(data)}}
<section class="band"><div class="band-title"><div class="kicker">01</div><h2>Monatsverlauf</h2></div><div>{{ui.trend_bars(data)}}</div></section>
<section class="band"><div class="band-title alt"><div class="kicker">02</div><h2>Entwicklungen</h2></div><div>{{ui.developments(data)}}</div></section>
<section class="band"><div class="band-title"><div class="kicker">03</div><h2>Empfehlungen</h2></div><div>{{ui.recommendations(data)}}</div></section>
{{ui.outlook(data)}}{{ui.notes(data)}}
</body></html>
+196
View File
@@ -0,0 +1,196 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
def _theme(
theme_id: str,
name: str,
layout: str,
category: str,
description: str,
primary: str,
secondary: str,
accent: str,
text: str,
muted: str,
surface: str,
font: str = "Arial",
*,
custom_palette_default: bool = False,
) -> dict[str, Any]:
return {
"id": theme_id,
"name": name,
"layout": layout,
"category": category,
"description": description,
"custom_palette_default": custom_palette_default,
"palette": {
"primary_color": primary,
"secondary_color": secondary,
"accent_color": accent,
"text_color": text,
"muted_color": muted,
"surface_color": surface,
"font_family": font,
},
}
THEMES: dict[str, dict[str, Any]] = {
"modern": _theme(
"modern", "Modern Custom", "modern", "Flexibel",
"Abgerundete Karten, Verlaufskopf und frei definierbare Kundenfarben.",
"#1F5D66", "#2C7C82", "#DBA25D", "#24343A", "#718087", "#F2F6F5",
custom_palette_default=True,
),
"classic": _theme(
"classic", "Classic Custom", "classic", "Flexibel",
"Serifentypografie, feine Linien und frei definierbare Kundenfarben.",
"#243B53", "#486581", "#C3913C", "#25313A", "#76838C", "#F4F1EA", "Georgia",
custom_palette_default=True,
),
"nordic-light": _theme(
"nordic-light", "Nordic Light", "minimal", "Minimal",
"Helles, luftiges Layout mit kuehlen Blautoenen und viel Weissraum.",
"#294C60", "#6D98A6", "#E7B45A", "#1F2D33", "#75858B", "#F1F5F5", "Arial",
),
"nordic-night": _theme(
"nordic-night", "Nordic Night", "minimal", "Minimal",
"Dunkle skandinavische Farbwelt mit eisblauem Akzent.",
"#182B36", "#2F5668", "#8FD3E6", "#162329", "#6E7F86", "#EDF3F4", "Arial",
),
"alpine-sage": _theme(
"alpine-sage", "Alpine Sage", "minimal", "Minimal",
"Ruhige Salbei- und Steinfarben fuer naturnahe Marken.",
"#49665B", "#718C7D", "#C99C64", "#26332E", "#78827D", "#F2F3ED", "Georgia",
),
"executive-blue": _theme(
"executive-blue", "Executive Blue", "dashboard", "Business",
"Klares Management-Dashboard in serioesem Marineblau.",
"#173B67", "#2A6298", "#37A6B8", "#182632", "#6B7A86", "#EEF3F7", "Arial",
),
"executive-charcoal": _theme(
"executive-charcoal", "Executive Charcoal", "dashboard", "Business",
"Anthrazit, Silber und Signalblau fuer technische Reports.",
"#252B31", "#48525B", "#4FA3D1", "#20272D", "#727C84", "#F0F2F3", "Arial",
),
"executive-burgundy": _theme(
"executive-burgundy", "Executive Burgundy", "dashboard", "Business",
"Zurueckhaltendes Bordeaux mit warmem Kupferakzent.",
"#662E3A", "#8B4654", "#C08B5C", "#2E2527", "#7D7375", "#F5F0EF", "Arial",
),
"ocean-breeze": _theme(
"ocean-breeze", "Ocean Breeze", "sidebar", "Sidebar",
"Breite Seitenleiste, maritime Blautoene und frischer Aqua-Akzent.",
"#075B6C", "#18859A", "#58C6C7", "#17353B", "#69858A", "#EDF8F8", "Arial",
),
"forest-lodge": _theme(
"forest-lodge", "Forest Lodge", "sidebar", "Sidebar",
"Tannengruen, Moos und Holzton fuer Ferien- und Naturmarken.",
"#284E3C", "#47715A", "#B78B55", "#243129", "#728079", "#F1F4EF", "Georgia",
),
"sunset-coral": _theme(
"sunset-coral", "Sunset Coral", "sidebar", "Sidebar",
"Warme Koralle mit tiefem Pflaumenton und freundlicher Flaechenwirkung.",
"#A9474E", "#D06B62", "#F0B66D", "#382B30", "#82747A", "#FBF1ED", "Arial",
),
"editorial-ivory": _theme(
"editorial-ivory", "Editorial Ivory", "editorial", "Editorial",
"Magazinartige Serifengestaltung auf warmem Elfenbein.",
"#33312E", "#6D655C", "#A77D45", "#282725", "#77716A", "#F7F2E8", "Georgia",
),
"editorial-ink": _theme(
"editorial-ink", "Editorial Ink", "editorial", "Editorial",
"Schwarz, Tinte und klares Blau fuer einen publizistischen Look.",
"#17222D", "#2E485F", "#3E88A8", "#171D22", "#68747E", "#F0F3F5", "Georgia",
),
"editorial-rose": _theme(
"editorial-rose", "Editorial Rose", "editorial", "Editorial",
"Staubiges Rosa und dunkles Aubergine mit hochwertiger Anmutung.",
"#5E3548", "#8A576B", "#D59AA7", "#33272C", "#7C6E74", "#F8EFF1", "Georgia",
),
"magazine-cobalt": _theme(
"magazine-cobalt", "Magazine Cobalt", "magazine", "Magazin",
"Kräftiges Kobaltblau, grosse Typografie und asymmetrische Module.",
"#1749A3", "#2F72D6", "#F3C342", "#172438", "#66758A", "#EEF3FA", "Arial",
),
"magazine-citrus": _theme(
"magazine-citrus", "Magazine Citrus", "magazine", "Magazin",
"Dunkles Gruen mit Zitrusgelb fuer einen lebendigen Auftritt.",
"#245443", "#3E8063", "#E6C84A", "#20312A", "#6E7C75", "#F2F6E9", "Arial",
),
"magazine-plum": _theme(
"magazine-plum", "Magazine Plum", "magazine", "Magazin",
"Pflaume, Violett und Apricot fuer kreative Kundenreports.",
"#5A2D67", "#814696", "#E8A875", "#302536", "#796D7E", "#F7F0F8", "Arial",
),
"ledger-green": _theme(
"ledger-green", "Ledger Green", "ledger", "Tabellarisch",
"Praezises Tabellenlayout in klassischem Buchhaltungsgruen.",
"#275F4B", "#467A66", "#A4864D", "#26322D", "#748078", "#F1F4F0", "Courier New",
),
"ledger-blue": _theme(
"ledger-blue", "Ledger Blue", "ledger", "Tabellarisch",
"Dichtes Zahlenlayout in Blau und Schiefergrau.",
"#294D73", "#4B7198", "#8AAAC2", "#25313C", "#71808D", "#EFF3F6", "Courier New",
),
"ledger-sepia": _theme(
"ledger-sepia", "Ledger Sepia", "ledger", "Tabellarisch",
"Warmer Papiercharakter mit Sepia und Rostakzent.",
"#664B38", "#8A6A51", "#B5784E", "#382E28", "#7F746C", "#F6F0E7", "Courier New",
),
"split-teal": _theme(
"split-teal", "Split Teal", "split", "Split",
"Geteilte Titelflaeche mit Petrol und hellem Tuerkis.",
"#095B61", "#2E8E8B", "#9ED8CB", "#183334", "#698281", "#EEF7F5", "Arial",
),
"split-violet": _theme(
"split-violet", "Split Violet", "split", "Split",
"Vertikal geteiltes Layout in Violett und Lavendel.",
"#513A75", "#7557A0", "#BFA8E2", "#302A38", "#787080", "#F4F0FA", "Arial",
),
"split-terracotta": _theme(
"split-terracotta", "Split Terracotta", "split", "Split",
"Terrakotta, Lehm und Sand fuer warme Markenwelten.",
"#8B4938", "#B26950", "#E3B68F", "#3B2D29", "#84756F", "#FAF1EA", "Georgia",
),
"poster-red": _theme(
"poster-red", "Poster Red", "poster", "Statement",
"Plakatives Rot-Schwarz-Layout mit sehr grossen Kennzahlen.",
"#B3202A", "#D53B42", "#F2B84B", "#1F1D1D", "#6D6767", "#F7F2F0", "Arial",
),
"poster-yellow": _theme(
"poster-yellow", "Poster Yellow", "poster", "Statement",
"Schwarz und Signalgelb fuer maximale visuelle Klarheit.",
"#171717", "#3B3B3B", "#F1C928", "#181818", "#707070", "#F7F5E8", "Arial",
),
"poster-mono": _theme(
"poster-mono", "Poster Mono", "poster", "Statement",
"Konsequentes Schwarz-Weiss-Design mit typografischem Fokus.",
"#111111", "#444444", "#A3A3A3", "#171717", "#737373", "#F3F3F3", "Arial",
),
"soft-lavender": _theme(
"soft-lavender", "Soft Lavender", "modern", "Soft",
"Sanftes Kartenlayout in Lavendel, Flieder und warmem Rosa.",
"#67558B", "#8B76B3", "#D5A6B8", "#302B38", "#7D7485", "#F5F0F8", "Arial",
),
"midnight-gold": _theme(
"midnight-gold", "Midnight Gold", "modern", "Premium",
"Tiefes Nachtblau mit Goldakzent fuer einen hochwertigen Auftritt.",
"#172A42", "#284967", "#D2A64C", "#1B2733", "#6F7C87", "#EFF2F4", "Georgia",
),
}
def get_theme(theme_id: str) -> dict[str, Any]:
try:
return deepcopy(THEMES[theme_id])
except KeyError as exc:
raise ValueError(f"Unknown theme: {theme_id}") from exc
def public_theme_catalog() -> list[dict[str, Any]]:
return [deepcopy(THEMES[key]) for key in sorted(THEMES)]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
BASE_DIR = Path(__file__).resolve().parent
SCHEMA_DIR = BASE_DIR / "schemas"
def load_schema(name: str) -> dict[str, Any]:
path = SCHEMA_DIR / name
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
REPORT_SCHEMA = load_schema("report-payload.schema.json")
CLAUDE_SCHEMA = load_schema("claude-output.schema.json")
REPORT_VALIDATOR = Draft202012Validator(REPORT_SCHEMA)
CLAUDE_VALIDATOR = Draft202012Validator(CLAUDE_SCHEMA)
def _format_path(path_parts) -> str:
path = "$"
for part in path_parts:
if isinstance(part, int):
path += f"[{part}]"
else:
path += f".{part}"
return path
def collect_errors(payload: Any, validator: Draft202012Validator) -> list[dict[str, str]]:
errors = []
for error in sorted(validator.iter_errors(payload), key=lambda item: list(item.path)):
errors.append({
"path": _format_path(error.absolute_path),
"message": error.message,
})
return errors
def validate_report(payload: Any) -> list[dict[str, str]]:
return collect_errors(payload, REPORT_VALIDATOR)
def validate_claude_output(payload: Any) -> list[dict[str, str]]:
return collect_errors(payload, CLAUDE_VALIDATOR)