feat(client): LT Wahl admin, Teamer admin, Verantwortlichen self-registration

New screens (client/app/lib/screens/):
- wahl_admin_screen.dart — per KC: list/create Wahlen; per Wahl: add
  workshops, run the assignment (POST /wahl/:id/zuteilung/run), view the
  result table.
- teamer_admin_screen.dart — per Gemeinde: list/create local Teamer
  accounts, create group-link or per-email invites (shows the token).
- verantwortliche_register_screen.dart — enter a KC invite code
  (GET /onboarding/kc/:code), pick a Gemeinde, submit
  (POST /onboarding/verantwortliche); shown on the home screen to a
  logged-in Authentik user who has no membership yet.
- ui.dart — shared toast / ErrorText / SectionHeader / promptText.
KcDetailScreen now links to Wahl admin and each Gemeinde row opens Teamer
admin.

Backend: widen the wahl + files LT routes to AuthGuard(['authentik','team'])
for consistency with the other LT controllers. Rebrand web/index.html +
manifest from "kc_app" to "KC-App".

Verified against local Postgres with an isLeitungsteam team token: create
KC/Gemeinde/Wahl/Workshop, run Zuteilung, create Teamer + invite, resolve
an invite code. flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 10:09:35 +02:00
co-authored by Claude Sonnet 5
parent d68ce42635
commit 2e3e62b896
12 changed files with 999 additions and 61 deletions
+28 -46
View File
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'teamer_admin_screen.dart';
import 'ui.dart';
import 'wahl_admin_screen.dart';
/// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations.
class AdminScreen extends StatefulWidget {
@@ -24,13 +27,13 @@ class _AdminScreenState extends State<AdminScreen> {
Future<void> _createKc() async {
final api = AppScope.of(context).api;
final name = await _promptText(context, 'Neues KC', 'Name');
final name = await promptText(context, 'Neues KC', 'Name');
if (name == null || name.isEmpty || !mounted) return;
try {
await api.createKc(name);
if (mounted) _reload();
} catch (e) {
if (mounted) _toast(context, '$e');
if (mounted) toast(context, '$e');
}
}
@@ -109,13 +112,13 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
Future<void> _addGemeinde() async {
final api = _api;
final name = await _promptText(context, 'Neue Gemeinde', 'Name');
final name = await promptText(context, 'Neue Gemeinde', 'Name');
if (name == null || name.isEmpty || !mounted) return;
try {
await api.createGemeinde(widget.kc.id, name);
if (mounted) _reloadGemeinden();
} catch (e) {
if (mounted) _toast(context, '$e');
if (mounted) toast(context, '$e');
}
}
@@ -133,20 +136,22 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
trailing: const Icon(Icons.qr_code_2),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text('Gemeinden',
style: Theme.of(context).textTheme.titleMedium),
Card(
child: ListTile(
leading: const Icon(Icons.how_to_vote),
title: const Text('Workshop-Wahlen'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => WahlAdminScreen(kcId: widget.kc.id)),
),
TextButton.icon(
onPressed: _addGemeinde,
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
),
],
),
),
const SizedBox(height: 16),
SectionHeader('Gemeinden', action: TextButton.icon(
onPressed: _addGemeinde,
icon: const Icon(Icons.add),
label: const Text('Hinzufügen'),
)),
_GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden),
const Divider(height: 40),
Text('Offene Verantwortlichen-Anfragen',
@@ -161,7 +166,7 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
: await _api.rejectOnboarding(id);
_reloadRequests();
} catch (e) {
if (context.mounted) _toast(context, '$e');
if (context.mounted) toast(context, '$e');
}
},
),
@@ -199,6 +204,12 @@ class _GemeindeList extends StatelessWidget {
dense: true,
leading: const Icon(Icons.church),
title: Text(g.name),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TeamerAdminScreen(gemeindeId: g.id, gemeindeName: g.name),
),
),
),
],
);
@@ -260,32 +271,3 @@ class _RequestList extends StatelessWidget {
}
}
Future<String?> _promptText(BuildContext context, String title, String label) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: label),
onSubmitted: (v) => Navigator.of(context).pop(v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('OK'),
),
],
),
);
}
void _toast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}