diff --git a/backend/src/files/files.controller.ts b/backend/src/files/files.controller.ts index b7bb5e3..4f91664 100644 --- a/backend/src/files/files.controller.ts +++ b/backend/src/files/files.controller.ts @@ -34,7 +34,7 @@ export class FilesController { /// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier. @Post(':kcId') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) @UseInterceptors(FileInterceptor('file')) upload( diff --git a/backend/src/wahl/wahl.controller.ts b/backend/src/wahl/wahl.controller.ts index 6498857..332b9a2 100644 --- a/backend/src/wahl/wahl.controller.ts +++ b/backend/src/wahl/wahl.controller.ts @@ -32,35 +32,35 @@ export class WahlController { /// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des /// Leitungsteams (global über alle KCs, siehe RolesGuard). @Post() - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) createWahl(@Body() dto: CreateWahlDto) { return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil); } @Get() - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) listWahlen(@Query('kcId') kcId: string) { return this.wahl.listWahlen(kcId); } @Post(':wahlId/workshops') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) { return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer); } @Get(':wahlId/workshops') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) listWorkshops(@Param('wahlId') wahlId: string) { return this.wahl.listWorkshops(wahlId); } @Post(':wahlId/force-zuteilung') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) createForceZuteilung( @Param('wahlId') wahlId: string, @@ -99,21 +99,21 @@ export class WahlController { } @Post(':wahlId/zuteilung/run') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) runZuteilung(@Param('wahlId') wahlId: string) { return this.zuteilung.run(wahlId); } @Get(':wahlId/zuteilung') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) getZuteilung(@Param('wahlId') wahlId: string) { return this.zuteilung.getResults(wahlId); } @Get(':wahlId/zuteilung/csv') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) { const csv = await this.zuteilung.exportCsv(wahlId); diff --git a/client/app/.gitignore b/client/app/.gitignore index 79f7eca..1c09fde 100644 --- a/client/app/.gitignore +++ b/client/app/.gitignore @@ -46,3 +46,6 @@ app.*.map.json # Widget Preview related .widget_preview/ + +# DevTools options +devtools_options.yaml diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index c9902f8..63add4d 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -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 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 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 j) { + final ga = (j['teilnehmer'] as Map?)?['guestAccount'] + as Map? ?? + const {}; + return ZuteilungRow( + name: [ga['firstName'], ga['lastName']].whereType().join(' ').trim(), + workshopName: (j['workshop'] as Map?)?['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 j) => TeamerAccount( + id: j['id'] as String, + email: j['email'] as String? ?? '', + name: [j['firstName'], j['lastName']].whereType().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 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 approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null); Future rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null); + // --- LT Wahl administration --- + Future> wahlenForKc(String kcId) async { + final list = await _get('/wahl?kcId=$kcId') as List; + return list.map((e) => WahlAdmin.fromJson(e as Map)).toList(); + } + + Future createWahl( + String kcId, + String name, + String datumsSchluessel, + String teil, + ) => + _post('/wahl', { + 'kcId': kcId, + 'name': name, + 'datumsSchluessel': datumsSchluessel, + 'teil': teil, + }); + + Future> workshopsForWahl(String wahlId) async { + final list = await _get('/wahl/$wahlId/workshops') as List; + return list.map((e) => WorkshopAdmin.fromJson(e as Map)).toList(); + } + + Future createWorkshop( + String wahlId, + String name, + int kapazitaet, + int minTeilnehmer, + ) => + _post('/wahl/$wahlId/workshops', { + 'name': name, + 'kapazitaet': kapazitaet, + 'minTeilnehmer': minTeilnehmer, + }); + + Future runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null); + + Future> zuteilungResults(String wahlId) async { + final list = await _get('/wahl/$wahlId/zuteilung') as List; + return list.map((e) => ZuteilungRow.fromJson(e as Map)).toList(); + } + + // --- Teamer administration (LT or the responsible Verantwortliche/r) --- + Future> teamerFor(String gemeindeId) async { + final list = await _get('/gemeinde/$gemeindeId/teamer') as List; + return list.map((e) => TeamerAccount.fromJson(e as Map)).toList(); + } + + Future 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> teamerInvitesFor(String gemeindeId) async { + final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List; + return list.map((e) => TeamerInvite.fromJson(e as Map)).toList(); + } + + Future 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); + + // --- Verantwortlichen self-registration --- + Future> resolveInvite(String inviteCode) async => + await _get('/onboarding/kc/$inviteCode') as Map; + + Future> registerVerantwortliche( + String inviteCode, + String gemeindeId, + ) async => + await _post('/onboarding/verantwortliche', { + 'inviteCode': inviteCode, + 'gemeindeId': gemeindeId, + }) as Map; + // --- chat: REST for channels/history; live send/receive is the /chat WS --- Future> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; diff --git a/client/app/lib/screens/admin_screen.dart b/client/app/lib/screens/admin_screen.dart index 82f5e16..290417e 100644 --- a/client/app/lib/screens/admin_screen.dart +++ b/client/app/lib/screens/admin_screen.dart @@ -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 { Future _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 { Future _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 { 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 { : 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 _promptText(BuildContext context, String title, String label) { - final controller = TextEditingController(); - return showDialog( - 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))); -} diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart index 5244f72..0432025 100644 --- a/client/app/lib/screens/home_screen.dart +++ b/client/app/lib/screens/home_screen.dart @@ -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 = [ 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, diff --git a/client/app/lib/screens/teamer_admin_screen.dart b/client/app/lib/screens/teamer_admin_screen.dart new file mode 100644 index 0000000..fa21d7f --- /dev/null +++ b/client/app/lib/screens/teamer_admin_screen.dart @@ -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 createState() => _TeamerAdminScreenState(); +} + +class _TeamerAdminScreenState extends State { + Future>? _teamer; + Future>? _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 _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 _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( + 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>( + 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>( + 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'), + ), + ], + ); + } +} diff --git a/client/app/lib/screens/ui.dart b/client/app/lib/screens/ui.dart new file mode 100644 index 0000000..bf1d05c --- /dev/null +++ b/client/app/lib/screens/ui.dart @@ -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 promptText(BuildContext context, String title, String label) { + final controller = TextEditingController(); + return showDialog( + 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'), + ), + ], + ), + ); +} diff --git a/client/app/lib/screens/verantwortliche_register_screen.dart b/client/app/lib/screens/verantwortliche_register_screen.dart new file mode 100644 index 0000000..431deb6 --- /dev/null +++ b/client/app/lib/screens/verantwortliche_register_screen.dart @@ -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 createState() => + _VerantwortlicheRegisterScreenState(); +} + +class _VerantwortlicheRegisterScreenState + extends State { + 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 _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?) ?? []) + .map((g) => (g['id'] as String, g['name'] as String)) + .toList(); + }); + } catch (e) { + setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _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( + 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)), + ], + ], + ], + ), + ), + ), + ); + } +} diff --git a/client/app/lib/screens/wahl_admin_screen.dart b/client/app/lib/screens/wahl_admin_screen.dart new file mode 100644 index 0000000..28a09b5 --- /dev/null +++ b/client/app/lib/screens/wahl_admin_screen.dart @@ -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 createState() => _WahlAdminScreenState(); +} + +class _WahlAdminScreenState extends State { + Future>? _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 _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>( + 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 createState() => _WahlDetailScreenState(); +} + +class _WahlDetailScreenState extends State { + Future>? _workshops; + Future>? _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 _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 _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>( + 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>( + 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'), + ), + ], + ); + } +} diff --git a/client/app/web/index.html b/client/app/web/index.html index 443cec3..400faaa 100644 --- a/client/app/web/index.html +++ b/client/app/web/index.html @@ -18,18 +18,18 @@ - + - + - kc_app + KC-App diff --git a/client/app/web/manifest.json b/client/app/web/manifest.json index 0785abb..817619f 100644 --- a/client/app/web/manifest.json +++ b/client/app/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "kc_app", - "short_name": "kc_app", + "name": "KC-App", + "short_name": "KC-App", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "A new Flutter project.", + "description": "Konfi-Castle Event-, Wahl- und Kommunikationsplattform", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [