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:
@@ -0,0 +1,224 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user