upload
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -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))
|
||||
@@ -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"],
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.34,<1
|
||||
Jinja2>=3.1,<4
|
||||
jsonschema>=4.23,<5
|
||||
requests>=2.32,<3
|
||||
Reference in New Issue
Block a user