feat: Mantine design system with dark mode

This commit is contained in:
Jonas Miederer 2026-09-12 08:39:00 +02:00
parent 6e12720ad6
commit 38d4899b07
18 changed files with 1170 additions and 322 deletions

View file

@ -103,6 +103,17 @@ auth` + the LFX/stack repair commit:
`npm run build` green, routes + `/api` proxy return 200, served schema `npm run build` green, routes + `/api` proxy return 200, served schema
validates under JSON-Schema Draft 7. validates under JSON-Schema Draft 7.
## 0.3 Frontend design pass (Mantine)
Plain-HTML UI replaced with a coherent Mantine v9 design system:
`@mantine/core` + `@mantine/hooks` + `@rjsf/mantine` (v6, matches core)
+ `@tabler/icons-react`. `src/theme.ts` (kirchenblau primary, system
fonts only, serif display headings), `AppShell` header/nav, per-page
`Card`/`Table`/`Alert`/`Badge` via shared `StatusBadge`, dark mode via
localStorage manager + toggle + inline `ColorSchemeScript` equivalent.
RJSF swap was import-only; SSR smoke test proved all 10 intake labels +
textarea/password/checkbox/slider widgets render. `npm run build` green.
## 1. Locked decisions (do not re-ask)* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, ## 1. Locked decisions (do not re-ask)* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,
store `sha256`, `used_at` set before session creation), session = opaque id store `sha256`, `used_at` set before session creation), session = opaque id
in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`. in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`.

View file

@ -3,7 +3,26 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1d4a79" />
<meta
name="description"
content="Kirche Einfach — KI-gestützte Verwaltungsplattform für Kirche"
/>
<title>Kirche Einfach</title> <title>Kirche Einfach</title>
<script>
try {
var s = localStorage.getItem("kirche-color-scheme");
if (
s === "dark" ||
(!s && matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.setAttribute(
"data-mantine-color-scheme",
"dark",
);
}
} catch (e) {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -8,8 +8,12 @@
"name": "kirche-einfach-frontend", "name": "kirche-einfach-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@rjsf/core": "^6.10.0", "@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0", "@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.18.3" "react-router-dom": "^7.18.3"
@ -746,6 +750,59 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.20",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.9",
"@floating-ui/utils": "^0.2.12",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -796,6 +853,50 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@mantine/core": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.1.tgz",
"integrity": "sha512-UkcPo8Zq0uSdodvyXV62HVmfIfHCcrjFrFrH0TggTy6Wtp8a78znGP8RJOz8zC9XSPpJmKjBQjg0M+nOvIzftA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.20",
"clsx": "^2.1.1",
"react-number-format": "^5.4.5",
"react-remove-scroll": "^2.7.2",
"type-fest": "^5.8.0"
},
"peerDependencies": {
"@mantine/hooks": "9.6.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/dates": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.1.tgz",
"integrity": "sha512-3NxfqrcwMuYB9wvxIhGhEhhazK3wcA2wG4AuNVFGJDd97ujWWAy+jGHqBF9jOusp/Mk4bn8u+3SuDICtLPDf5w==",
"license": "MIT",
"peer": true,
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"@mantine/core": "9.6.1",
"@mantine/hooks": "9.6.1",
"dayjs": ">=1.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/hooks": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
"integrity": "sha512-pfxM95WozngB0ZALw6h99QV1TQ0qniOkFhzi0+dmSnKpxkVSR0xeNsvPpGDj9Teu+M69fRoE4IkzyMnu5Xld3w==",
"license": "MIT",
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -829,6 +930,26 @@
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/@rjsf/mantine": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/mantine/-/mantine-6.10.0.tgz",
"integrity": "sha512-tqD2v7rCEaI5Dme66MiaIMem/zTuKJBn9+B1d5J8cVCOh77FmEyn1neVvB6rPK+jXNVY6jJD8DRW8vAzK6OPRQ==",
"license": "Apache-2.0",
"dependencies": {
"dayjs": "^1.11.21"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@mantine/core": ">=8",
"@mantine/dates": ">=8",
"@mantine/hooks": ">=8",
"@rjsf/core": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": { "node_modules/@rjsf/utils": {
"version": "6.10.0", "version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz", "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
@ -1222,6 +1343,32 @@
"win32" "win32"
] ]
}, },
"node_modules/@tabler/icons": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
"integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
"integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.46.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1285,7 +1432,7 @@
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1449,6 +1596,15 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1473,7 +1629,13 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -1494,6 +1656,12 @@
} }
} }
}, },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.420", "version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
@ -1629,6 +1797,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -1829,6 +2006,16 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/react-number-format": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
"integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==",
"license": "MIT",
"peerDependencies": {
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -1839,6 +2026,53 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.3", "version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
@ -1877,6 +2111,28 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -1964,6 +2220,24 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -1981,6 +2255,27 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
"integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@ -2026,6 +2321,49 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",

View file

@ -9,8 +9,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@rjsf/core": "^6.10.0", "@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0", "@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.18.3" "react-router-dom": "^7.18.3"

View file

@ -1,5 +1,17 @@
import { Link, Route, Routes, useNavigate } from "react-router-dom"; import { Link, Route, Routes, useNavigate } from "react-router-dom";
import {
Anchor,
AppShell,
Button,
Center,
Container,
Group,
Loader,
Text,
Title,
} from "@mantine/core";
import { AuthProvider, useAuth } from "./auth"; import { AuthProvider, useAuth } from "./auth";
import SchemeToggle from "./components/SchemeToggle";
import StartPage from "./pages/StartPage"; import StartPage from "./pages/StartPage";
import AccessPage from "./pages/AccessPage"; import AccessPage from "./pages/AccessPage";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
@ -7,15 +19,8 @@ import WorkflowsPage from "./pages/WorkflowsPage";
import TasksPage from "./pages/TasksPage"; import TasksPage from "./pages/TasksPage";
import AdminPage from "./pages/AdminPage"; import AdminPage from "./pages/AdminPage";
const navStyle: React.CSSProperties = { function Nav() {
display: "flex", const { me, logout } = useAuth();
gap: 12,
marginBottom: 16,
alignItems: "center",
};
function Shell() {
const { me, loading, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
async function onLogout() { async function onLogout() {
@ -24,36 +29,89 @@ function Shell() {
} }
return ( return (
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}> <Group gap="md">
<h1>Kirche Einfach</h1> <Anchor component={Link} to="/">
<p>KI-gestützte Verwaltungsplattform für Kirche</p> Start
<nav style={navStyle}> </Anchor>
<Link to="/">Start</Link> {!me && (
{!me && <Link to="/access">Zugang</Link>} <Anchor component={Link} to="/access">
{me && ( Zugang
<> </Anchor>
<Link to="/app/workflows">Workflows</Link>
<Link to="/app/tasks">Aufgaben</Link>
<Link to="/app/admin">Verwaltung</Link>
<span style={{ marginLeft: "auto" }}>{me.email}</span>
<button onClick={() => void onLogout()}>Abmelden</button>
</>
)}
</nav>
{loading ? (
<p>Wird geladen …</p>
) : (
<Routes>
<Route path="/" element={<StartPage />} />
<Route path="/access" element={<AccessPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/app/workflows" element={<WorkflowsPage />} />
<Route path="/app/tasks" element={<TasksPage />} />
<Route path="/app/admin" element={<AdminPage />} />
<Route path="*" element={<StartPage />} />
</Routes>
)} )}
</div> {me && (
<>
<Anchor component={Link} to="/app/workflows">
Workflows
</Anchor>
<Anchor component={Link} to="/app/tasks">
Aufgaben
</Anchor>
<Anchor component={Link} to="/app/admin">
Verwaltung
</Anchor>
</>
)}
<SchemeToggle />
{me && (
<>
<Text size="sm" c="dimmed">
{me.email}
</Text>
<Button variant="subtle" size="compact-md" onClick={() => void onLogout()}>
Abmelden
</Button>
</>
)}
</Group>
);
}
function Shell() {
const { loading } = useAuth();
return (
<AppShell header={{ height: 64 }} padding="md">
<AppShell.Header>
<Container
h="100%"
style={{ display: "flex", alignItems: "center", gap: 16 }}
>
<Anchor
component={Link}
to="/"
underline="never"
style={{ color: "inherit" }}
>
<Title order={3}>Kirche Einfach</Title>
</Anchor>
<div style={{ marginLeft: "auto" }}>
<Nav />
</div>
</Container>
</AppShell.Header>
<AppShell.Main>
<Container>
<Text size="sm" c="dimmed" mb="md">
KI-gestützte Verwaltungsplattform für Kirche
</Text>
{loading ? (
<Center mt="xl">
<Loader />
</Center>
) : (
<Routes>
<Route path="/" element={<StartPage />} />
<Route path="/access" element={<AccessPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/app/workflows" element={<WorkflowsPage />} />
<Route path="/app/tasks" element={<TasksPage />} />
<Route path="/app/admin" element={<AdminPage />} />
<Route path="*" element={<StartPage />} />
</Routes>
)}
</Container>
</AppShell.Main>
</AppShell>
); );
} }

View file

@ -1,5 +1,19 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
} from "@tabler/icons-react";
import { ApiError, api, type AccessRequest } from "../api"; import { ApiError, api, type AccessRequest } from "../api";
import StatusBadge from "./StatusBadge";
export default function AccessQueue() { export default function AccessQueue() {
const [requests, setRequests] = useState<AccessRequest[]>([]); const [requests, setRequests] = useState<AccessRequest[]>([]);
@ -43,46 +57,72 @@ export default function AccessQueue() {
return ( return (
<div> <div>
<h3>Zugangsanfragen</h3> <Title order={3} mb="xs">
{error && <p style={{ color: "crimson" }}>{error}</p>} Zugangsanfragen
{info && <p style={{ color: "green" }}>{info}</p>} </Title>
{pending.length === 0 && <p>Keine offenen Anfragen.</p>} {error && (
<ul> <Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
{info && (
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
{info}
</Alert>
)}
{pending.length === 0 && (
<Text c="dimmed" mb="md">
Keine offenen Anfragen.
</Text>
)}
<Stack mb="md">
{pending.map((r) => ( {pending.map((r) => (
<li key={r.id} style={{ marginBottom: 8 }}> <Card withBorder padding="md" key={r.id}>
<strong>{r.name}</strong> · {r.email} <Group gap="xs" mb="xs">
<StatusBadge status={r.status} />
<strong>{r.name}</strong>
<Text size="sm" c="dimmed">
{r.email}
</Text>
</Group>
{r.reason && ( {r.reason && (
<> <Text size="sm" mb="sm">
<br />
{r.reason} {r.reason}
</> </Text>
)} )}
<br /> <Group>
<button <Button
onClick={() => void decide(r.id, true)} color="green"
disabled={busyId === r.id} loading={busyId === r.id}
> onClick={() => void decide(r.id, true)}
Genehmigen >
</button>{" "} Genehmigen
<button </Button>
onClick={() => void decide(r.id, false)} <Button
disabled={busyId === r.id} color="red"
> variant="light"
Ablehnen loading={busyId === r.id}
</button> onClick={() => void decide(r.id, false)}
</li> >
Ablehnen
</Button>
</Group>
</Card>
))} ))}
</ul> </Stack>
{done.length > 0 && ( {done.length > 0 && (
<> <>
<h4>Entschieden ({done.length})</h4> <Title order={4} mb="xs">
<ul> Entschieden ({done.length})
{done.map((r) => ( </Title>
<li key={r.id}> {done.map((r) => (
<strong>{r.status}</strong> · {r.name} · {r.email} <Group key={r.id} gap="xs" mb={4}>
</li> <StatusBadge status={r.status} />
))} <Text size="sm">
</ul> {r.name} · {r.email}
</Text>
</Group>
))}
</> </>
)} )}
</div> </div>

View file

@ -1,4 +1,22 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import {
Alert,
Anchor,
Button,
Card,
Group,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
IconExternalLink,
IconRefresh,
IconUpload,
} from "@tabler/icons-react";
import { import {
ApiError, ApiError,
LANGFLOW_URL, LANGFLOW_URL,
@ -6,6 +24,7 @@ import {
type FlowDefinition, type FlowDefinition,
type FlowVersion, type FlowVersion,
} from "../api"; } from "../api";
import StatusBadge from "./StatusBadge";
export default function AdminTab() { export default function AdminTab() {
const [flows, setFlows] = useState<FlowDefinition[]>([]); const [flows, setFlows] = useState<FlowDefinition[]>([]);
@ -86,61 +105,110 @@ export default function AdminTab() {
return ( return (
<div> <div>
<h2>Administration</h2> <Title order={3} mb="xs">
{error && <p style={{ color: "crimson" }}>{error}</p>} Flows & Versionen
{info && <p style={{ color: "green" }}>{info}</p>} </Title>
<p> {error && (
Flows werden visuell in Langflow entworfen:{" "} <Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
<a href={LANGFLOW_URL} target="_blank" rel="noreferrer"> {error}
Langflow öffnen </Alert>
</a> )}
</p> {info && (
<h3>Import aus Langflow</h3> <Alert color="green" icon={<IconCheck size={18} />} mb="md">
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}> {info}
<input </Alert>
value={langflowId} )}
onChange={(e) => setLangflowId(e.target.value)} <Card withBorder padding="md" mb="md">
placeholder="Langflow Flow-ID (UUID)" <Text mb="xs">
style={{ flex: 1, fontFamily: "monospace" }} Flows werden visuell in Langflow entworfen:{" "}
/> <Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
<input Langflow öffnen <IconExternalLink size={14} />
value={importName} </Anchor>
onChange={(e) => setImportName(e.target.value)} </Text>
placeholder="Name (optional)" <Group align="flex-end">
/> <TextInput
<button onClick={() => void onImport()} disabled={!langflowId.trim()}> label="Langflow Flow-ID (UUID)"
Importieren + Deployen value={langflowId}
</button> onChange={(e) => setLangflowId(e.target.value)}
</div> style={{ flex: 1 }}
<h3>Flows + Versionen</h3> styles={{ input: { fontFamily: "monospace" } }}
<div style={{ marginBottom: 12 }}> />
<button onClick={() => void onSync()}> <TextInput
Aus backend/flows synchronisieren label="Name (optional)"
</button> value={importName}
</div> onChange={(e) => setImportName(e.target.value)}
{flows.length === 0 && <p>Keine Flows vorhanden.</p>} />
<ul> <Button
{flows.map((f) => ( leftSection={<IconUpload size={16} />}
<li key={f.id} style={{ marginBottom: 8 }}> onClick={() => void onImport()}
<strong>{f.name}</strong>{" "} disabled={!langflowId.trim()}
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}> >
{f.source_path ? `(${f.source_path})` : "(API)"} Importieren + Deployen
</span> </Button>
<ul> </Group>
{(versions[f.id] ?? []).map((v) => ( </Card>
<li key={v.id}> <Card withBorder padding="md" mb="md">
v{v.version_no} ·{" "} <Group>
{v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "} <Button
{v.author || "–"}{" "} variant="light"
<button onClick={() => void onDeploy(f.id, v.version_no)}> leftSection={<IconRefresh size={16} />}
Deployen onClick={() => void onSync()}
</button> >
</li> Aus backend/flows synchronisieren
))} </Button>
</ul> </Group>
</li> </Card>
))} {flows.length === 0 ? (
</ul> <Text c="dimmed">Keine Flows vorhanden.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Flow</Table.Th>
<Table.Th>Version</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Autor</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{flows.map((f) =>
(versions[f.id] ?? []).map((v, i) => (
<Table.Tr key={v.id}>
<Table.Td>
{i === 0 && (
<>
<strong>{f.name}</strong>{" "}
<Text size="xs" c="dimmed" component="span">
{f.source_path ? `(${f.source_path})` : "(API)"}
</Text>
</>
)}
</Table.Td>
<Table.Td>v{v.version_no}</Table.Td>
<Table.Td>
{v.deployed_at ? (
<StatusBadge status="deployed" />
) : (
<StatusBadge status="pending" />
)}
</Table.Td>
<Table.Td>{v.author || "–"}</Table.Td>
<Table.Td>
<Button
size="compact-sm"
variant="light"
onClick={() => void onDeploy(f.id, v.version_no)}
>
Deployen
</Button>
</Table.Td>
</Table.Tr>
)),
)}
</Table.Tbody>
</Table>
)}
</div> </div>
); );
} }

View file

@ -1,5 +1,18 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Code,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react";
import { ApiError, api, type ApprovalTask } from "../api"; import { ApiError, api, type ApprovalTask } from "../api";
import StatusBadge from "./StatusBadge";
export default function ApprovalsTab() { export default function ApprovalsTab() {
const [tasks, setTasks] = useState<ApprovalTask[]>([]); const [tasks, setTasks] = useState<ApprovalTask[]>([]);
@ -40,56 +53,89 @@ export default function ApprovalsTab() {
return ( return (
<div> <div>
<h2>Freigaben</h2> <Title order={3} mb="xs">
{error && <p style={{ color: "crimson" }}>{error}</p>} Freigaben
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}> </Title>
<input {error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
<Group mb="md" align="flex-end">
<TextInput
label="Name (Bearbeiter)"
value={approver} value={approver}
onChange={(e) => setApprover(e.target.value)} onChange={(e) => setApprover(e.target.value)}
placeholder="Name (Bearbeiter)"
/> />
<input <TextInput
label="Notiz"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="Notiz"
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<button onClick={() => void load()}>Aktualisieren</button> <Button
</div> variant="light"
<h3>Offen ({open.length})</h3> leftSection={<IconRefresh size={16} />}
{open.length === 0 && <p>Keine offenen Freigaben.</p>} onClick={() => void load()}
<ul> >
Aktualisieren
</Button>
</Group>
<Title order={4} mb="xs">
Offen ({open.length})
</Title>
{open.length === 0 && (
<Text c="dimmed" mb="md">
Keine offenen Freigaben.
</Text>
)}
<Stack mb="md">
{open.map((t) => ( {open.map((t) => (
<li key={t.id} style={{ marginBottom: 8 }}> <Card withBorder padding="md" key={t.id}>
<strong>{t.title || t.id}</strong> · {t.tenant_id} <Group gap="xs" mb="xs">
<br /> <StatusBadge status={t.status} />
<code>{JSON.stringify(t.payload)}</code> <strong>{t.title || t.id}</strong>
<br /> <Text size="sm" c="dimmed">
<button {t.tenant_id}
onClick={() => void decide(t.id, "approve")} </Text>
disabled={busyId === t.id} </Group>
> <Code block mb="sm">
Freigeben {JSON.stringify(t.payload)}
</button>{" "} </Code>
<button <Group>
onClick={() => void decide(t.id, "reject")} <Button
disabled={busyId === t.id} color="green"
> leftSection={<IconCheck size={16} />}
Ablehnen loading={busyId === t.id}
</button> onClick={() => void decide(t.id, "approve")}
</li> >
Freigeben
</Button>
<Button
color="red"
variant="light"
leftSection={<IconX size={16} />}
loading={busyId === t.id}
onClick={() => void decide(t.id, "reject")}
>
Ablehnen
</Button>
</Group>
</Card>
))} ))}
</ul> </Stack>
<h3>Entschieden ({done.length})</h3> <Title order={4} mb="xs">
<ul> Entschieden ({done.length})
{done.map((t) => ( </Title>
<li key={t.id}> {done.map((t) => (
<strong>{t.status}</strong> · {t.title || t.id} ·{" "} <Group key={t.id} gap="xs" mb={4}>
{t.approver ?? "–"} <StatusBadge status={t.status} />
<Text size="sm">
{t.title || t.id} · {t.approver ?? "–"}
{t.decision_note ? ` · „${t.decision_note}“` : ""} {t.decision_note ? ` · „${t.decision_note}“` : ""}
</li> </Text>
))} </Group>
</ul> ))}
</div> </div>
); );
} }

View file

@ -0,0 +1,18 @@
import { ActionIcon, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
export default function SchemeToggle() {
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<ActionIcon
variant="subtle"
size="lg"
onClick={() => toggleColorScheme()}
aria-label={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
title={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
>
{dark ? <IconSun size={20} /> : <IconMoon size={20} />}
</ActionIcon>
);
}

View file

@ -0,0 +1,21 @@
import { Badge } from "@mantine/core";
/* Single color language for every status in the app. */
const COLORS: Record<string, string> = {
completed: "green",
approved: "green",
genehmigt: "green",
deployed: "green",
running: "blue",
open: "yellow",
pending: "yellow",
pending_approval: "yellow",
failed: "red",
rejected: "red",
abgelehnt: "red",
};
export default function StatusBadge({ status }: { status: string }) {
return <Badge color={COLORS[status] ?? "gray"}>{status}</Badge>;
}

View file

@ -1,12 +1,21 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import App from "./App"; import App from "./App";
import { colorSchemeManager, theme } from "./theme";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <MantineProvider
<App /> theme={theme}
</BrowserRouter> colorSchemeManager={colorSchemeManager}
defaultColorScheme="light"
>
<BrowserRouter>
<App />
</BrowserRouter>
</MantineProvider>
</StrictMode>, </StrictMode>,
); );

View file

@ -1,4 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import {
Alert,
Button,
Card,
Stack,
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { ApiError, api } from "../api"; import { ApiError, api } from "../api";
export default function AccessPage() { export default function AccessPage() {
@ -29,60 +40,54 @@ export default function AccessPage() {
} }
} }
if (done) { return (
return ( <Card withBorder padding="xl" maw={560}>
<div> <Title order={2} mb="md">
<h2>Zugang</h2> Zugang anfragen
<p> </Title>
{done ? (
<Alert color="green" icon={<IconCheck size={18} />} title="Angefragt">
Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail, sobald Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail, sobald
sie freigegeben wurde. sie freigegeben wurde.
</p> </Alert>
</div> ) : (
); <form onSubmit={(e) => void onSubmit(e)}>
} <Stack>
{error && (
return ( <Alert color="red" icon={<IconAlertCircle size={18} />}>
<div> {error}
<h2>Zugang anfragen</h2> </Alert>
{error && <p style={{ color: "crimson" }}>{error}</p>} )}
<form <TextInput
onSubmit={(e) => void onSubmit(e)} label="Name"
style={{ display: "grid", gap: 8, maxWidth: 480 }} value={name}
> onChange={(e) => setName(e.target.value)}
<label> required
Name maxLength={200}
<input />
value={name} <TextInput
onChange={(e) => setName(e.target.value)} label="E-Mail"
required type="email"
maxLength={200} value={email}
style={{ width: "100%" }} onChange={(e) => setEmail(e.target.value)}
/> required
</label> maxLength={320}
<label> />
E-Mail <Textarea
<input label="Wofür brauchen Sie Zugang?"
type="email" value={reason}
value={email} onChange={(e) => setReason(e.target.value)}
onChange={(e) => setEmail(e.target.value)} rows={4}
required />
maxLength={320} <Button type="submit" loading={busy}>
style={{ width: "100%" }} Anfragen
/> </Button>
</label> <Text size="xs" c="dimmed">
<label> Ihre Angaben werden ausschließlich zur Zugangsfreigabe verwendet.
Wofür brauchen Sie Zugang? </Text>
<textarea </Stack>
value={reason} </form>
onChange={(e) => setReason(e.target.value)} )}
rows={4} </Card>
style={{ width: "100%" }}
/>
</label>
<button type="submit" disabled={busy}>
{busy ? "Wird gesendet …" : "Anfragen"}
</button>
</form>
</div>
); );
} }

View file

@ -1,3 +1,5 @@
import { Alert, Stack, Title } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import AdminTab from "../components/AdminTab"; import AdminTab from "../components/AdminTab";
import ApprovalsTab from "../components/ApprovalsTab"; import ApprovalsTab from "../components/ApprovalsTab";
import AccessQueue from "../components/AccessQueue"; import AccessQueue from "../components/AccessQueue";
@ -8,25 +10,37 @@ export default function AdminPage() {
if (!me) { if (!me) {
return ( return (
<div> <div>
<h2>Verwaltung</h2> <Title order={2} mb="md">
<p>Bitte melden Sie sich an, um die Verwaltung zu nutzen.</p> Verwaltung
</Title>
<Alert color="blue">
Bitte melden Sie sich an, um die Verwaltung zu nutzen.
</Alert>
</div> </div>
); );
} }
if (me.role !== "admin") { if (me.role !== "admin") {
return ( return (
<div> <div>
<h2>Verwaltung</h2> <Title order={2} mb="md">
<p>Nur für Administratoren. Ihr Konto hat die Rolle „{me.role}“.</p> Verwaltung
</Title>
<Alert color="red" icon={<IconAlertCircle size={18} />}>
Nur für Administratoren. Ihr Konto hat die Rolle „{me.role}“.
</Alert>
</div> </div>
); );
} }
return ( return (
<div> <div>
<h2>Verwaltung</h2> <Title order={2} mb="md">
<AccessQueue /> Verwaltung
<ApprovalsTab /> </Title>
<AdminTab /> <Stack gap="xl">
<AccessQueue />
<ApprovalsTab />
<AdminTab />
</Stack>
</div> </div>
); );
} }

View file

@ -1,5 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, Navigate, useSearchParams } from "react-router-dom"; import { Link, Navigate, useSearchParams } from "react-router-dom";
import { Alert, Anchor, Card, Center, Loader, Title } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { ApiError, api } from "../api"; import { ApiError, api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
@ -29,16 +31,30 @@ export default function LoginPage() {
if (me) return <Navigate to="/app/workflows" replace />; if (me) return <Navigate to="/app/workflows" replace />;
return ( return (
<div> <Center>
<h2>Login</h2> <Card withBorder padding="xl" maw={480} w="100%">
{busy && <p>Link wird geprüft …</p>} <Title order={2} mb="md">
{error && <p style={{ color: "crimson" }}>{error}</p>} Login
{!token && !error && ( </Title>
<p> {busy && (
Kein Link vorhanden. <Link to="/access">Zugang anfragen</Link> — der <Center>
Login-Link kommt per E-Mail. <Loader />
</p> </Center>
)} )}
</div> {error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
{!token && !error && (
<Alert color="blue" title="Kein Link vorhanden">
<Anchor component={Link} to="/access">
Zugang anfragen
</Anchor>{" "}
— der Login-Link kommt per E-Mail.
</Alert>
)}
</Card>
</Center>
); );
} }

View file

@ -1,26 +1,71 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button, Card, Grid, Group, Text, Title } from "@mantine/core";
import {
IconClipboardCheck,
IconInbox,
IconKey,
IconSparkles,
} from "@tabler/icons-react";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
const actions = [
{
to: "/access",
icon: <IconKey size={28} />,
title: "Zugang anfragen",
text: "Neu hier? Fordern Sie Ihren persönlichen Zugang an.",
},
{
to: "/app/workflows",
icon: <IconSparkles size={28} />,
title: "Workflows",
text: "Vorgänge über Formulare starten und verfolgen.",
},
{
to: "/app/tasks",
icon: <IconInbox size={28} />,
title: "Aufgaben",
text: "Eigene Läufe und offene Freigaben im Blick.",
},
];
export default function StartPage() { export default function StartPage() {
const { me } = useAuth(); const { me } = useAuth();
return ( return (
<div> <div>
<h2>Willkommen</h2> <Card withBorder padding="xl" mb="lg">
<p> <Group gap="sm" mb="xs">
Kirche Einfach unterstützt die Verwaltung Ihrer Gemeinde: Vorgänge <IconClipboardCheck size={32} />
starten, Freigaben erteilen, Zugänge verwalten. <Title order={2}>Willkommen</Title>
</p> </Group>
{me ? ( <Text>
<p> Kirche Einfach unterstützt die Verwaltung Ihrer Gemeinde: Vorgänge
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}). Weiter zu{" "} starten, Freigaben erteilen, Zugänge verwalten.
<Link to="/app/workflows">Workflows</Link>. </Text>
</p> {me && (
) : ( <Text mt="sm">
<p> Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}).
<Link to="/access">Zugang anfragen</Link>, um loszulegen. Mit </Text>
Einladungslink geht es direkt zum <Link to="/login">Login</Link>. )}
</p> </Card>
)} <Grid>
{actions.map((a) => (
<Grid.Col key={a.to} span={{ base: 12, sm: 4 }}>
<Card withBorder padding="lg" h="100%">
{a.icon}
<Title order={4} mt="sm">
{a.title}
</Title>
<Text size="sm" c="dimmed" mt="xs" mb="md">
{a.text}
</Text>
<Button component={Link} to={a.to} variant="light" fullWidth>
Öffnen
</Button>
</Card>
</Grid.Col>
))}
</Grid>
</div> </div>
); );
} }

View file

@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Alert, Code, Table, Text, Title } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { ApiError, api, type Run } from "../api"; import { ApiError, api, type Run } from "../api";
import ApprovalsTab from "../components/ApprovalsTab"; import ApprovalsTab from "../components/ApprovalsTab";
import StatusBadge from "../components/StatusBadge";
export default function TasksPage() { export default function TasksPage() {
const [runs, setRuns] = useState<{ flow: string; run: Run }[]>([]); const [runs, setRuns] = useState<{ flow: string; run: Run }[]>([]);
@ -27,18 +30,45 @@ export default function TasksPage() {
return ( return (
<div> <div>
<h2>Aufgaben</h2> <Title order={2} mb="md">
{error && <p style={{ color: "crimson" }}>{error}</p>} Aufgaben
<h3>Eigene Läufe</h3> </Title>
{runs.length === 0 && <p>Noch keine Läufe.</p>} {error && (
<ul> <Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{runs.map(({ flow, run }) => ( {error}
<li key={run.id}> </Alert>
<strong>{run.status}</strong> · {flow} ·{" "} )}
<code>{run.output?.result ?? run.output?.error ?? "–"}</code> <Title order={3} mb="xs">
</li> Eigene Läufe
))} </Title>
</ul> {runs.length === 0 ? (
<Text c="dimmed" mb="md">
Noch keine Läufe.
</Text>
) : (
<Table striped highlightOnHover mb="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Flow</Table.Th>
<Table.Th>Ergebnis</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map(({ flow, run }) => (
<Table.Tr key={run.id}>
<Table.Td>
<StatusBadge status={run.status} />
</Table.Td>
<Table.Td>{flow}</Table.Td>
<Table.Td>
<Code>{run.output?.result ?? run.output?.error ?? "–"}</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<ApprovalsTab /> <ApprovalsTab />
</div> </div>
); );

View file

@ -1,9 +1,22 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Form from "@rjsf/core"; import Form from "@rjsf/mantine";
import validator from "@rjsf/validator-ajv8"; import validator from "@rjsf/validator-ajv8";
import type { RJSFSchema, UiSchema } from "@rjsf/utils"; import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import {
Alert,
Button,
Card,
Code,
Select,
Table,
Text,
Textarea,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconPlayerPlay } from "@tabler/icons-react";
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api"; import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import StatusBadge from "../components/StatusBadge";
export default function WorkflowsPage() { export default function WorkflowsPage() {
const { me } = useAuth(); const { me } = useAuth();
@ -70,68 +83,107 @@ export default function WorkflowsPage() {
return ( return (
<div> <div>
<h2>Workflows</h2> <Title order={2} mb="md">
{!me && <p>Sie sind nicht angemeldet — Läufe nutzen den Standard-Mandanten.</p>} Workflows
{error && <p style={{ color: "crimson" }}>{error}</p>} </Title>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}> {!me && (
<select value={flowId} onChange={(e) => setFlowId(e.target.value)}> <Alert color="blue" mb="md">
<option value="">— Flow wählen —</option> Sie sind nicht angemeldet — Läufe nutzen den Standard-Mandanten.
{flows.map((f) => ( </Alert>
<option key={f.id} value={f.id}> )}
{f.name} {error && (
</option> <Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
))} {error}
</select> </Alert>
</div> )}
<Select
label="Flow"
placeholder="— Flow wählen —"
value={flowId}
onChange={(v) => setFlowId(v ?? "")}
data={flows.map((f) => ({ value: f.id, label: f.name }))}
mb="md"
maw={480}
/>
{schema && schema.fallback && ( {schema && schema.fallback && (
<div> <Card withBorder padding="lg" mb="md">
<textarea <Textarea
label="Freitext-Eingabe"
rows={6} rows={6}
style={{ width: "100%", fontFamily: "monospace" }}
value={freeText} value={freeText}
onChange={(e) => setFreeText(e.target.value)} onChange={(e) => setFreeText(e.target.value)}
placeholder="Freitext-Eingabe" mb="sm"
/> />
<button <Button
leftSection={<IconPlayerPlay size={16} />}
loading={busy}
disabled={!flowId}
onClick={() => void onRun({ text: freeText })} onClick={() => void onRun({ text: freeText })}
disabled={!flowId || busy}
> >
{busy ? "Läuft …" : "Starten"} Starten
</button> </Button>
</div> </Card>
)} )}
{schema && !schema.fallback && ( {schema && !schema.fallback && (
<Form <Card withBorder padding="lg" mb="md">
schema={schema.json_schema as RJSFSchema} <Form
uiSchema={schema.ui_schema as UiSchema} schema={schema.json_schema as RJSFSchema}
validator={validator} uiSchema={schema.ui_schema as UiSchema}
formData={formData} validator={validator}
onChange={(e) => formData={formData}
setFormData((e.formData as Record<string, unknown>) ?? {}) onChange={(e) =>
} setFormData((e.formData as Record<string, unknown>) ?? {})
onSubmit={(e) => void onRun(e.formData)} }
> onSubmit={(e) => void onRun(e.formData)}
<button type="submit" disabled={busy}> >
{busy ? "Läuft …" : "Starten"} <Button
</button> type="submit"
</Form> leftSection={<IconPlayerPlay size={16} />}
loading={busy}
mt="sm"
>
Starten
</Button>
</Form>
</Card>
)} )}
{result && ( {result && (
<div> <Card withBorder padding="lg" mb="md">
<h3>Ergebnis</h3> <Title order={3} mb="xs">
<pre style={{ whiteSpace: "pre-wrap" }}>{result}</pre> Ergebnis
</div> </Title>
<Code block>{result}</Code>
</Card>
)}
<Title order={3} mb="xs">
Letzte Läufe
</Title>
{runs.length === 0 ? (
<Text c="dimmed">Noch keine Läufe.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Mandant</Table.Th>
<Table.Th>Ergebnis</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<StatusBadge status={r.status} />
</Table.Td>
<Table.Td>{r.tenant_id}</Table.Td>
<Table.Td>
<Code>{r.output?.result ?? r.output?.error ?? "–"}</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)} )}
<h3>Letzte Läufe</h3>
{runs.length === 0 && <p>Noch keine Läufe.</p>}
<ul>
{runs.map((r) => (
<li key={r.id}>
<strong>{r.status}</strong> · {r.tenant_id} ·{" "}
<code>{r.output?.result ?? r.output?.error ?? "–"}</code>
</li>
))}
</ul>
</div> </div>
); );
} }

54
frontend/src/theme.ts Normal file
View file

@ -0,0 +1,54 @@
import {
createTheme,
localStorageColorSchemeManager,
type MantineColorSchemeManager,
} from "@mantine/core";
/* Calm-traditional direction: deep kirchenblau primary, warm paper
* neutrals in light mode, deep slate in dark mode. System font stacks
* only — no external requests (privacy). Serif display for headings. */
const kirchenblau: [
string,
string,
string,
string,
string,
string,
string,
string,
string,
string,
] = [
"#eef4fb",
"#d9e6f5",
"#b3cbe9",
"#84add9",
"#548bc6",
"#356fae",
"#245a93",
"#1d4a79",
"#1a3f65",
"#162f4c",
];
export const colorSchemeManager: MantineColorSchemeManager =
localStorageColorSchemeManager({ key: "kirche-color-scheme" });
export const theme = createTheme({
primaryColor: "kirchenblau",
colors: { kirchenblau },
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
headings: {
fontFamily:
'Georgia, "Times New Roman", "Noto Serif", serif',
},
defaultRadius: "md",
focusRing: "auto",
components: {
Container: {
defaultProps: { size: 960 },
},
},
});