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:
47
openbudget-app-scaffold/.gitignore
vendored
Normal file
47
openbudget-app-scaffold/.gitignore
vendored
Normal 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
8
openbudget-app-scaffold/.idea/.gitignore
generated
vendored
Normal 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
|
||||
6
openbudget-app-scaffold/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
openbudget-app-scaffold/.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
6
openbudget-app-scaffold/.idea/misc.xml
generated
Normal 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>
|
||||
8
openbudget-app-scaffold/.idea/modules.xml
generated
Normal file
8
openbudget-app-scaffold/.idea/modules.xml
generated
Normal 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>
|
||||
9
openbudget-app-scaffold/.idea/openbudget-app-scaffold.iml
generated
Normal file
9
openbudget-app-scaffold/.idea/openbudget-app-scaffold.iml
generated
Normal 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>
|
||||
6
openbudget-app-scaffold/README.md
Normal file
6
openbudget-app-scaffold/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
# OpenBudget.DE – App Scaffold
|
||||
Quick start:
|
||||
- npm i
|
||||
- npm run dev
|
||||
Upload `HH2026_titel_eur.json` in Explorer.
|
||||
BIN
openbudget-app-scaffold/bundeshaushalt-1.zip
Normal file
BIN
openbudget-app-scaffold/bundeshaushalt-1.zip
Normal file
Binary file not shown.
BIN
openbudget-app-scaffold/bundeshaushalt.zip
Normal file
BIN
openbudget-app-scaffold/bundeshaushalt.zip
Normal file
Binary file not shown.
13
openbudget-app-scaffold/index.html
Normal file
13
openbudget-app-scaffold/index.html
Normal 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
8252
openbudget-app-scaffold/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
openbudget-app-scaffold/package.json
Normal file
45
openbudget-app-scaffold/package.json
Normal 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"
|
||||
}
|
||||
7
openbudget-app-scaffold/postcss.config.js
Normal file
7
openbudget-app-scaffold/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
17
openbudget-app-scaffold/src/app/ErrorBoundary.tsx
Normal file
17
openbudget-app-scaffold/src/app/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
3
openbudget-app-scaffold/src/app/providers.tsx
Normal file
3
openbudget-app-scaffold/src/app/providers.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
import React from 'react'
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) { return <>{children}</> }
|
||||
38
openbudget-app-scaffold/src/app/routes.tsx
Normal file
38
openbudget-app-scaffold/src/app/routes.tsx
Normal 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/> }
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
316
openbudget-app-scaffold/src/features/data/reforms.json
Normal file
316
openbudget-app-scaffold/src/features/data/reforms.json
Normal file
@@ -0,0 +1,316 @@
|
||||
[
|
||||
{
|
||||
"id": "ngos_remove",
|
||||
"label": "NGO‑Fö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.5–0.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.3–0.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": "E‑Auto‑Förderungen & Ladeinfrastruktur beenden",
|
||||
"description": "Kaufprämien/Umweltboni/Ladeinfrastruktur‑Zuschüsse streichen.",
|
||||
"type": "exp",
|
||||
"impactMrd": 3.0,
|
||||
"appliesToTags": [
|
||||
"Elektromobilität",
|
||||
"Ladeinfrastruktur",
|
||||
"Batterie"
|
||||
],
|
||||
"epHints": [
|
||||
"09",
|
||||
"16"
|
||||
],
|
||||
"notes": "Bandbreite 2–5 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 12–18 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": "Catch‑all; später per Tagging konkret."
|
||||
},
|
||||
{
|
||||
"id": "broadcast_cuts",
|
||||
"label": "Bundes‑Medienfö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 (8–10) 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 35–40 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 1–4 Mrd €/a."
|
||||
},
|
||||
{
|
||||
"id": "ukraine_aid_review",
|
||||
"label": "Ukraine‑Hilfen ü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": "GKV‑Zuschuss 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": "Catch‑all; später konkretisieren."
|
||||
}
|
||||
]
|
||||
16
openbudget-app-scaffold/src/features/data/reforms.ts
Normal file
16
openbudget-app-scaffold/src/features/data/reforms.ts
Normal 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[];
|
||||
19
openbudget-app-scaffold/src/features/data/selectors.ts
Normal file
19
openbudget-app-scaffold/src/features/data/selectors.ts
Normal 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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
15
openbudget-app-scaffold/src/main.tsx
Normal file
15
openbudget-app-scaffold/src/main.tsx
Normal 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>
|
||||
)
|
||||
BIN
openbudget-app-scaffold/src/openbudget_patch2.zip
Normal file
BIN
openbudget-app-scaffold/src/openbudget_patch2.zip
Normal file
Binary file not shown.
39
openbudget-app-scaffold/src/store/index.ts
Normal file
39
openbudget-app-scaffold/src/store/index.ts
Normal 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: {} })
|
||||
}))
|
||||
51
openbudget-app-scaffold/src/store/reforms.slice.ts
Normal file
51
openbudget-app-scaffold/src/store/reforms.slice.ts
Normal 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' }
|
||||
)
|
||||
)
|
||||
11
openbudget-app-scaffold/src/styles.css
Normal file
11
openbudget-app-scaffold/src/styles.css
Normal 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;
|
||||
}
|
||||
4
openbudget-app-scaffold/src/utils/number.ts
Normal file
4
openbudget-app-scaffold/src/utils/number.ts
Normal 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
|
||||
BIN
openbudget-app-scaffold/src_1.zip
Normal file
BIN
openbudget-app-scaffold/src_1.zip
Normal file
Binary file not shown.
BIN
openbudget-app-scaffold/src_with_de_theme_and_radix.zip
Normal file
BIN
openbudget-app-scaffold/src_with_de_theme_and_radix.zip
Normal file
Binary file not shown.
23
openbudget-app-scaffold/tailwind.config.ts
Normal file
23
openbudget-app-scaffold/tailwind.config.ts
Normal 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
|
||||
17
openbudget-app-scaffold/tsconfig.json
Normal file
17
openbudget-app-scaffold/tsconfig.json
Normal 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"]
|
||||
}
|
||||
13
openbudget-app-scaffold/vite.config.ts
Normal file
13
openbudget-app-scaffold/vite.config.ts
Normal 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 }
|
||||
})
|
||||
Reference in New Issue
Block a user