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,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)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user