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:
@@ -34,7 +34,7 @@ export class FilesController {
|
|||||||
|
|
||||||
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
|
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
|
||||||
@Post(':kcId')
|
@Post(':kcId')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
upload(
|
upload(
|
||||||
|
|||||||
@@ -32,35 +32,35 @@ export class WahlController {
|
|||||||
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
|
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
|
||||||
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
|
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
createWahl(@Body() dto: CreateWahlDto) {
|
createWahl(@Body() dto: CreateWahlDto) {
|
||||||
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
|
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
listWahlen(@Query('kcId') kcId: string) {
|
listWahlen(@Query('kcId') kcId: string) {
|
||||||
return this.wahl.listWahlen(kcId);
|
return this.wahl.listWahlen(kcId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':wahlId/workshops')
|
@Post(':wahlId/workshops')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
|
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
|
||||||
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
|
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':wahlId/workshops')
|
@Get(':wahlId/workshops')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
listWorkshops(@Param('wahlId') wahlId: string) {
|
listWorkshops(@Param('wahlId') wahlId: string) {
|
||||||
return this.wahl.listWorkshops(wahlId);
|
return this.wahl.listWorkshops(wahlId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':wahlId/force-zuteilung')
|
@Post(':wahlId/force-zuteilung')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
createForceZuteilung(
|
createForceZuteilung(
|
||||||
@Param('wahlId') wahlId: string,
|
@Param('wahlId') wahlId: string,
|
||||||
@@ -99,21 +99,21 @@ export class WahlController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':wahlId/zuteilung/run')
|
@Post(':wahlId/zuteilung/run')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
runZuteilung(@Param('wahlId') wahlId: string) {
|
runZuteilung(@Param('wahlId') wahlId: string) {
|
||||||
return this.zuteilung.run(wahlId);
|
return this.zuteilung.run(wahlId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':wahlId/zuteilung')
|
@Get(':wahlId/zuteilung')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
getZuteilung(@Param('wahlId') wahlId: string) {
|
getZuteilung(@Param('wahlId') wahlId: string) {
|
||||||
return this.zuteilung.getResults(wahlId);
|
return this.zuteilung.getResults(wahlId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':wahlId/zuteilung/csv')
|
@Get(':wahlId/zuteilung/csv')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
|
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
|
||||||
const csv = await this.zuteilung.exportCsv(wahlId);
|
const csv = await this.zuteilung.exportCsv(wahlId);
|
||||||
|
|||||||
@@ -46,3 +46,6 @@ app.*.map.json
|
|||||||
|
|
||||||
# Widget Preview related
|
# Widget Preview related
|
||||||
.widget_preview/
|
.widget_preview/
|
||||||
|
|
||||||
|
# DevTools options
|
||||||
|
devtools_options.yaml
|
||||||
|
|||||||
@@ -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 {
|
class Workshop {
|
||||||
Workshop({required this.id, required this.name, required this.kapazitaet});
|
Workshop({required this.id, required this.name, required this.kapazitaet});
|
||||||
final String id;
|
final String id;
|
||||||
@@ -402,6 +504,99 @@ class Api {
|
|||||||
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
|
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
|
||||||
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', 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 ---
|
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
||||||
Future<List<ChatChannel>> channels(String kcId) async {
|
Future<List<ChatChannel>> channels(String kcId) async {
|
||||||
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
import '../main.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.
|
/// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations.
|
||||||
class AdminScreen extends StatefulWidget {
|
class AdminScreen extends StatefulWidget {
|
||||||
@@ -24,13 +27,13 @@ class _AdminScreenState extends State<AdminScreen> {
|
|||||||
|
|
||||||
Future<void> _createKc() async {
|
Future<void> _createKc() async {
|
||||||
final api = AppScope.of(context).api;
|
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;
|
if (name == null || name.isEmpty || !mounted) return;
|
||||||
try {
|
try {
|
||||||
await api.createKc(name);
|
await api.createKc(name);
|
||||||
if (mounted) _reload();
|
if (mounted) _reload();
|
||||||
} catch (e) {
|
} 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 {
|
Future<void> _addGemeinde() async {
|
||||||
final api = _api;
|
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;
|
if (name == null || name.isEmpty || !mounted) return;
|
||||||
try {
|
try {
|
||||||
await api.createGemeinde(widget.kc.id, name);
|
await api.createGemeinde(widget.kc.id, name);
|
||||||
if (mounted) _reloadGemeinden();
|
if (mounted) _reloadGemeinden();
|
||||||
} catch (e) {
|
} 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),
|
trailing: const Icon(Icons.qr_code_2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
Card(
|
||||||
Row(
|
child: ListTile(
|
||||||
children: [
|
leading: const Icon(Icons.how_to_vote),
|
||||||
Expanded(
|
title: const Text('Workshop-Wahlen'),
|
||||||
child: Text('Gemeinden',
|
trailing: const Icon(Icons.chevron_right),
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => WahlAdminScreen(kcId: widget.kc.id)),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SectionHeader('Gemeinden', action: TextButton.icon(
|
||||||
onPressed: _addGemeinde,
|
onPressed: _addGemeinde,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Hinzufügen'),
|
label: const Text('Hinzufügen'),
|
||||||
),
|
)),
|
||||||
],
|
|
||||||
),
|
|
||||||
_GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden),
|
_GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden),
|
||||||
const Divider(height: 40),
|
const Divider(height: 40),
|
||||||
Text('Offene Verantwortlichen-Anfragen',
|
Text('Offene Verantwortlichen-Anfragen',
|
||||||
@@ -161,7 +166,7 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
|
|||||||
: await _api.rejectOnboarding(id);
|
: await _api.rejectOnboarding(id);
|
||||||
_reloadRequests();
|
_reloadRequests();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) _toast(context, '$e');
|
if (context.mounted) toast(context, '$e');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -199,6 +204,12 @@ class _GemeindeList extends StatelessWidget {
|
|||||||
dense: true,
|
dense: true,
|
||||||
leading: const Icon(Icons.church),
|
leading: const Icon(Icons.church),
|
||||||
title: Text(g.name),
|
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 'admin_screen.dart';
|
||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
import 'files_screen.dart';
|
import 'files_screen.dart';
|
||||||
|
import 'verantwortliche_register_screen.dart';
|
||||||
import 'wahl_screen.dart';
|
import 'wahl_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
@@ -16,6 +17,10 @@ class HomeScreen extends StatelessWidget {
|
|||||||
final id = state.identity!;
|
final id = state.identity!;
|
||||||
final kcId = id.kcId;
|
final kcId = id.kcId;
|
||||||
|
|
||||||
|
final needsVerantwRegistration = id.kind == SessionKind.user &&
|
||||||
|
!id.isLeitungsteam &&
|
||||||
|
id.memberships.isEmpty;
|
||||||
|
|
||||||
final tiles = <Widget>[
|
final tiles = <Widget>[
|
||||||
if (id.isLeitungsteam)
|
if (id.isLeitungsteam)
|
||||||
_NavTile(
|
_NavTile(
|
||||||
@@ -24,6 +29,13 @@ class HomeScreen extends StatelessWidget {
|
|||||||
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
|
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
|
||||||
onTap: () => _open(context, const AdminScreen()),
|
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)
|
if (id.kind == SessionKind.guest)
|
||||||
_NavTile(
|
_NavTile(
|
||||||
icon: Icons.how_to_vote,
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,18 +18,18 @@
|
|||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="A new Flutter project.">
|
<meta name="description" content="Konfi-Castle App">
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="kc_app">
|
<meta name="apple-mobile-web-app-title" content="KC-App">
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>kc_app</title>
|
<title>KC-App</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "kc_app",
|
"name": "KC-App",
|
||||||
"short_name": "kc_app",
|
"short_name": "KC-App",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#0175C2",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#0175C2",
|
||||||
"description": "A new Flutter project.",
|
"description": "Konfi-Castle Event-, Wahl- und Kommunikationsplattform",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
Reference in New Issue
Block a user