Files
KC-APP/client/app/lib/screens/teamer_admin_screen.dart
T
linusandClaude Sonnet 5 2e3e62b896 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>
2026-09-10 10:09:35 +02:00

225 lines
7.2 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'ui.dart';
/// Teamer administration for one Gemeinde — usable by the Leitungsteam or the
/// responsible Gemeinde Verantwortliche/r.
class TeamerAdminScreen extends StatefulWidget {
const TeamerAdminScreen({
super.key,
required this.gemeindeId,
required this.gemeindeName,
});
final String gemeindeId;
final String gemeindeName;
@override
State<TeamerAdminScreen> createState() => _TeamerAdminScreenState();
}
class _TeamerAdminScreenState extends State<TeamerAdminScreen> {
Future<List<TeamerAccount>>? _teamer;
Future<List<TeamerInvite>>? _invites;
Api get _api => AppScope.of(context).api;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_teamer ??= _api.teamerFor(widget.gemeindeId);
_invites ??= _api.teamerInvitesFor(widget.gemeindeId);
}
void _reloadTeamer() =>
setState(() => _teamer = _api.teamerFor(widget.gemeindeId));
void _reloadInvites() =>
setState(() => _invites = _api.teamerInvitesFor(widget.gemeindeId));
Future<void> _addTeamer() async {
final api = _api;
final v = await showDialog<(String, String, String, String)>(
context: context,
builder: (_) => const _NewTeamerDialog(),
);
if (v == null || !mounted) return;
try {
await api.createTeamer(
widget.gemeindeId,
firstName: v.$1,
lastName: v.$2,
email: v.$3,
password: v.$4,
);
if (mounted) _reloadTeamer();
} catch (e) {
if (mounted) toast(context, '$e');
}
}
Future<void> _addInvite({required bool personal}) async {
final api = _api;
String? email;
if (personal) {
email = await promptText(context, 'E-Mail-Invite', 'E-Mail-Adresse');
if (email == null || email.isEmpty || !mounted) return;
}
try {
final inv = await api.createTeamerInvite(widget.gemeindeId, email: email);
if (!mounted) return;
_reloadInvites();
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Invite erstellt'),
content: SelectableText(
personal
? 'E-Mail an ${inv.email} ausgelöst.\n\nToken: ${inv.token}'
: 'Gruppen-Link-Token (mehrfach nutzbar):\n\n${inv.token}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
} catch (e) {
if (mounted) toast(context, '$e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Teamer:innen · ${widget.gemeindeName}')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SectionHeader('Konten', action: TextButton.icon(
onPressed: _addTeamer,
icon: const Icon(Icons.person_add),
label: const Text('Anlegen'),
)),
FutureBuilder<List<TeamerAccount>>(
future: _teamer,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final list = snap.data!;
if (list.isEmpty) return const Text('Noch keine Teamer:innen.');
return Column(
children: [
for (final t in list)
ListTile(
dense: true,
leading: const Icon(Icons.person),
title: Text(t.name.isEmpty ? t.email : t.name),
subtitle: Text(t.email),
),
],
);
},
),
const Divider(height: 40),
SectionHeader('Einladungen', action: Wrap(
spacing: 4,
children: [
TextButton(
onPressed: () => _addInvite(personal: false),
child: const Text('Gruppen-Link'),
),
TextButton(
onPressed: () => _addInvite(personal: true),
child: const Text('per E-Mail'),
),
],
)),
FutureBuilder<List<TeamerInvite>>(
future: _invites,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const LinearProgressIndicator();
}
if (snap.hasError) return Text('Fehler: ${snap.error}');
final list = snap.data!;
if (list.isEmpty) return const Text('Keine Einladungen.');
return Column(
children: [
for (final i in list)
ListTile(
dense: true,
leading: Icon(i.revoked
? Icons.block
: i.email != null
? Icons.mail
: Icons.link),
title: Text(i.email ?? 'Gruppen-Link'),
subtitle: Text(
'${i.usedCount}${i.maxUses != null ? '/${i.maxUses}' : ''} genutzt'
'${i.revoked ? ' · widerrufen' : ''}',
),
trailing: SelectableText(
i.token.substring(0, 8),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
);
},
),
],
),
);
}
}
class _NewTeamerDialog extends StatefulWidget {
const _NewTeamerDialog();
@override
State<_NewTeamerDialog> createState() => _NewTeamerDialogState();
}
class _NewTeamerDialogState extends State<_NewTeamerDialog> {
final _first = TextEditingController();
final _last = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Teamer:in anlegen'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: _first, decoration: const InputDecoration(labelText: 'Vorname')),
TextField(controller: _last, decoration: const InputDecoration(labelText: 'Nachname')),
TextField(controller: _email, decoration: const InputDecoration(labelText: 'E-Mail')),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort (min. 8)'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
FilledButton(
onPressed: () => Navigator.of(context).pop((
_first.text.trim(),
_last.text.trim(),
_email.text.trim(),
_password.text,
)),
child: const Text('Anlegen'),
),
],
);
}
}