Flatten budget-app contents into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:41:01 +00:00
parent c9fd26f1c7
commit 93f2696764
95 changed files with 184 additions and 187 deletions
View File
+110
View File
@@ -0,0 +1,110 @@
"""Inline SVG charts, so the same markup renders in the browser and in WeasyPrint."""
import math
from html import escape
BLUE = "#2f6fd6"
ORANGE = "#e8632b"
INK = "#1d1d1f"
MUTED = "#6b6b70"
GRID = "#e4e4e8"
def nice_ticks(lo: float, hi: float, count: int = 8) -> list[float]:
if hi <= lo:
hi = lo + 1
raw = (hi - lo) / count
mag = 10 ** math.floor(math.log10(raw))
step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw)
start = math.floor(lo / step) * step
ticks, v = [], start
while v <= hi + step * 0.001:
ticks.append(round(v, 10))
v += step
if ticks[-1] < hi:
ticks.append(ticks[-1] + step)
return ticks
def _wrap(text: str, width: int = 30, lines: int = 2) -> list[str]:
words, out, cur = text.split(), [], ""
for w in words:
if len(cur) + len(w) + (1 if cur else 0) <= width:
cur = f"{cur} {w}".strip()
else:
out.append(cur)
cur = w
out.append(cur)
out = [o for o in out if o]
if len(out) > lines:
out = out[:lines]
out[-1] = out[-1][: width - 1].rstrip() + "…"
return [o if len(o) <= width else o[: width - 1] + "…" for o in out] or [""]
def grouped_bars(rows: list[tuple[str, float, float]], fmt_tick, fmt_value=None, width: int = 900,
height: int = 490) -> str:
"""Horizontal bars, two per row (prior in blue, adjusted in orange), sized to fill width x height."""
if not rows:
return ""
label_w = 250
top, bottom = 8, 30
row_h = min(130.0, (height - top - bottom) / len(rows))
bar_h = min(row_h * 0.34, 36)
plot_w = width - label_w - (80 if fmt_value else 24)
height = round(top + bottom + row_h * len(rows))
hi = max(max(p, a) for _, p, a in rows) or 1
ticks = nice_ticks(0, hi, 8)
x = lambda v: label_w + plot_w * v / ticks[-1]
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{escape(fmt_tick(t))}</text>')
for i, (label, prior, adjusted) in enumerate(rows):
y0 = top + i * row_h
cy = y0 + row_h / 2
fs = 13 if row_h >= 22 else 11.5
wrapped = _wrap(label, 34, 2 if row_h >= 30 else 1)
for j, chunk in enumerate(wrapped):
dy = (j - (len(wrapped) - 1) / 2) * (fs + 2) + fs / 3
parts.append(f'<text x="{label_w - 10}" y="{cy + dy:.1f}" font-size="{fs}" text-anchor="end" fill="{INK}">{escape(chunk)}</text>')
for k, (value, colour) in enumerate(((prior, BLUE), (adjusted, ORANGE))):
by = cy - bar_h - 1 + k * (bar_h + 2)
w = max(x(max(value, 0)) - label_w, 0)
parts.append(f'<rect x="{label_w}" y="{by:.1f}" width="{w:.1f}" height="{bar_h:.1f}" fill="{colour}"/>')
if fmt_value and row_h >= 26:
vfs = min(12, bar_h + 1)
parts.append(f'<text x="{label_w + w + 5:.1f}" y="{by + bar_h / 2 + vfs / 3:.1f}" font-size="{vfs:.1f}" fill="{MUTED}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{label_w}" x2="{label_w}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
return "".join(parts)
def change_bars(rows: list[tuple[str, float]], fmt_value, width: int = 900, height: int = 520) -> str:
"""Ranked % change per category; increases orange, decreases blue."""
if not rows:
return ""
label_w, top, bottom = 270, 8, 30
row_h = min(60.0, (height - top - bottom) / len(rows))
bar = min(row_h * 0.6, 28)
plot_w = width - label_w - 90
height = round(top + bottom + row_h * len(rows))
lo = min(0.0, min(v for _, v in rows))
hi = max(0.0, max(v for _, v in rows)) or 1
ticks = nice_ticks(lo, hi, 8)
span = ticks[-1] - ticks[0]
x = lambda v: label_w + plot_w * (v - ticks[0]) / span
parts = [f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" class="chart">']
for t in ticks:
parts.append(f'<line x1="{x(t):.1f}" x2="{x(t):.1f}" y1="{top}" y2="{height - bottom}" stroke="{GRID}"/>')
parts.append(f'<text x="{x(t):.1f}" y="{height - bottom + 18}" font-size="12" text-anchor="middle" fill="{MUTED}">{"+" if t > 0 else ""}{t:g}%</text>')
for i, (label, value) in enumerate(rows):
cy = top + i * row_h + row_h / 2
x0, x1 = sorted((x(0), x(value)))
colour = ORANGE if value > 0 else BLUE
parts.append(f'<text x="{label_w - 10}" y="{cy + 4.5:.1f}" font-size="13" text-anchor="end" fill="{INK}">{escape(_wrap(label, 38, 1)[0])}</text>')
parts.append(f'<rect x="{x0:.1f}" y="{cy - bar / 2:.1f}" width="{max(x1 - x0, 1):.1f}" height="{bar:.1f}" fill="{colour}"/>')
tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end")
parts.append(f'<text x="{tx:.1f}" y="{cy + 4.5:.1f}" font-size="12.5" font-weight="bold" text-anchor="{anchor}" fill="{INK}">{escape(fmt_value(value))}</text>')
parts.append(f'<line x1="{x(0):.1f}" x2="{x(0):.1f}" y1="{top}" y2="{height - bottom}" stroke="{MUTED}"/>')
parts.append("</svg>")
return "".join(parts)
+196
View File
@@ -0,0 +1,196 @@
"""Excel exports with live formulas, so totals keep working when people edit the file."""
import re
from datetime import datetime
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from ..summary import YearView
from .i18n import T
MONEY = '#,##0.00;[Red]-#,##0.00'
PCT = '0.00%;[Red]-0.00%'
HEAD_FILL = PatternFill("solid", fgColor="1F3B63")
CAT_FILL = PatternFill("solid", fgColor="EEF2F8")
WHITE_BOLD = Font(bold=True, color="FFFFFF")
BOLD = Font(bold=True)
THIN_TOP = Border(top=Side(style="thin", color="999999"))
def _safe_sheet_name(name: str, used: set[str]) -> str:
base = re.sub(r"[\[\]:*?/\\]", "-", name)[:31] or "Sheet"
candidate, n = base, 2
while candidate.lower() in used:
suffix = f" ({n})"
candidate, n = base[: 31 - len(suffix)] + suffix, n + 1
used.add(candidate.lower())
return candidate
def _header(ws, row: int, labels: list[str]) -> None:
for i, label in enumerate(labels, start=1):
c = ws.cell(row, i, label)
c.font, c.fill = WHITE_BOLD, HEAD_FILL
c.alignment = Alignment(horizontal="left" if i <= 2 else "right", vertical="center")
def _title(ws, title: str, sub: str) -> None:
ws["A1"] = title
ws["A1"].font = Font(bold=True, size=14)
ws["A2"] = sub
ws["A2"].font = Font(color="666666")
def _out(wb: Workbook) -> bytes:
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _meta(view: YearView, t: T, site: str | None) -> str:
bits = [f"{t('status')}: {t(view.year.status)}", f"{t('exported')}: {datetime.now():%Y-%m-%d %H:%M}"]
if site:
bits.insert(0, t("site_filter", site=site))
return " · ".join(bits)
def budget_xlsx(view: YearView, lang: str, site: str | None = None, include_empty: bool = True) -> bytes:
t = T(lang, view.year.year, view.year.department.name)
wb = Workbook()
ws = wb.active
ws.title = _safe_sheet_name(t("budget_title"), set())
_title(ws, t("budget_title"), t("budget_sub") + " · " + _meta(view, t, site))
months = t("months")
_header(ws, 4, [t("line"), t("site"), *months, t("total")])
row = 5
cost_subtotals = []
cats = sorted([c for c in view.categories if c.lines or include_empty],
key=lambda c: 0 if c.category.section == "revenue" else 1)
for cv in cats:
ws.cell(row, 1, cv.name(lang)).font = BOLD
for col in range(1, 16):
ws.cell(row, col).fill = CAT_FILL
row += 1
first = row
for lv in cv.lines:
ws.cell(row, 1, lv.line.name)
ws.cell(row, 2, lv.line.site.name if lv.line.site else t("group"))
for m, value in enumerate(lv.figures.months):
ws.cell(row, 3 + m, float(round(value, 2))).number_format = MONEY
ws.cell(row, 15, f"=SUM(C{row}:N{row})").number_format = MONEY
row += 1
label = t("revenue_total") if cv.category.section == "revenue" else t("sub_total", name=cv.name(lang))
ws.cell(row, 1, label).font = BOLD
for col in range(3, 16):
letter = get_column_letter(col)
formula = f"=SUM({letter}{first}:{letter}{row - 1})" if cv.lines else 0
c = ws.cell(row, col, formula)
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
if cv.category.section != "revenue":
cost_subtotals.append(row)
row += 2
ws.cell(row, 1, t("total_costs")).font = WHITE_BOLD
for col in range(1, 16):
ws.cell(row, col).fill = HEAD_FILL
for col in range(3, 16):
letter = get_column_letter(col)
formula = "=" + "+".join(f"{letter}{r}" for r in cost_subtotals) if cost_subtotals else 0
c = ws.cell(row, col, formula)
c.number_format, c.font = MONEY, WHITE_BOLD
ws.column_dimensions["A"].width = 52
ws.column_dimensions["B"].width = 12
for col in range(3, 16):
ws.column_dimensions[get_column_letter(col)].width = 13
ws.freeze_panes = "C5"
_inputs_sheet(wb.create_sheet(_safe_sheet_name(t("inputs"), {ws.title.lower()})), view, t, site)
return _out(wb)
def _inputs_sheet(ws, view: YearView, t: T, site: str | None) -> None:
"""The Sheet1 layout: actual, base, factor, adjusted, change, remarks."""
_title(ws, t("inputs") + f" {view.year.year}", _meta(view, t, site))
_header(ws, 4, [t("category"), t("line"), t("site"), t("prior_actual"), t("base"), t("factor"),
t("adjusted"), t("change_eur"), t("change_pct"), t("reason"), t("remarks")])
row = 5
for cv in view.with_lines():
for lv in cv.lines:
f, line = lv.figures, lv.line
ws.cell(row, 1, cv.name(t.lang))
ws.cell(row, 2, line.name)
ws.cell(row, 3, line.site.name if line.site else t("group"))
ws.cell(row, 4, float(f.prior)).number_format = MONEY
ws.cell(row, 5, float(f.base)).number_format = MONEY
ws.cell(row, 6, float(f.factor)).number_format = "0.0000"
ws.cell(row, 7, f"=ROUND(E{row}*F{row},2)").number_format = MONEY
ws.cell(row, 8, f"=G{row}-D{row}").number_format = MONEY
ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})').number_format = PCT
ws.cell(row, 10, line.adj_reason)
ws.cell(row, 11, line.remarks)
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((4, "D"), (5, "E"), (7, "G"), (8, "H")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
c = ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})')
c.number_format, c.font = PCT, BOLD
for letter, width in zip("ABCDEFGHIJK", (34, 46, 12, 16, 16, 9, 18, 15, 12, 30, 40)):
ws.column_dimensions[letter].width = width
ws.freeze_panes = "C5"
def cost_report_xlsx(view: YearView, lang: str, site: str | None = None) -> bytes:
t = T(lang, view.year.year, view.year.department.name)
wb = Workbook()
ws = wb.active
used: set[str] = set()
ws.title = _safe_sheet_name(t("summary"), used)
_title(ws, t("cost_report_title"), t("summary_sub") + " · " + _meta(view, t, site))
_header(ws, 4, [t("category"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct")])
cats = view.with_lines("cost")
row = 5
for cv in cats:
sheet = wb.create_sheet(_safe_sheet_name(cv.name(lang), used))
total_row = _category_sheet(sheet, cv, t)
ref = f"'{sheet.title}'"
ws.cell(row, 1, cv.name(lang))
ws.cell(row, 2, f"={ref}!B{total_row}").number_format = MONEY
ws.cell(row, 3, f"={ref}!C{total_row}").number_format = MONEY
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((2, "B"), (3, "C")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
ws.cell(row, 4).font = ws.cell(row, 5).font = BOLD
for letter, width in zip("ABCDE", (40, 20, 22, 18, 14)):
ws.column_dimensions[letter].width = width
return _out(wb)
def _category_sheet(ws, cv, t: T) -> int:
_title(ws, cv.name(t.lang), t("cost_lines", n=len(cv.lines)))
_header(ws, 4, [t("line"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct"), t("site"), t("remarks")])
row = 5
for lv in cv.lines:
ws.cell(row, 1, lv.line.name)
ws.cell(row, 2, float(lv.figures.prior)).number_format = MONEY
ws.cell(row, 3, float(lv.figures.adjusted)).number_format = MONEY
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
ws.cell(row, 6, lv.line.site.name if lv.line.site else t("group"))
ws.cell(row, 7, lv.line.remarks)
row += 1
ws.cell(row, 1, t("total")).font = BOLD
for col, letter in ((2, "B"), (3, "C")):
c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})")
c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP
ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY
ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT
for letter, width in zip("ABCDEFG", (46, 18, 20, 16, 12, 12, 40)):
ws.column_dimensions[letter].width = width
return row
+136
View File
@@ -0,0 +1,136 @@
from decimal import Decimal
LABELS = {
"en": {
"cost_report_title": "Capex and Costs {year}",
"cost_report_subtitle": "{dept} · cost budget: {prior} actual vs. {year} with adjustment",
"total_across": "Total across {n} categories",
"by_category": "Budget by category",
"by_category_sub": "Total cost per category, {prior} vs. {year}",
"change_by_category": "Change by category",
"change_by_category_sub": "Percentage change from {prior} actual to {year} with adjustment",
"change_legend": "Orange = cost increase · Blue = cost reduction",
"prior_actual": "{prior} actual",
"adjusted": "{year} with adjustment",
"base": "{year} without adjustment",
"change_eur": "Change (€)",
"change_pct": "Change (%)",
"cost_lines": "{n} cost lines",
"cost_line": "1 cost line",
"summary": "Summary",
"summary_sub": "All categories, {prior} actual vs. {year} with adjustment",
"category": "Category",
"line": "Line",
"site": "Site",
"group": "Group",
"total": "Total",
"factor": "Factor",
"reason": "Reason",
"remarks": "Remarks",
"budget_title": "Budget {year} · {dept}",
"budget_sub": "Monthly budget by category",
"revenue_total": "Sub total revenue",
"sub_total": "Sub total {name}",
"total_costs": "Total costs",
"status": "Status",
"exported": "Exported",
"draft": "Draft",
"in_review": "In review",
"approved": "Approved",
"inputs": "Inputs",
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"no_data": "No cost lines yet.",
"site_filter": "Site: {site}",
},
"pt": {
"cost_report_title": "Capex e Custos para {year}",
"cost_report_subtitle": "{dept} · orçamento de custos: real {prior} vs. {year} com ajuste",
"total_across": "Total de {n} categorias",
"by_category": "Orçamento por categoria",
"by_category_sub": "Custo total por categoria, {prior} vs. {year}",
"change_by_category": "Variação por categoria",
"change_by_category_sub": "Variação percentual do real {prior} para {year} com ajuste",
"change_legend": "Laranja = aumento de custo · Azul = redução de custo",
"prior_actual": "{prior} real",
"adjusted": "{year} com ajuste",
"base": "{year} sem ajuste",
"change_eur": "Variação (€)",
"change_pct": "Variação (%)",
"cost_lines": "{n} linhas de custo",
"cost_line": "1 linha de custo",
"summary": "Resumo",
"summary_sub": "Todas as categorias, real {prior} vs. {year} com ajuste",
"category": "Categoria",
"line": "Linha",
"site": "Unidade",
"group": "Grupo",
"total": "Total",
"factor": "Fator",
"reason": "Motivo",
"remarks": "Observações",
"budget_title": "Orçamento {year} · {dept}",
"budget_sub": "Orçamento mensal por categoria",
"revenue_total": "Subtotal receitas",
"sub_total": "Subtotal {name}",
"total_costs": "Total de custos",
"status": "Estado",
"exported": "Exportado",
"draft": "Rascunho",
"in_review": "Em revisão",
"approved": "Aprovado",
"inputs": "Dados",
"months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"],
"no_data": "Ainda não há linhas de custo.",
"site_filter": "Unidade: {site}",
},
}
class T:
def __init__(self, lang: str, year: int, dept: str = ""):
self.lang = lang if lang in LABELS else "pt"
self.labels = LABELS[self.lang]
self.ctx = {"year": year, "prior": year - 1, "dept": dept}
def __call__(self, key: str, **kw):
value = self.labels[key]
return value.format(**{**self.ctx, **kw}) if isinstance(value, str) else value
def _group(self, value: Decimal, decimals: int) -> str:
q = Decimal(1).scaleb(-decimals) if decimals else Decimal(1)
text = f"{abs(Decimal(value)).quantize(q):,.{decimals}f}"
whole, _, frac = text.partition(".")
whole = whole.replace(",", " ")
if not frac:
return whole
return whole + ("," if self.lang == "pt" else ".") + frac
def eur(self, value, decimals: int = 2, signed: bool = False) -> str:
value = Decimal(value or 0)
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ("−" if value < 0 else "")
body = self._group(value, decimals)
return f"{sign}{body} €" if self.lang == "pt" else f"{sign}€{body}"
def num(self, value, decimals: int = 2) -> str:
value = Decimal(value or 0)
return ("\u2212" if value < 0 else "") + self._group(value, decimals)
def pct(self, value, signed: bool = True) -> str:
if value is None:
return "—"
value = Decimal(value)
sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ""
return f"{sign}{self._group(value, 2)}%"
def short_eur(self, value: float) -> str:
v = abs(value)
sign = "−" if value < 0 else ""
if v >= 1_000_000:
body = f"{v / 1_000_000:g}M"
elif v >= 1000:
body = f"{v / 1000:g}k"
else:
body = f"{v:g}"
if self.lang == "pt":
return f"{sign}{body.replace('.', ',')} €"
return f"{sign}€{body}"
+52
View File
@@ -0,0 +1,52 @@
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from jinja2 import Environment, FileSystemLoader, select_autoescape
from ..summary import YearView
from .charts import change_bars, grouped_bars
from .i18n import T
TEMPLATES = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape(["html"]))
CSS = (TEMPLATES / "report.css").read_text()
def _exported() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M")
def cost_report_html(view: YearView, lang: str, site: str | None = None, screen: bool = True) -> str:
t = T(lang, view.year.year, view.year.department.name)
cost_cats = view.with_lines("cost")
cats = []
for cv in cost_cats:
rows = [(lv.line.name, float(lv.figures.prior), float(lv.figures.adjusted)) for lv in cv.lines]
cats.append(SimpleNamespace(
name=cv.name(lang), count=len(cv.lines), totals=cv.totals,
chart=grouped_bars(rows, t.short_eur, lambda v: t.eur(v, 0))))
overview = grouped_bars([(c.name, float(c.totals.prior), float(c.totals.adjusted)) for c in cats], t.short_eur)
ranked = sorted([c for c in cats if c.totals.change_pct is not None],
key=lambda c: c.totals.change_pct, reverse=True)
change = change_bars([(c.name, float(c.totals.change_pct)) for c in ranked], lambda v: t.pct(v))
return env.get_template("cost_report.html").render(
t=t, title=t("cost_report_title"), year=view.year, site=site, cats=cats, costs=view.costs,
overview_chart=overview, change_chart=change, exported=_exported(), css=CSS, screen=screen)
def budget_html(view: YearView, lang: str, site: str | None = None, include_empty: bool = False) -> str:
t = T(lang, view.year.year, view.year.department.name)
cats = [SimpleNamespace(name=cv.name(lang), category=cv.category, lines=cv.lines, totals=cv.totals)
for cv in view.categories if cv.lines or include_empty]
# revenue first, as in the Budget IT sheet
cats.sort(key=lambda c: 0 if c.category.section == "revenue" else 1)
return env.get_template("budget.html").render(
t=t, title=t("budget_title"), year=view.year, site=site, cats=cats, costs=view.costs,
exported=_exported(), css=CSS)
def to_pdf(html: str) -> bytes:
from weasyprint import HTML # imported lazily: it loads system libraries
return HTML(string=html).write_pdf()
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="{{ t.lang }}">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title><style>{{ css|safe }}</style></head>
<body>
<section class="page budget">
<h1>{{ title }}</h1>
<div class="sub">{{ t('budget_sub') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %} · {{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
<table class="grid">
<thead><tr><th>{{ t('line') }}</th><th>{{ t('site') }}</th>{% for m in t('months') %}<th>{{ m }}</th>{% endfor %}<th>{{ t("total") }} (€)</th></tr></thead>
<tbody>
{% for c in cats %}
<tr class="cat"><td colspan="15">{{ c.name }}</td></tr>
{% for l in c.lines %}
<tr><td class="name">{{ l.line.name }}</td><td>{{ l.line.site.name if l.line.site else t('group') }}</td>
{% for m in l.figures.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(l.figures.adjusted) }}</td></tr>
{% endfor %}
<tr class="subtotal"><td colspan="2">{{ t('revenue_total') if c.category.section == 'revenue' else t('sub_total', name=c.name) }}</td>
{% for m in c.totals.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(c.totals.adjusted) }}</td></tr>
{% endfor %}
<tr class="grand"><td colspan="2">{{ t('total_costs') }}</td>{% for m in costs.months %}<td class="num">{{ t.num(m) }}</td>{% endfor %}<td class="num">{{ t.num(costs.adjusted) }}</td></tr>
</tbody>
</table>
</section>
</body></html>
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="{{ t.lang }}">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title><style>{{ css|safe }}</style>
{% if screen %}<script>
function fit(){var w=document.documentElement.clientWidth-32;var z=Math.min(1,w/1280);document.documentElement.style.setProperty('--zoom',z);}
window.addEventListener('resize',fit);document.addEventListener('DOMContentLoaded',fit);
</script>{% endif %}
</head>
<body>
{% set total_pages = 3 + cats|length %}
<section class="page cover">
<h1>{{ title }}</h1>
<p class="sub">{{ t('cost_report_subtitle') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %}</p>
<div class="kicker">{{ t('total_across', n=cats|length) }}</div>
<div class="big">{{ t.eur(costs.prior, 0) }} &nbsp;→&nbsp; {{ t.eur(costs.adjusted, 0) }}</div>
<div class="delta {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }} &nbsp;({{ t.eur(costs.change, 0, true) }})</div>
<div class="meta">{{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}</div>
</section>
<section class="page">
<h1>{{ t('by_category') }}</h1>
<p class="sub">{{ t('by_category_sub') }}</p>
<div class="rule"></div>
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
<div class="chartbox full">{{ overview_chart|safe }}</div>
<div class="footer">{{ title }}<span class="n">2 / {{ total_pages }}</span></div>
</section>
<section class="page">
<h1>{{ t('change_by_category') }}</h1>
<p class="sub">{{ t('change_by_category_sub') }}</p>
<div class="rule"></div>
<div class="legend" style="text-align:center">{{ t('change_legend') }}</div>
<div class="chartbox full">{{ change_chart|safe }}</div>
<div class="footer">{{ title }}<span class="n">3 / {{ total_pages }}</span></div>
</section>
{% for c in cats %}
<section class="page">
<h1>{{ c.name }}</h1>
<p class="sub">{{ t('cost_line') if c.count == 1 else t('cost_lines', n=c.count) }}</p>
<div class="rule"></div>
<div class="legend"><span><i style="background:#2f6fd6"></i>{{ t('prior_actual') }}</span><span><i style="background:#e8632b"></i>{{ t('adjusted') }}</span></div>
<div class="body">
<div class="chartbox">{{ c.chart|safe }}</div>
<div class="kpis">
<div class="kpi"><div class="k">{{ t('prior_actual') }}</div><div class="v">{{ t.eur(c.totals.prior) }}</div></div>
<div class="kpi"><div class="k">{{ t('adjusted') }}</div><div class="v">{{ t.eur(c.totals.adjusted) }}</div></div>
<div class="kpi"><div class="k">{{ t('change_eur') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</div></div>
<div class="kpi"><div class="k">{{ t('change_pct') }}</div><div class="v {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</div></div>
</div>
</div>
<div class="footer">{{ title }}<span class="n">{{ 3 + loop.index }} / {{ total_pages }}</span></div>
</section>
{% endfor %}
<section class="page">
<h1>{{ t('summary') }}</h1>
<p class="sub">{{ t('summary_sub') }}</p>
<div class="rule"></div>
<table class="summary">
<thead><tr><th>{{ t('category') }}</th><th class="num">{{ t('prior_actual') }}</th><th class="num">{{ t('adjusted') }}</th><th class="num">{{ t('change_eur') }}</th><th class="num">{{ t('change_pct') }}</th></tr></thead>
<tbody>
{% for c in cats %}
<tr><td>{{ c.name }}</td><td class="num">{{ t.eur(c.totals.prior) }}</td><td class="num">{{ t.eur(c.totals.adjusted) }}</td>
<td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.eur(c.totals.change, 2, true) }}</td><td class="num {{ 'up' if c.totals.change > 0 else 'down' }}">{{ t.pct(c.totals.change_pct) }}</td></tr>
{% endfor %}
<tr class="total"><td>{{ t('total') }}</td><td class="num">{{ t.eur(costs.prior) }}</td><td class="num">{{ t.eur(costs.adjusted) }}</td>
<td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.eur(costs.change, 2, true) }}</td><td class="num {{ 'up' if costs.change > 0 else 'down' }}">{{ t.pct(costs.change_pct) }}</td></tr>
</tbody>
</table>
<div class="footer">{{ title }}<span class="n">{{ total_pages }} / {{ total_pages }}</span></div>
</section>
</body></html>
+62
View File
@@ -0,0 +1,62 @@
@page { size: 338.67mm 190.5mm; margin: 0; }
@page budget { size: A3 landscape; margin: 12mm 10mm 14mm 10mm;
@bottom-left { content: string(doc-title); font: 8pt Helvetica, Arial, sans-serif; color: #888; }
@bottom-right { content: counter(page) " / " counter(pages); font: 8pt Helvetica, Arial, sans-serif; color: #888; } }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; color: #1d1d1f; background: #ececee; }
.page { position: relative; width: 338.67mm; height: 190.5mm; padding: 13mm 16mm 12mm; background: #fbfbf9;
overflow: hidden; page-break-after: always; break-after: page; }
.page:last-child { page-break-after: auto; break-after: auto; }
.page h1 { font-size: 22pt; margin: 0 0 1.5mm; }
.page .sub { font-size: 10pt; color: #444; margin: 0 0 3mm; }
.rule { border-top: 1px solid #ddd; margin: 2mm 0 3mm; }
.legend { font-size: 8.5pt; color: #333; margin-bottom: 2mm; }
.legend span { display: inline-block; margin-right: 18mm; }
.legend i { display: inline-block; width: 3mm; height: 3mm; margin-right: 2mm; vertical-align: -0.4mm; }
.footer { position: absolute; left: 16mm; right: 16mm; bottom: 6mm; font-size: 7pt; color: #888; }
.footer .n { float: right; }
.cover { padding-left: 22mm; }
.cover::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4mm; background: #2f6fd6; }
.cover h1 { font-size: 34pt; margin-top: 48mm; }
.cover .sub { font-size: 13pt; }
.cover .kicker { margin-top: 16mm; font-size: 10pt; color: #444; }
.cover .big { font-size: 22pt; font-weight: bold; margin: 2mm 0; }
.cover .delta { font-size: 13pt; font-weight: bold; }
.up { color: #c2410c; } .down { color: #15803d; }
.body { display: flex; gap: 8mm; }
.chartbox { flex: 1 1 auto; height: 128mm; }
.chartbox.full { height: 136mm; }
.chartbox svg { width: 100%; height: 100%; }
.kpis { flex: 0 0 72mm; padding-top: 2mm; }
.kpi { margin-bottom: 7mm; }
.kpi .k { font-size: 9pt; color: #333; }
.kpi .v { font-size: 18pt; font-weight: bold; margin-top: 1.5mm; }
table.summary { width: 100%; border-collapse: collapse; font-size: 9pt; margin-top: 3mm; }
table.summary th { text-align: left; font-weight: bold; border-bottom: 1px solid #bbb; padding: 2mm; }
table.summary td { padding: 1.6mm 2mm; border-bottom: 1px solid #e4e4e4; }
table.summary .num { text-align: right; white-space: nowrap; }
table.summary tr.total td { font-weight: bold; border-top: 1px solid #999; }
.meta { font-size: 8pt; color: #777; margin-top: 2mm; }
/* annual budget */
.budget { page: budget; background: #fff; padding: 0; width: auto; height: auto; overflow: visible; }
.budget h1 { font-size: 16pt; margin: 0 0 1mm; string-set: doc-title content(); }
.budget .sub { font-size: 9pt; color: #555; margin-bottom: 4mm; }
table.grid { width: 100%; border-collapse: collapse; font-size: 7.2pt; }
table.grid th, table.grid td { padding: 1.1mm 1.2mm; border-bottom: 1px solid #eee; }
table.grid thead th { background: #1f3b63; color: #fff; text-align: right; font-weight: bold; }
table.grid thead th:first-child, table.grid thead th:nth-child(2) { text-align: left; }
table.grid thead { display: table-header-group; }
table.grid td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
table.grid tr.cat td { font-weight: bold; background: #eef2f8; padding-top: 2mm; }
table.grid tr.subtotal td { font-weight: bold; border-top: 1px solid #9aa; border-bottom: 1px solid #9aa; }
table.grid tr.grand td { font-weight: bold; background: #1f3b63; color: #fff; }
table.grid tr { page-break-inside: avoid; }
table.grid td.name { max-width: 70mm; }
@media screen {
body { padding: 16px 0; }
.page { margin: 0 auto 16px; box-shadow: 0 1px 4px rgba(0,0,0,.15); zoom: var(--zoom, 1); }
.budget { width: auto; max-width: 1600px; height: auto; padding: 16px 20px; margin: 0 auto; overflow: visible; }
}