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