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:
2026-09-10 10:09:35 +02:00
co-authored by Claude Sonnet 5
parent d68ce42635
commit 2e3e62b896
12 changed files with 999 additions and 61 deletions
+195
View File
@@ -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>;