Scaffolded the OpenBudget app structure with initial pages, components, and data. Included features for compare, reform design, and about sections. Added base utilities, design system, and data handling modules.

This commit is contained in:
m17hr1l
2025-10-19 21:05:34 +02:00
commit 06a312d5e9
42 changed files with 79507 additions and 0 deletions

47
openbudget-app-scaffold/.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
dist-ssr
*.local
build
out/*
!out/.gitkeep
# IDE - VSCode
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Debug & Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Testing
coverage
*.lcov
# TypeScript
*.tsbuildinfo
# OS
.DS_Store
Thumbs.db

8
openbudget-app-scaffold/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
openbudget-app-scaffold/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_24" default="true" project-jdk-name="openjdk-24" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/openbudget-app-scaffold.iml" filepath="$PROJECT_DIR$/.idea/openbudget-app-scaffold.iml" />
</modules>
</component>
</project>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,6 @@
# OpenBudget.DE App Scaffold
Quick start:
- npm i
- npm run dev
Upload `HH2026_titel_eur.json` in Explorer.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenBudget.DE</title>
</head>
<body class="bg-slate-950 text-white">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8252
openbudget-app-scaffold/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
{
"name": "openbudget-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-select": "^2.2.6",
"@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/react-table": "^8.16.0",
"clsx": "^2.1.0",
"fast-xml-parser": "^5.3.0",
"framer-motion": "^11.0.0",
"i18next": "^23.10.1",
"npm": "^11.6.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^13.5.0",
"react-router-dom": "^6.23.0",
"recharts": "^2.9.0",
"zod": "^3.23.8",
"zustand": "^4.4.1"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^7.1.9",
"vitest": "^3.2.4"
},
"packageManager": "npm@11.6.1"
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
export class ErrorBoundary extends React.Component<{children: React.ReactNode}, {error?: Error}> {
constructor(props:any){ super(props); this.state = { error: undefined } }
static getDerivedStateFromError(error: Error){ return { error } }
render(){
if (this.state.error) {
return (
<div className="container py-10">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<pre className="mt-4 bg-black/40 p-4 rounded overflow-auto">{String(this.state.error)}</pre>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,3 @@
import React from 'react'
export function AppProviders({ children }: { children: React.ReactNode }) { return <>{children}</> }

View File

@@ -0,0 +1,38 @@
import React from 'react'
import { createBrowserRouter } from 'react-router-dom'
import LandingPage from '@/features/landing/pages/LandingPage'
import ExplorerPage from '@/features/explorer/pages/ExplorerPage'
import ReformDesignerPage from '@/features/reform/pages/ReformDesignerPage'
import ConvertPage from '@/features/convert/pages/ConvertPage'
import AboutPage from '@/features/about/pages/AboutPage'
import ComparePage from '@/features/compare/pages/ComparePage'
import AppShell from '@/design-system/layout/AppShell'
// 404 Not Found component
const NotFoundPage = () => (
<div className="container py-10 text-center">
<h1 className="text-3xl font-bold mb-4">Page Not Found</h1>
<p className="text-white/70">The page you're looking for doesn't exist.</p>
</div>
)
export const router = createBrowserRouter([
{
path: '/',
element: <AppShell/>,
errorElement: <div className="container py-10 text-center">
<h1 className="text-3xl font-bold mb-4 text-red-400">Something went wrong</h1>
<p className="text-white/70">Please try refreshing the page.</p>
</div>,
children: [
{ index: true, element: <LandingPage/> },
{ path: 'convert', element: <ConvertPage/> },
{ path: 'explorer', element: <ExplorerPage/> },
{ path: 'reform', element: <ReformDesignerPage/> },
{ path: 'compare', element: <ComparePage/> },
{ path: 'about', element: <AboutPage/> },
{ path: '*', element: <NotFoundPage/> }
]
}
])

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom'
export default function AppShell(){
const loc = useLocation()
const nav = [
{ to: '/', label: 'Home' },
{ to: '/convert', label: 'Converter' },
{ to: '/explorer', label: 'Explorer' },
{ to: '/reform', label: 'Reform-Designer' },
{ to: '/compare', label: 'Compare' },
{ to: '/about', label: 'About' },
]
return (
<div className="min-h-screen bg-gradient-to-b from-black via-zinc-900 to-black text-white">
<nav className="sticky top-0 backdrop-blur bg-black/70 border-b border-zinc-800 z-50">
<div className="container flex items-center gap-6 py-3">
<div className="font-extrabold text-lg tracking-wide">
<span className="text-deutschland-gold">Open</span>
<span className="text-white">Budget</span>
<span className="text-deutschland-red">.DE</span>
</div>
{nav.map(n => (
<Link key={n.to} to={n.to}
className={`relative text-sm px-1 ${
loc.pathname===n.to ? 'text-white' : 'text-white/80 hover:text-white'
}`}
>
{n.label}
{loc.pathname===n.to && (
<span className="absolute -bottom-1 left-0 right-0 h-0.5 bg-deutschland-red rounded"></span>
)}
</Link>
))}
<div className="ml-auto flex items-center gap-2">
<span className="inline-flex h-1.5 w-3 rounded-full bg-deutschland-black"></span>
<span className="inline-flex h-1.5 w-3 rounded-full bg-deutschland-red"></span>
<span className="inline-flex h-1.5 w-3 rounded-full bg-deutschland-gold"></span>
</div>
</div>
</nav>
<main><Outlet/></main>
<footer className="border-t border-white/10 mt-16">
<div className="container py-8 text-sm text-white/70">© {new Date().getFullYear()} OpenBudget.DE</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
export default function AboutPage(){
return (
<section className="container py-12 space-y-6">
<h1 className="text-3xl font-bold">About</h1>
<p className="mt-2 max-w-3xl text-white/80">
Warum OpenBudget.DE? Weil der Bundeshaushalt groß ist aber nicht unverständlich sein muss.
Wir machen ihn durchsuchbar, filterbar und erklärbar. So diskutieren wir auf Basis von Daten,
nicht Bauchgefühl. Alle Ergebnisse sind reproduzierbar: Code offen, Daten amtlich.
</p>
<ul className="space-y-2 text-white/80">
<li> <b>Open Data</b>: nachvollziehbar, quelloffen</li>
<li> <b>Kein Tracking</b>: keine personenbezogenen Daten</li>
<li> <b>Lizenz</b>: MIT (Code), amtliche Werke (Daten)</li>
</ul>
</section>
)
}

View File

@@ -0,0 +1,54 @@
import React, { useMemo } from 'react'
import { useDataset } from '@/store'
import { useReforms } from '@/store/reforms.slice'
import { ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip, Legend } from 'recharts'
export default function ComparePage(){
const titles = useDataset(s=> s.titles)
const { totals } = useReforms()
const t = totals()
const originalSpend = useMemo(() => titles.reduce((sum:any, r:any) => sum + (r.betrag_eur || 0), 0), [titles])
const afterSpend = originalSpend - (t.expSavingsMrd * 1e9)
const data = [{ name: 'Ausgaben', Vorher: +(originalSpend/1e9).toFixed(2), Nachher: +(afterSpend/1e9).toFixed(2) }]
return (
<section className="container py-10 space-y-8">
<h1 className="text-3xl font-bold">Vorher / Nachher</h1>
<div className="grid md:grid-cols-4 gap-6">
<div className="rounded-xl bg-white/5 border border-white/10 p-6">
<div className="text-sm text-white/70">Original: Ausgaben gesamt</div>
<div className="text-3xl font-extrabold">{(originalSpend/1e9).toFixed(2)} Mrd </div>
</div>
<div className="rounded-xl bg-deutschland-gold/10 border border-deutschland-gold/30 p-6">
<div className="text-sm text-yellow-300/80">Einsparungen</div>
<div className="text-3xl font-extrabold text-yellow-300">+{t.expSavingsMrd.toFixed(1)} Mrd </div>
</div>
<div className="rounded-xl bg-deutschland-red/10 border border-deutschland-red/30 p-6">
<div className="text-sm text-red-300/80">Einnahmen-Delta</div>
<div className="text-3xl font-extrabold text-red-300">{t.revenueDeltaMrd.toFixed(1)} Mrd </div>
</div>
<div className="rounded-xl bg-white/5 border border-white/10 p-6">
<div className="text-sm text-white/70">Nach Reform: Ausgaben</div>
<div className="text-3xl font-extrabold">{(afterSpend/1e9).toFixed(2)} Mrd </div>
</div>
</div>
<div className="card p-6">
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 10, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid stroke="#3f3f46" />
<XAxis dataKey="name" tick={{ fill: '#e5e7eb' }} />
<YAxis tick={{ fill: '#e5e7eb' }} />
<Tooltip />
<Legend />
<Bar dataKey="Vorher" fill="#FFCE00" />
<Bar dataKey="Nachher" fill="#DD0000" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react'
import { XMLParser } from 'fast-xml-parser'
type Row = {
ep: string; ep_text: string;
kapitel: string; kapitel_text: string;
art: 'Einnahmen'|'Ausgaben';
titel_nr: string;
bezeichnung: string;
betrag_eur: number;
seite?: string;
}
function mapXMLtoRows(xmlObj:any): Row[] {
const rows: Row[] = []
const hh = xmlObj?.haushalt
if(!hh) return rows
const eps = Array.isArray(hh.einzelplan) ? hh.einzelplan : (hh.einzelplan ? [hh.einzelplan] : [])
for(const ep of eps){
const epNr = ep?.nr ?? ep?.['@_nr'] ?? ep?.['nr']
const epText = ep?.text ?? ep?.['text']
const kaps = Array.isArray(ep?.kapitel) ? ep.kapitel : (ep?.kapitel ? [ep.kapitel] : [])
for(const k of kaps){
const kNr = k?.nr ?? k?.['@_nr'] ?? k?.['nr']
const kText = k?.text ?? k?.['text']
const pushTitle = (artName:'Einnahmen'|'Ausgaben', t:any) => {
const nr = String(t?.nr ?? t?.['@_nr'] ?? t?.['nr'] ?? '')
const text = t?.text ?? t?.['text'] ?? ''
const wert = Number(t?.soll?.wert ?? t?.soll?.['wert'] ?? 0) // often in Tsd €
const seite = t?.seite ?? t?.['seite']
rows.push({ ep:String(epNr), ep_text:epText||'', kapitel:String(kNr||''), kapitel_text:kText||'', art:artName, titel_nr:nr, bezeichnung:text, betrag_eur: Math.round(wert*1000), seite })
}
const handleNode = (artName:'Einnahmen'|'Ausgaben', node:any) => {
if(!node) return
const direct = node.titel ? (Array.isArray(node.titel) ? node.titel : [node.titel]) : []
for(const t of direct) pushTitle(artName, t)
const arts = node['einnahmen-ausgaben-art']
const list = Array.isArray(arts) ? arts : (arts ? [arts] : [])
for(const a of list){
const titles = a?.titel ? (Array.isArray(a.titel)?a.titel:[a.titel]) : []
for(const t of titles) pushTitle(artName, t)
}
}
handleNode('Einnahmen', k?.einnahmen)
handleNode('Ausgaben', k?.ausgaben)
}
}
return rows
}
export default function ConvertPage(){
const [out, setOut] = useState<string>('')
const [count, setCount] = useState<number>(0)
const handleXML = async (file: File) => {
const xml = await file.text()
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' })
const j = parser.parse(xml)
const rows = mapXMLtoRows(j)
setCount(rows.length)
setOut(JSON.stringify(rows, null, 2))
}
const download = () => {
const blob = new Blob([out], {type: 'application/json'})
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'haushalt_titel_eur.json'
a.click()
}
return (
<section className="container py-10 space-y-6">
<h1 className="text-3xl font-bold">XML JSON Converter</h1>
<p className="text-white/70 max-w-2xl">
Lade die offizielle XML hoch, wir wandeln sie in ein JSON, das der Explorer versteht
(Felder: ep, kapitel, titel_nr, bezeichnung, betrag_eur).
</p>
<label className="inline-flex items-center gap-3 cursor-pointer rounded-xl bg-black text-deutschland-gold px-4 py-3 ring-1 ring-zinc-800 hover:bg-zinc-900">
<span>XML hochladen</span>
<input type="file" accept=".xml,application/xml" className="hidden" onChange={e=>{
const f = e.target.files?.[0]; if (f) handleXML(f)
}}/>
</label>
{out && (
<div className="space-y-3">
<div className="text-sm text-white/70">Einträge: {count.toLocaleString('de-DE')}</div>
<button onClick={download} className="rounded-lg bg-deutschland-gold text-black px-4 py-2">JSON herunterladen</button>
<pre className="max-h-96 overflow-auto p-4 rounded bg-white/5 border border-white/10 text-xs">{out}</pre>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,316 @@
[
{
"id": "ngos_remove",
"label": "NGOFörderungen streichen",
"description": "In- und ausländische NGO-Zuwendungen (direkt/indirekt) auf 0 setzen.",
"type": "exp",
"impactMrd": 2.0,
"appliesToTags": [
"NGO"
],
"epHints": [
"05",
"09",
"10",
"11",
"12",
"13",
"16"
],
"notes": "Schätzwert; je nach Abgrenzung variabel."
},
{
"id": "religion_state_payments",
"label": "Staatsleistungen/Kirchenzahlungen streichen",
"description": "Historische Staatsleistungen sowie kultusbezogene Zahlungen einstellen.",
"type": "exp",
"impactMrd": 0.6,
"appliesToTags": [
"Religion",
"Kirche",
"Kultur-Religion"
],
"epHints": [
"04",
"06",
"11"
],
"notes": "Schätzung 0.50.7 Mrd €."
},
{
"id": "party_foundations",
"label": "Parteinahe Stiftungen beenden",
"description": "Öffentliche Finanzierung parteinaher Stiftungen streichen.",
"type": "exp",
"impactMrd": 0.5,
"appliesToTags": [
"Parteistiftung"
],
"epHints": [
"04"
],
"notes": "Ordnung 0.30.7 Mrd €."
},
{
"id": "subsidies_general",
"label": "Subventionen allgemein streichen",
"description": "Industrie-, Energie-, Agrar-, Regional- und Branchenbeihilfen beenden.",
"type": "exp",
"impactMrd": 25.0,
"appliesToTags": [
"Subvention",
"Förderung",
"Industriehilfe",
"Agrar",
"Energiehilfe"
],
"epHints": [
"09",
"10",
"16",
"23"
],
"notes": "Konservativer Sammelansatz; später präzisieren."
},
{
"id": "ev_mobility_stop",
"label": "EAutoFörderungen & Ladeinfrastruktur beenden",
"description": "Kaufprämien/Umweltboni/LadeinfrastrukturZuschüsse streichen.",
"type": "exp",
"impactMrd": 3.0,
"appliesToTags": [
"Elektromobilität",
"Ladeinfrastruktur",
"Batterie"
],
"epHints": [
"09",
"16"
],
"notes": "Bandbreite 25 Mrd €."
},
{
"id": "climate_subsidies_stop",
"label": "Klimasubventionen (Förderprogramme) stoppen",
"description": "Förderprogramme mit primärem Klimasubventionscharakter einstellen.",
"type": "exp",
"impactMrd": 12.0,
"appliesToTags": [
"Klima",
"Energiewende",
"Wasserstoff",
"Dekarbonisierung"
],
"epHints": [
"09",
"23"
],
"notes": "Zusammen mit EV 1218 Mrd €."
},
{
"id": "politics_salaries_halve",
"label": "Politikgehälter 50 %",
"description": "Bezüge von Parlament und Regierung halbieren; an Wirtschaft koppeln.",
"type": "exp",
"impactMrd": 0.7,
"appliesToTags": [
"Politikgehälter"
],
"epHints": [
"02",
"04"
],
"notes": "Nebenkosten grob enthalten."
},
{
"id": "politics_pensions_align",
"label": "Politikerpensionen in Regelrente überführen",
"description": "Sonderversorgungen beenden, Angleichung an allgemeine Rentensystematik.",
"type": "exp",
"impactMrd": 1.0,
"appliesToTags": [
"Politikerpension"
],
"epHints": [
"02",
"04"
],
"notes": "Spareffekt mittelfristig steigend."
},
{
"id": "citizens_income_eligibility",
"label": "Bürgergeld nur bei ≥2 Jahren Beitragszeit",
"description": "Anspruchsbegrenzung: unter 2 Jahren Beitragszeit keine Zahlung.",
"type": "exp",
"impactMrd": 5.0,
"appliesToTags": [
"Bürgergeld",
"Grundsicherung"
],
"epHints": [
"11"
],
"notes": "Placeholder; stark vom Bestand abhängig."
},
{
"id": "funding_programs_cut",
"label": "Förderprogramme (unspezifisch) streichen",
"description": "Kleine/mittlere Förderlinien ohne klaren ROI beenden.",
"type": "exp",
"impactMrd": 4.0,
"appliesToTags": [
"Förderprogramm",
"Projektförderung"
],
"epHints": [
"04",
"05",
"09",
"12",
"13",
"16",
"23"
],
"notes": "Catchall; später per Tagging konkret."
},
{
"id": "broadcast_cuts",
"label": "BundesMedienförderung kürzen",
"description": "Bundesmittel für Medien-/Film-/Presseförderung zurückfahren.",
"type": "exp",
"impactMrd": 0.8,
"appliesToTags": [
"Medien",
"Filmförderung",
"Presse"
],
"epHints": [
"04",
"17"
],
"notes": "Nicht der Rundfunkbeitrag (Ländersache)."
},
{
"id": "ministries_slim",
"label": "Ministerien verschlanken / zusammenlegen",
"description": "Reduktion der Ressorts (810) und Abbau Doppelstrukturen.",
"type": "exp",
"impactMrd": 1.5,
"appliesToTags": [
"Ministerium",
"Verwaltung"
],
"epHints": [
"04",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
"13",
"14",
"16",
"23"
],
"notes": "Direktkosten klein; Overhead spart mittelfristig mehr."
},
{
"id": "investment_tax_abolish",
"label": "Investment-/Abgeltungsteuer abschaffen",
"description": "25 % Abgeltungsteuer (+Soli/KiSt) auf Kapitalerträge streichen.",
"type": "rev",
"impactMrd": -38.0,
"appliesToTags": [
"Abgeltungsteuer",
"Kapitalertragsteuer"
],
"epHints": [],
"notes": "Mindereinnahmen 3540 Mrd €/a."
},
{
"id": "homeoffice_rent_taxfree",
"label": "Eigenmiete Homeoffice steuerfrei",
"description": "Miete aus privatem Arbeitszimmer an eigene GmbH steuerfrei (Cap).",
"type": "rev",
"impactMrd": -2.0,
"appliesToTags": [
"Homeoffice",
"Eigenmiete"
],
"epHints": [],
"notes": "Mindereinnahmen grob 14 Mrd €/a."
},
{
"id": "ukraine_aid_review",
"label": "UkraineHilfen überprüfen/kürzen",
"description": "Militärische, wirtschaftliche und humanitäre Hilfen priorisieren/streichen.",
"type": "exp",
"impactMrd": 2.0,
"appliesToTags": [
"Ukraine",
"Militärhilfe",
"Wiederaufbau",
"Humanitär"
],
"epHints": [
"05",
"09",
"14"
],
"notes": "Placeholder bis Tagging fertig."
},
{
"id": "health_fund_scrub",
"label": "GKVZuschuss effizientisieren",
"description": "Effizienzhebel, Fehlanreize/Verwaltung reduzieren (keine Leistungskürzung).",
"type": "exp",
"impactMrd": 3.0,
"appliesToTags": [
"Gesundheit",
"GKV",
"Gesundheitsfonds"
],
"epHints": [
"12"
],
"notes": "Konservativer Effizienzansatz."
},
{
"id": "pension_politicians_remove",
"label": "Sonderpensionen Politik vollständig abbauen",
"description": "Keine separaten Systeme; Integration in allgemeine Rentenversicherung.",
"type": "exp",
"impactMrd": 0.5,
"appliesToTags": [
"Politikerpension"
],
"epHints": [
"02",
"04"
],
"notes": "Ergänzt die Angleichung; Doppelzählung vermeiden."
},
{
"id": "ideology_posts_remove",
"label": "Ideologische Posten streichen",
"description": "Programme ohne klaren gesellschaftlichen/ökonomischen Nutzen beenden.",
"type": "exp",
"impactMrd": 3.0,
"appliesToTags": [
"Ideologie",
"Kampagne",
"Image"
],
"epHints": [
"04",
"05",
"09",
"12",
"13",
"23"
],
"notes": "Catchall; später konkretisieren."
}
]

View File

@@ -0,0 +1,16 @@
// Type + export reformsData from the JSON file.
// This avoids flakiness with direct JSON imports in some setups.
export type Reform = {
id: string;
label: string;
description: string;
type: 'exp' | 'rev';
impactMrd: number;
appliesToTags: string[];
epHints: string[];
notes: string;
};
import data from './reforms.json';
export const reformsData = data as Reform[];

View File

@@ -0,0 +1,19 @@
import { TitleRow } from '@/store'
export const uid = (r: TitleRow) => `${r.einzelplan_nr}|${r.kapitel_nr}|${r.titel_nr}`
export function filterRows(rows: TitleRow[], opts: {ep?: string|'ALL', query?: string, minMio?: number, maxMio?: number}){
const q = (opts.query||'').trim().toLowerCase()
const min = opts.minMio!=null ? opts.minMio*1e6 : -Infinity
const max = opts.maxMio!=null ? opts.maxMio*1e6 : Infinity
return rows.filter(r=>{
if(opts.ep && opts.ep!=='ALL' && r.einzelplan_nr!==opts.ep) return false
if(q){
const hay = `${r.einzelplan_name} ${r.kapitel_name} ${r.bezeichnung}`.toLowerCase()
if(!hay.includes(q)) return false
}
const v = r.betrag_eur||0
return v>=min && v<=max
})
}
export const sumEUR = (rows: TitleRow[]) => rows.reduce((a,r)=> a+(r.betrag_eur||0), 0)
export const topN = (rows: TitleRow[], n=10) => [...rows].sort((a,b)=> (b.betrag_eur||0)-(a.betrag_eur||0)).slice(0,n)

View File

@@ -0,0 +1,227 @@
import React, { useMemo, useState } from 'react'
import * as Select from '@radix-ui/react-select'
import { useDataset, useFilters } from '@/store'
import { filterRows, sumEUR, topN } from '@/features/data/selectors'
import { mrd, mio } from '@/utils/number'
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, ResponsiveContainer, Tooltip, LabelList } from 'recharts'
export default function ExplorerPage(){
const titles = useDataset(s=> s.titles)
const setData = useDataset(s=> s.loadFromJson)
const filters = useFilters()
const [min, setMin] = useState('')
const [max, setMax] = useState('')
const [filename, setFilename] = useState<string>('')
const eps = useMemo(()=>{
const m = new Map<string,string>()
titles.forEach(r=> m.set(r.einzelplan_nr, r.einzelplan_name))
return Array.from(m.entries()).sort((a,b)=> a[0].localeCompare(b[0]))
},[titles])
const rows = useMemo(()=> filterRows(titles, {
ep: filters.ep, query: filters.query, minMio: min?parseFloat(min):undefined, maxMio: max?parseFloat(max):undefined
}),[titles, filters, min, max])
const top = useMemo(
()=> topN(rows, 10).map(r => ({
name: `${r.titel_nr} ${r.bezeichnung}`.slice(0, 60),
value: Math.round(mio(r.betrag_eur))
})),
[rows]
)
const total = useMemo(()=> mrd(sumEUR(rows)), [rows])
const ctl =
"w-full rounded-lg bg-black text-deutschland-gold placeholder-yellow-300/50 " +
"px-3 py-3 text-base shadow-sm ring-1 ring-zinc-800 " +
"focus:outline-none focus:ring-2 focus:ring-deutschland-red"
function onFile(e: React.ChangeEvent<HTMLInputElement>){
const f = e.target.files?.[0]; if(!f) return
setFilename(f.name)
const reader = new FileReader()
reader.onload = () => {
const json = JSON.parse(String(reader.result))
const norm = json.map((r:any)=> ({
einzelplan_nr: String(r.einzelplan_nr||'').padStart(2,'0'),
einzelplan_name: r.einzelplan_name||'',
kapitel_nr: r.kapitel_nr||'',
kapitel_name: r.kapitel_name||'',
titel_nr: r.titel_nr||'',
bezeichnung: r.bezeichnung||'',
seite: r.seite ?? null,
betrag_eur: Number(r.betrag_eur)||0,
category: r.category,
tags: r.tags||[]
}))
setData(norm)
}
reader.readAsText(f)
}
return (
<section className="container py-10 space-y-6">
{/* Header + upload */}
<div className="grid md:grid-cols-2 gap-6 items-end">
<div>
<h1 className="text-3xl font-bold">Explorer</h1>
<p className="text-white/70 text-sm">Lade die offizielle JSON und filtere Ausgaben.</p>
</div>
<div className="flex gap-3 justify-end">
<label className="inline-flex items-center gap-3 cursor-pointer rounded-xl bg-black text-deutschland-gold px-4 py-3 ring-1 ring-zinc-800 hover:bg-zinc-900">
<span>Choose File</span>
<input type="file" accept="application/json" onChange={onFile} className="hidden" />
</label>
<span className="self-center text-sm text-yellow-300 truncate max-w-[320px]">{filename || 'HH2026_titel_eur.json'}</span>
</div>
</div>
{/* Filters row (all on one line on md+) */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
<div className="md:col-span-4">
<label className="block text-xs text-white/70 mb-1">Einzelplan</label>
<Select.Root value={filters.ep} onValueChange={(v)=>filters.set({ep: v as any})}>
<Select.Trigger
className="w-full rounded-lg bg-black text-deutschland-gold px-3 py-3 text-base shadow-sm
ring-1 ring-zinc-800 data-[state=open]:ring-deutschland-red focus:outline-none focus:ring-2 focus:ring-deutschland-red"
>
<Select.Value placeholder="Einzelplan auswählen" />
</Select.Trigger>
<Select.Portal>
<Select.Content className="overflow-hidden rounded-lg bg-black text-deutschland-gold shadow-xl ring-1 ring-zinc-800">
<Select.Viewport className="p-1">
<Select.Item value="ALL" className="px-3 py-2 rounded hover:bg-zinc-900 focus:bg-zinc-900 outline-none">
<Select.ItemText>Alle</Select.ItemText>
</Select.Item>
{eps.map(([nr, name]) => (
<Select.Item key={nr} value={nr} className="px-3 py-2 rounded hover:bg-zinc-900 focus:bg-zinc-900 outline-none">
<Select.ItemText>{nr} {name}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div className="md:col-span-4">
<label className="block text-xs text-white/70 mb-1">Suche</label>
<input className={ctl} value={filters.query} onChange={e=> filters.set({query: e.target.value})} placeholder="z. B. Rente, Klimaschutz, Polizei…" />
</div>
<div className="md:col-span-2">
<label className="block text-xs text-white/70 mb-1">Min (Mio )</label>
<input className={ctl} value={min} onChange={e=> setMin(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className="block text-xs text-white/70 mb-1">Max (Mio )</label>
<input className={ctl} value={max} onChange={e=> setMax(e.target.value)} />
</div>
</div>
{/* Chart */}
<div className="card p-4">
<div className="font-semibold mb-2">Top 10 nach Betrag (Mio )</div>
<div className="h-96"> {/* Changed from h-72 to h-80 */}
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top} margin={{ top: 10, right: 20, bottom: 50, left: 20 }}>
<CartesianGrid stroke="#3f3f46" />
<XAxis dataKey="name" interval={0} angle={-45} height={80} tick={{ fill: '#FFCE00', fontSize: 12 }} />
<YAxis tick={{ fill: '#e5e7eb', fontSize: 10 }} />
<Tooltip formatter={(value: number) => `${Math.round(Number(value))} Mio €`} />
<Bar dataKey="value" fill="#4a4a4a">
<LabelList dataKey="value" position="top" fill="#FFCE00" fontSize={11} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="text-4xl font-extrabold mt-6 text-deutschland-red drop-shadow-lg">
{total.toFixed(3)} Mrd
</div>
<div className="text-sm text-deutschland-red/80 mt-3 drop-shadow-md">
Zeilen: {rows.length.toLocaleString('de-DE')}
</div>
</div>
{/* Table */}
<div className="card p-0 overflow-hidden">
{/* Vertical scroll only, generous height */}
<div className="max-h-[65vh] overflow-y-auto md:overflow-x-hidden">
<table className="w-full text-sm border-separate border-spacing-0">
{/* column widths: give description the most space */}
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '10%' }} />
<col />{/* description flex */}
<col style={{ width: '14%' }} />
</colgroup>
{/* Themed sticky header */}
<thead className="sticky top-0 z-10">
<tr className="bg-zinc-900/90 text-deutschland-red border-b border-red-900/40 backdrop-blur">
<th className="p-3 text-left font-semibold">EP</th>
<th className="p-3 text-left font-semibold">Kapitel</th>
<th className="p-3 text-left font-semibold">Titel-Nr</th>
<th className="p-3 text-left font-semibold">Bezeichnung</th>
<th className="p-3 text-right font-semibold">Betrag (Mio )</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 600).map((r, i) => (
<tr
key={i}
className="border-t border-white/10 hover:bg-white/5 transition-colors"
>
<td className="p-3 align-top whitespace-pre-wrap break-words">
{r.einzelplan_nr} {r.einzelplan_name}
</td>
<td className="p-3 align-top whitespace-pre-wrap break-words">
{r.kapitel_nr} {r.kapitel_name}
</td>
<td className="p-3 align-top whitespace-nowrap">{r.titel_nr}</td>
{/* Description + tags */}
<td className="p-3 align-top">
<div className="whitespace-pre-wrap break-words">
{r.bezeichnung}
</div>
{/* Tags as badges */}
{Array.isArray(r.tags) && r.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{r.tags.map((t: string, idx: number) => (
<span
key={idx}
className="px-2 py-0.5 rounded-full text-[11px] leading-5
bg-deutschland-gold/12 text-yellow-300
border border-deutschland-gold/25"
title={`Tag: ${t}`}
>
#{t}
</span>
))}
</div>
)}
</td>
<td className="p-3 text-right font-mono tabular-nums align-top">
{(r.betrag_eur / 1e6).toLocaleString('de-DE', {
minimumFractionDigits: 3,
maximumFractionDigits: 3
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { Link } from 'react-router-dom'
export default function LandingPage(){
return (
<section className="container py-14">
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight">Transparenter Bundeshaushalt.</h1>
<p className="mt-6 max-w-2xl text-lg text-white/80">
Finde Posten, filtere Milliarden, simuliere Reformen. <b>Faktenbasiert</b>,
nachvollziehbar, bürgernah. Datenquelle: amtliche Regierungs-XML/JSON.
</p>
<div className="mt-8 flex gap-3">
<Link to="/explorer" className="rounded-xl px-4 py-3 font-semibold bg-deutschland-gold text-black hover:bg-yellow-300 focus:outline-none focus:ring-2 focus:ring-deutschland-red">Zum Explorer</Link>
<Link to="/reform" className="rounded-xl px-4 py-3 border border-zinc-700 text-deutschland-gold hover:bg-zinc-900 focus:outline-none focus:ring-2 focus:ring-deutschland-red">Reformen bauen</Link>
</div>
<div className="mt-8 grid md:grid-cols-3 gap-4 max-w-4xl">
<div className="rounded-xl bg-white/5 border border-white/10 p-4">
<div className="font-semibold">1) Daten laden</div>
<div className="text-sm text-white/70 mt-1">JSON hochladen (oder XML im Converter umwandeln).</div>
</div>
<div className="rounded-xl bg-white/5 border border-white/10 p-4">
<div className="font-semibold">2) Explorer nutzen</div>
<div className="text-sm text-white/70 mt-1">Suchen, filtern, Top-10 und Details checken.</div>
</div>
<div className="rounded-xl bg-white/5 border border-white/10 p-4">
<div className="font-semibold">3) Reformen bauen</div>
<div className="text-sm text-white/70 mt-1">Schalter umlegen, Ersparnis & Saldo sehen.</div>
</div>
</div>
<div className="mt-10">
<span className="inline-flex items-center gap-2 rounded-full bg-deutschland-gold/15 text-yellow-300 border border-deutschland-gold/30 px-3 py-1 text-sm">
Open Data Bürgernah Revisionssicher
</span>
</div>
</section>
)
}

View File

@@ -0,0 +1,98 @@
import React, { useMemo, useState } from 'react'
import { reformsData } from '@/features/data/reforms'
import { useReforms } from '@/store/reforms.slice'
export default function ReformDesignerPage(){
const { toggles, setToggle, clearAll, totals } = useReforms()
const [q, setQ] = useState('')
const filtered = useMemo(() => {
const s = q.trim().toLowerCase()
if (!s) return reformsData
return reformsData.filter(r =>
(r.label + ' ' + r.description + ' ' + r.notes).toLowerCase().includes(s)
)
}, [q])
const t = totals() // {expSavingsMrd, revenueDeltaMrd, totalDeltaMrd}
return (
<section className="container py-10 space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Reform-Designer</h1>
<p className="text-white/70 text-sm">Schalte Reformen und sieh den Gesamteffekt. Auswahl wird gespeichert.</p>
</div>
<input
value={q}
onChange={e=> setQ(e.target.value)}
placeholder="Suchen (z. B. NGO, Subvention, Klima…)"
className="w-full max-w-xs rounded-md bg-white text-gray-900 px-3 py-2 text-sm shadow-sm
ring-1 ring-gray-300 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-3">
{filtered.map(r=> (
<label key={r.id} className="flex gap-3 p-3 rounded-xl border border-white/10 bg-white/5">
<input
type="checkbox"
checked={!!toggles[r.id]}
onChange={e => setToggle(r.id, e.target.checked)}
className="mt-1"
/>
<div className="min-w-0">
<div className="font-medium">{r.label}</div>
<div className="text-xs text-white/70 mt-0.5">
{r.impactMrd>0?'+':''}{r.impactMrd.toFixed(1)} Mrd {r.type==='rev' ? 'Einnahmen' : 'Ersparnis'}
</div>
{r.description && <div className="text-xs text-white/70 mt-2">{r.description}</div>}
<div className="flex flex-wrap gap-1 mt-2">
{r.epHints?.map(ep => (
<span key={ep} className="px-2 py-0.5 rounded-full text-xs bg-white/10 border border-white/10">
EP {ep}
</span>
))}
{r.appliesToTags?.slice(0,4).map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full text-xs bg-emerald-500/10 border border-emerald-500/20">
#{tag}
</span>
))}
</div>
</div>
</label>
))}
</div>
<div className="card p-6">
<div className="text-3xl font-semibold">Saldo</div>
<div className={`text-5xl font-bold mt-2 ${t.totalDeltaMrd>=0?'text-emerald-400':'text-red-400'}`}>
{t.totalDeltaMrd.toFixed(1)} Mrd
</div>
<div className="grid grid-cols-2 gap-4 mt-6">
<div className="bg-emerald-500/10 rounded-xl p-4">
<div className="text-sm text-emerald-200">Einsparungen (Ausgaben )</div>
<div className="text-3xl font-bold">{t.expSavingsMrd.toFixed(1)} Mrd </div>
</div>
<div className="bg-red-500/10 rounded-xl p-4">
<div className="text-sm text-red-200">Einnahmen-Delta</div>
<div className="text-3xl font-bold">{t.revenueDeltaMrd.toFixed(1)} Mrd </div>
</div>
</div>
<div className="mt-6 space-y-2 text-sm text-white/70">
<div>Aktiviert: {Object.values(toggles).filter(Boolean).length} / {reformsData.length}</div>
<button
onClick={clearAll}
className="px-3 py-2 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10"
>
Alle deaktivieren
</button>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,41 @@
/** Drop-in for the right-side sticky saldo panel inside ReformDesignerPage */
<div className="md:sticky md:top-20 card p-6 bg-black/60 border border-white/10">
<div className="text-xl font-semibold">Saldo</div>
<div className={`text-5xl font-extrabold mt-2 ${t.totalDeltaMrd>=0?'text-deutschland-gold':'text-deutschland-red'}`}>
{t.totalDeltaMrd>=0?'+':''}{t.totalDeltaMrd.toFixed(1)} Mrd
</div>
<div className="grid grid-cols-2 gap-3 mt-6">
<div className="rounded-lg bg-deutschland-gold/10 border border-deutschland-gold/30 p-3">
<div className="text-xs text-yellow-300/80">Einsparungen</div>
<div className="text-2xl font-bold text-yellow-300">{t.expSavingsMrd.toFixed(1)} Mrd </div>
</div>
<div className="rounded-lg bg-deutschland-red/10 border border-deutschland-red/30 p-3">
<div className="text-xs text-red-300/80">Einnahmen-Delta</div>
<div className="text-2xl font-bold text-red-300">{t.revenueDeltaMrd.toFixed(1)} Mrd </div>
</div>
</div>
<div className="mt-6">
<div className="text-sm text-white/70 mb-2">Aktive Reformen</div>
<ul className="space-y-1 max-h-48 overflow-auto pr-2">
{reformsData.filter(r=> toggles[r.id]).map(r=> (
<li key={r.id} className="text-sm flex justify-between gap-3">
<span className="truncate">{r.label}</span>
<span className={r.impactMrd>=0?'text-yellow-300':'text-red-300'}>
{r.impactMrd>=0?'+':''}{r.impactMrd.toFixed(1)} Mrd
</span>
</li>
))}
{reformsData.filter(r=> toggles[r.id]).length===0 && (
<li className="text-sm text-white/50">Noch keine Reform ausgewählt.</li>
)}
</ul>
</div>
<div className="mt-6">
<button onClick={clearAll} className="px-3 py-2 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10">
Alle deaktivieren
</button>
</div>
</div>

View File

@@ -0,0 +1,15 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { router } from './app/routes'
import { AppProviders } from './app/providers'
import { ErrorBoundary } from './app/ErrorBoundary';
import './styles.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AppProviders>
<RouterProvider router={router} />
</AppProviders>
</React.StrictMode>
)

Binary file not shown.

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand'
export type TitleRow = {
einzelplan_nr: string
einzelplan_name: string
kapitel_nr: string
kapitel_name: string
titel_nr: string
bezeichnung: string
seite?: number | null
betrag_eur: number
category?: string
tags?: string[]
}
type DatasetState = {
titles: TitleRow[]
loadFromJson: (data: TitleRow[]) => void
}
export { useReforms } from './reforms.slice'
export const useDataset = create<DatasetState>()((set)=> ({
titles: [],
loadFromJson: (data)=> set({ titles: data })
}))
type FilterState = {
ep: string | 'ALL'
query: string
set: (p: Partial<FilterState>) => void
}
export const useFilters = create<FilterState>()((set)=> ({
ep: 'ALL',
query: '',
set
}))
type ScenarioState = { basket: Record<string, boolean>; toggle: (id:string)=>void; clear: ()=>void }
export const useScenario = create<ScenarioState>()((set,get)=> ({
basket: {},
toggle: (id)=> set({ basket: { ...get().basket, [id]: !get().basket[id] }}),
clear: ()=> set({ basket: {} })
}))

View File

@@ -0,0 +1,51 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { reformsData } from '@/features/data/reforms'
type ReformToggles = Record<string, boolean>
type ReformTotals = {
expSavingsMrd: number // + means spending goes down
revenueDeltaMrd: number // negative means less revenue (e.g. tax cut)
totalDeltaMrd: number // sum of both (budget balance effect)
}
type ReformsState = {
toggles: ReformToggles
setToggle: (id: string, on: boolean) => void
clearAll: () => void
setAll: (on: boolean) => void
totals: () => ReformTotals
}
export const useReforms = create<ReformsState>()(
persist(
(set, get) => ({
toggles: {},
setToggle: (id, on) => set(s => ({ toggles: { ...s.toggles, [id]: on } })),
clearAll: () => set({ toggles: {} }),
setAll: (on) => {
const next: ReformToggles = {}
for (const r of reformsData) next[r.id] = on
set({ toggles: next })
},
totals: () => {
// Split exp vs rev so we can show both on Compare
const { toggles } = get()
let expSavings = 0
let revenueDelta = 0
for (const r of reformsData) {
if (!toggles[r.id]) continue
if (r.type === 'exp') expSavings += r.impactMrd
else if (r.type === 'rev') revenueDelta += r.impactMrd // e.g. -38 for tax cut
}
return {
expSavingsMrd: expSavings,
revenueDeltaMrd: revenueDelta,
totalDeltaMrd: expSavings + revenueDelta,
}
},
}),
{ name: 'reforms-v1' }
)
)

View File

@@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.table-num { font-variant-numeric: tabular-nums; font-family: JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, monospace; }
.container { max-width: 1200px; margin-inline: auto; padding-inline: 1rem; }
.card { @apply bg-white/95 text-gray-900 rounded-2xl shadow-xl; }
:root{
--de-black:#000000; --de-red:#DD0000; --de-gold:#FFCE00;
}

View File

@@ -0,0 +1,4 @@
export const formatEUR = (v:number) => new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(v)
export const mio = (v:number)=> v/1e6
export const mrd = (v:number)=> v/1e9

Binary file not shown.

View File

@@ -0,0 +1,23 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
deutschland: {
black: '#000000',
red: '#DD0000',
gold: '#FFCE00',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace']
}
},
},
plugins: [
// other plugins here, but not @tailwindcss/line-clamp
]
} satisfies Config

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: { port: 5173 }
})