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:
@@ -142,6 +142,108 @@ class OnboardingRequest {
|
||||
}
|
||||
}
|
||||
|
||||
class WahlAdmin {
|
||||
WahlAdmin({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.datumsSchluessel,
|
||||
required this.teil,
|
||||
required this.isOpen,
|
||||
});
|
||||
final String id;
|
||||
final String name;
|
||||
final String datumsSchluessel;
|
||||
final String teil;
|
||||
final bool isOpen;
|
||||
factory WahlAdmin.fromJson(Map<String, dynamic> j) => WahlAdmin(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
datumsSchluessel: j['datumsSchluessel'] as String? ?? '',
|
||||
teil: j['teil'] as String? ?? '',
|
||||
isOpen: j['isOpen'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
class WorkshopAdmin {
|
||||
WorkshopAdmin({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.kapazitaet,
|
||||
required this.minTeilnehmer,
|
||||
});
|
||||
final String id;
|
||||
final String name;
|
||||
final int kapazitaet;
|
||||
final int minTeilnehmer;
|
||||
factory WorkshopAdmin.fromJson(Map<String, dynamic> j) => WorkshopAdmin(
|
||||
id: j['id'] as String,
|
||||
name: j['name'] as String,
|
||||
kapazitaet: (j['kapazitaet'] as num).toInt(),
|
||||
minTeilnehmer: (j['minTeilnehmer'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class ZuteilungRow {
|
||||
ZuteilungRow({
|
||||
required this.name,
|
||||
required this.workshopName,
|
||||
required this.wunschRang,
|
||||
required this.isForced,
|
||||
});
|
||||
final String name;
|
||||
final String? workshopName;
|
||||
final int wunschRang;
|
||||
final bool isForced;
|
||||
factory ZuteilungRow.fromJson(Map<String, dynamic> j) {
|
||||
final ga = (j['teilnehmer'] as Map<String, dynamic>?)?['guestAccount']
|
||||
as Map<String, dynamic>? ??
|
||||
const {};
|
||||
return ZuteilungRow(
|
||||
name: [ga['firstName'], ga['lastName']].whereType<String>().join(' ').trim(),
|
||||
workshopName: (j['workshop'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
wunschRang: (j['wunschRang'] as num?)?.toInt() ?? -1,
|
||||
isForced: j['isForced'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TeamerAccount {
|
||||
TeamerAccount({required this.id, required this.email, required this.name});
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
factory TeamerAccount.fromJson(Map<String, dynamic> j) => TeamerAccount(
|
||||
id: j['id'] as String,
|
||||
email: j['email'] as String? ?? '',
|
||||
name: [j['firstName'], j['lastName']].whereType<String>().join(' ').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
class TeamerInvite {
|
||||
TeamerInvite({
|
||||
required this.id,
|
||||
required this.token,
|
||||
required this.email,
|
||||
required this.usedCount,
|
||||
required this.maxUses,
|
||||
required this.revoked,
|
||||
});
|
||||
final String id;
|
||||
final String token;
|
||||
final String? email;
|
||||
final int usedCount;
|
||||
final int? maxUses;
|
||||
final bool revoked;
|
||||
factory TeamerInvite.fromJson(Map<String, dynamic> j) => TeamerInvite(
|
||||
id: j['id'] as String,
|
||||
token: j['token'] as String,
|
||||
email: j['email'] as String?,
|
||||
usedCount: (j['usedCount'] as num?)?.toInt() ?? 0,
|
||||
maxUses: (j['maxUses'] as num?)?.toInt(),
|
||||
revoked: j['revokedAt'] != null,
|
||||
);
|
||||
}
|
||||
|
||||
class Workshop {
|
||||
Workshop({required this.id, required this.name, required this.kapazitaet});
|
||||
final String id;
|
||||
@@ -402,6 +504,99 @@ class Api {
|
||||
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
|
||||
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null);
|
||||
|
||||
// --- LT Wahl administration ---
|
||||
Future<List<WahlAdmin>> wahlenForKc(String kcId) async {
|
||||
final list = await _get('/wahl?kcId=$kcId') as List<dynamic>;
|
||||
return list.map((e) => WahlAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> createWahl(
|
||||
String kcId,
|
||||
String name,
|
||||
String datumsSchluessel,
|
||||
String teil,
|
||||
) =>
|
||||
_post('/wahl', {
|
||||
'kcId': kcId,
|
||||
'name': name,
|
||||
'datumsSchluessel': datumsSchluessel,
|
||||
'teil': teil,
|
||||
});
|
||||
|
||||
Future<List<WorkshopAdmin>> workshopsForWahl(String wahlId) async {
|
||||
final list = await _get('/wahl/$wahlId/workshops') as List<dynamic>;
|
||||
return list.map((e) => WorkshopAdmin.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> createWorkshop(
|
||||
String wahlId,
|
||||
String name,
|
||||
int kapazitaet,
|
||||
int minTeilnehmer,
|
||||
) =>
|
||||
_post('/wahl/$wahlId/workshops', {
|
||||
'name': name,
|
||||
'kapazitaet': kapazitaet,
|
||||
'minTeilnehmer': minTeilnehmer,
|
||||
});
|
||||
|
||||
Future<void> runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null);
|
||||
|
||||
Future<List<ZuteilungRow>> zuteilungResults(String wahlId) async {
|
||||
final list = await _get('/wahl/$wahlId/zuteilung') as List<dynamic>;
|
||||
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
|
||||
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
|
||||
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
|
||||
return list.map((e) => TeamerAccount.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> createTeamer(
|
||||
String gemeindeId, {
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String email,
|
||||
required String password,
|
||||
}) =>
|
||||
_post('/gemeinde/$gemeindeId/teamer', {
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'email': email,
|
||||
'password': password,
|
||||
});
|
||||
|
||||
Future<List<TeamerInvite>> teamerInvitesFor(String gemeindeId) async {
|
||||
final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List<dynamic>;
|
||||
return list.map((e) => TeamerInvite.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<TeamerInvite> createTeamerInvite(
|
||||
String gemeindeId, {
|
||||
String? email,
|
||||
int? maxUses,
|
||||
int? expiresInHours,
|
||||
}) async =>
|
||||
TeamerInvite.fromJson(await _post('/gemeinde/$gemeindeId/teamer-invites', {
|
||||
if (email != null && email.isNotEmpty) 'email': email,
|
||||
'maxUses': ?maxUses,
|
||||
'expiresInHours': ?expiresInHours,
|
||||
}) as Map<String, dynamic>);
|
||||
|
||||
// --- Verantwortlichen self-registration ---
|
||||
Future<Map<String, dynamic>> resolveInvite(String inviteCode) async =>
|
||||
await _get('/onboarding/kc/$inviteCode') as Map<String, dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> registerVerantwortliche(
|
||||
String inviteCode,
|
||||
String gemeindeId,
|
||||
) async =>
|
||||
await _post('/onboarding/verantwortliche', {
|
||||
'inviteCode': inviteCode,
|
||||
'gemeindeId': gemeindeId,
|
||||
}) as Map<String, dynamic>;
|
||||
|
||||
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
||||
Future<List<ChatChannel>> channels(String kcId) async {
|
||||
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../main.dart';
|
||||
import 'admin_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'files_screen.dart';
|
||||
import 'verantwortliche_register_screen.dart';
|
||||
import 'wahl_screen.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
@@ -16,6 +17,10 @@ class HomeScreen extends StatelessWidget {
|
||||
final id = state.identity!;
|
||||
final kcId = id.kcId;
|
||||
|
||||
final needsVerantwRegistration = id.kind == SessionKind.user &&
|
||||
!id.isLeitungsteam &&
|
||||
id.memberships.isEmpty;
|
||||
|
||||
final tiles = <Widget>[
|
||||
if (id.isLeitungsteam)
|
||||
_NavTile(
|
||||
@@ -24,6 +29,13 @@ class HomeScreen extends StatelessWidget {
|
||||
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
|
||||
onTap: () => _open(context, const AdminScreen()),
|
||||
),
|
||||
if (needsVerantwRegistration)
|
||||
_NavTile(
|
||||
icon: Icons.how_to_reg,
|
||||
title: 'Als Verantwortliche/r registrieren',
|
||||
subtitle: 'KC-Code eingeben, Gemeinde wählen, Freigabe abwarten',
|
||||
onTap: () => _open(context, const VerantwortlicheRegisterScreen()),
|
||||
),
|
||||
if (id.kind == SessionKind.guest)
|
||||
_NavTile(
|
||||
icon: Icons.how_to_vote,
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Small shared widgets/helpers used across the admin screens.
|
||||
|
||||
void toast(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
class ErrorText extends StatelessWidget {
|
||||
const ErrorText(this.message, {super.key, this.onRetry});
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SectionHeader extends StatelessWidget {
|
||||
const SectionHeader(this.title, {super.key, this.action});
|
||||
final String title;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-line text prompt dialog. Returns the trimmed value or null.
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
|
||||
/// Self-registration as a Gemeinde Verantwortliche/r: enter the KC invite
|
||||
/// code, pick your Gemeinde, send the request. The result is a PENDING
|
||||
/// membership a Leitungsteam member has to approve.
|
||||
class VerantwortlicheRegisterScreen extends StatefulWidget {
|
||||
const VerantwortlicheRegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<VerantwortlicheRegisterScreen> createState() =>
|
||||
_VerantwortlicheRegisterScreenState();
|
||||
}
|
||||
|
||||
class _VerantwortlicheRegisterScreenState
|
||||
extends State<VerantwortlicheRegisterScreen> {
|
||||
final _code = TextEditingController();
|
||||
String? _kcName;
|
||||
List<(String id, String name)> _gemeinden = [];
|
||||
String? _selectedGemeinde;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
String? _done;
|
||||
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
Future<void> _resolve() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_kcName = null;
|
||||
_gemeinden = [];
|
||||
});
|
||||
try {
|
||||
final res = await _api.resolveInvite(_code.text.trim());
|
||||
setState(() {
|
||||
_kcName = res['kcName'] as String?;
|
||||
_gemeinden = ((res['gemeinden'] as List<dynamic>?) ?? [])
|
||||
.map((g) => (g['id'] as String, g['name'] as String))
|
||||
.toList();
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_selectedGemeinde == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final res =
|
||||
await _api.registerVerantwortliche(_code.text.trim(), _selectedGemeinde!);
|
||||
setState(() => _done =
|
||||
'Anfrage gesendet (Status: ${res['status']}). Ein Leitungsteam-Mitglied '
|
||||
'muss dich noch freischalten.');
|
||||
} catch (e) {
|
||||
setState(() => _error = '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Als Verantwortliche/r registrieren')),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
if (_done != null) ...[
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(_done!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => AppScope.of(context).logout(),
|
||||
child: const Text('Abmelden'),
|
||||
),
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: _code,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'KC-Einladungscode',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _busy ? null : _resolve,
|
||||
child: const Text('KC suchen'),
|
||||
),
|
||||
if (_kcName != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text('KC: $_kcName',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedGemeinde,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Deine Gemeinde',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final g in _gemeinden)
|
||||
DropdownMenuItem(value: g.$1, child: Text(g.$2)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _selectedGemeinde = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: (_busy || _selectedGemeinde == null) ? null : _submit,
|
||||
child: const Text('Anfrage senden'),
|
||||
),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api.dart';
|
||||
import '../main.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// LT: manage the Wahlen of one KC — create, add workshops, run the
|
||||
/// assignment algorithm, view the result.
|
||||
class WahlAdminScreen extends StatefulWidget {
|
||||
const WahlAdminScreen({super.key, required this.kcId});
|
||||
final String kcId;
|
||||
|
||||
@override
|
||||
State<WahlAdminScreen> createState() => _WahlAdminScreenState();
|
||||
}
|
||||
|
||||
class _WahlAdminScreenState extends State<WahlAdminScreen> {
|
||||
Future<List<WahlAdmin>>? _future;
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= _api.wahlenForKc(widget.kcId);
|
||||
}
|
||||
|
||||
void _reload() => setState(() => _future = _api.wahlenForKc(widget.kcId));
|
||||
|
||||
Future<void> _create() async {
|
||||
final api = _api;
|
||||
final v = await showDialog<(String, String, String)>(
|
||||
context: context,
|
||||
builder: (_) => const _NewWahlDialog(),
|
||||
);
|
||||
if (v == null || !mounted) return;
|
||||
try {
|
||||
await api.createWahl(widget.kcId, v.$1, v.$2, v.$3);
|
||||
if (mounted) _reload();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Wahlen')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _create,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Wahl'),
|
||||
),
|
||||
body: FutureBuilder<List<WahlAdmin>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
|
||||
final wahlen = snap.data!;
|
||||
if (wahlen.isEmpty) {
|
||||
return const Center(child: Text('Noch keine Wahlen. Unten anlegen.'));
|
||||
}
|
||||
return ListView(
|
||||
children: [
|
||||
for (final w in wahlen)
|
||||
ListTile(
|
||||
leading: Icon(w.isOpen ? Icons.lock_open : Icons.lock),
|
||||
title: Text(w.name),
|
||||
subtitle: Text('${w.datumsSchluessel} · Teil ${w.teil}'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => WahlDetailScreen(wahl: w)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WahlDetailScreen extends StatefulWidget {
|
||||
const WahlDetailScreen({super.key, required this.wahl});
|
||||
final WahlAdmin wahl;
|
||||
|
||||
@override
|
||||
State<WahlDetailScreen> createState() => _WahlDetailScreenState();
|
||||
}
|
||||
|
||||
class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
||||
Future<List<WorkshopAdmin>>? _workshops;
|
||||
Future<List<ZuteilungRow>>? _results;
|
||||
bool _running = false;
|
||||
Api get _api => AppScope.of(context).api;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_workshops ??= _api.workshopsForWahl(widget.wahl.id);
|
||||
_results ??= _api.zuteilungResults(widget.wahl.id);
|
||||
}
|
||||
|
||||
void _reloadWorkshops() =>
|
||||
setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id));
|
||||
void _reloadResults() =>
|
||||
setState(() => _results = _api.zuteilungResults(widget.wahl.id));
|
||||
|
||||
Future<void> _addWorkshop() async {
|
||||
final api = _api;
|
||||
final v = await showDialog<(String, int, int)>(
|
||||
context: context,
|
||||
builder: (_) => const _NewWorkshopDialog(),
|
||||
);
|
||||
if (v == null || !mounted) return;
|
||||
try {
|
||||
await api.createWorkshop(widget.wahl.id, v.$1, v.$2, v.$3);
|
||||
if (mounted) _reloadWorkshops();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
final api = _api;
|
||||
setState(() => _running = true);
|
||||
try {
|
||||
await api.runZuteilung(widget.wahl.id);
|
||||
if (mounted) _reloadResults();
|
||||
} catch (e) {
|
||||
if (mounted) toast(context, '$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _running = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.wahl.name)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SectionHeader('Workshops', action: TextButton.icon(
|
||||
onPressed: _addWorkshop,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Hinzufügen'),
|
||||
)),
|
||||
FutureBuilder<List<WorkshopAdmin>>(
|
||||
future: _workshops,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
if (snap.hasError) return Text('Fehler: ${snap.error}');
|
||||
final ws = snap.data!;
|
||||
if (ws.isEmpty) return const Text('Noch keine Workshops.');
|
||||
return Column(
|
||||
children: [
|
||||
for (final w in ws)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.groups),
|
||||
title: Text(w.name),
|
||||
subtitle: Text('Kapazität ${w.kapazitaet} · min. ${w.minTeilnehmer}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 40),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('Zuteilung',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _running ? null : _run,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: const Text('Ausführen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<List<ZuteilungRow>>(
|
||||
future: _results,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
if (snap.hasError) return Text('Fehler: ${snap.error}');
|
||||
final rows = snap.data!;
|
||||
if (rows.isEmpty) {
|
||||
return const Text('Noch keine Zuteilung berechnet.');
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final r in rows)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(r.name),
|
||||
subtitle: Text(r.workshopName ?? 'UNZUGETEILT'),
|
||||
trailing: Text(
|
||||
r.isForced
|
||||
? 'fest'
|
||||
: r.wunschRang > 0
|
||||
? 'Wunsch ${r.wunschRang}'
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewWahlDialog extends StatefulWidget {
|
||||
const _NewWahlDialog();
|
||||
@override
|
||||
State<_NewWahlDialog> createState() => _NewWahlDialogState();
|
||||
}
|
||||
|
||||
class _NewWahlDialogState extends State<_NewWahlDialog> {
|
||||
final _name = TextEditingController();
|
||||
final _datum = TextEditingController();
|
||||
final _teil = TextEditingController(text: '1');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Neue Wahl'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
|
||||
TextField(
|
||||
controller: _datum,
|
||||
decoration: const InputDecoration(labelText: 'Datumsschlüssel (z. B. 2026-06-13)')),
|
||||
TextField(controller: _teil, decoration: const InputDecoration(labelText: 'Teil')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
(_name.text.trim(), _datum.text.trim(), _teil.text.trim()),
|
||||
),
|
||||
child: const Text('Anlegen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewWorkshopDialog extends StatefulWidget {
|
||||
const _NewWorkshopDialog();
|
||||
@override
|
||||
State<_NewWorkshopDialog> createState() => _NewWorkshopDialogState();
|
||||
}
|
||||
|
||||
class _NewWorkshopDialogState extends State<_NewWorkshopDialog> {
|
||||
final _name = TextEditingController();
|
||||
final _kap = TextEditingController(text: '12');
|
||||
final _min = TextEditingController(text: '0');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Neuer Workshop'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
|
||||
TextField(
|
||||
controller: _kap,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Kapazität')),
|
||||
TextField(
|
||||
controller: _min,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'Mindestteilnehmer')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop((
|
||||
_name.text.trim(),
|
||||
int.tryParse(_kap.text) ?? 0,
|
||||
int.tryParse(_min.text) ?? 0,
|
||||
)),
|
||||
child: const Text('Anlegen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user