diff --git a/backend/src/wahl/dto/update-wahl.dto.ts b/backend/src/wahl/dto/update-wahl.dto.ts new file mode 100644 index 0000000..5d3a1bc --- /dev/null +++ b/backend/src/wahl/dto/update-wahl.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateWahlDto { + @IsOptional() + @IsBoolean() + isOpen?: boolean; +} diff --git a/backend/src/wahl/wahl.controller.ts b/backend/src/wahl/wahl.controller.ts index 332b9a2..8dd49c1 100644 --- a/backend/src/wahl/wahl.controller.ts +++ b/backend/src/wahl/wahl.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, Param, + Patch, Post, Query, Req, @@ -14,6 +15,7 @@ import { Response } from 'express'; import { WahlService } from './wahl.service'; import { ZuteilungService } from './zuteilung.service'; import { CreateWahlDto } from './dto/create-wahl.dto'; +import { UpdateWahlDto } from './dto/update-wahl.dto'; import { CreateWorkshopDto } from './dto/create-workshop.dto'; import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto'; import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto'; @@ -45,6 +47,20 @@ export class WahlController { return this.wahl.listWahlen(kcId); } + @Patch(':wahlId') + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + updateWahl(@Param('wahlId') wahlId: string, @Body() dto: UpdateWahlDto) { + return this.wahl.updateWahl(wahlId, { isOpen: dto.isOpen }); + } + + @Get(':wahlId/teilnehmer') + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listTeilnehmer(@Param('wahlId') wahlId: string) { + return this.wahl.listTeilnehmer(wahlId); + } + @Post(':wahlId/workshops') @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) diff --git a/backend/src/wahl/wahl.service.ts b/backend/src/wahl/wahl.service.ts index 9223234..f1858ae 100644 --- a/backend/src/wahl/wahl.service.ts +++ b/backend/src/wahl/wahl.service.ts @@ -22,6 +22,33 @@ export class WahlService { return this.prisma.wahl.findMany({ where: { kcId } }); } + async updateWahl(wahlId: string, data: { isOpen?: boolean }) { + await this.getWahlOrThrow(wahlId); + const wahl = await this.prisma.wahl.update({ where: { id: wahlId }, data }); + await this.sync.capture('Wahl', SyncOperation.UPDATE, wahl.id, wahl); + return wahl; + } + + /// LT view of who took part in a Wahl, with their priorities and any + /// existing Force-Zuteilung. + async listTeilnehmer(wahlId: string) { + await this.getWahlOrThrow(wahlId); + const rows = await this.prisma.teilnehmer.findMany({ + where: { wahlId }, + orderBy: { guestAccount: { lastName: 'asc' } }, + include: { + guestAccount: { select: { firstName: true, lastName: true } }, + forceZuteilung: { select: { workshopId: true } }, + }, + }); + return rows.map((t) => ({ + id: t.id, + name: `${t.guestAccount.firstName} ${t.guestAccount.lastName}`.trim(), + prioritaeten: (t.prioritaeten as string[] | null) ?? [], + forcedWorkshopId: t.forceZuteilung?.workshopId ?? null, + })); + } + /// Guest-facing view: open Wahlen for the guest's KC, each with its /// workshops and the guest's own current priorities (null if not submitted). async guestOverview(kcId: string, guestAccountId: string) { diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 63add4d..da4acc0 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -183,6 +183,26 @@ class WorkshopAdmin { ); } +class TeilnehmerRow { + TeilnehmerRow({ + required this.id, + required this.name, + required this.prioritaeten, + required this.forcedWorkshopId, + }); + final String id; + final String name; + final List prioritaeten; + final String? forcedWorkshopId; + factory TeilnehmerRow.fromJson(Map j) => TeilnehmerRow( + id: j['id'] as String, + name: j['name'] as String? ?? '', + prioritaeten: + (j['prioritaeten'] as List? ?? []).map((e) => e as String).toList(), + forcedWorkshopId: j['forcedWorkshopId'] as String?, + ); +} + class ZuteilungRow { ZuteilungRow({ required this.name, @@ -393,6 +413,15 @@ class Api { return _decode(res); } + Future _patch(String path, Object? body) async { + final res = await _client.patch( + Uri.parse('$kApiBase$path'), + headers: _headers, + body: body == null ? null : jsonEncode(body), + ); + return _decode(res); + } + dynamic _decode(http.Response res) { final text = res.body.isEmpty ? '{}' : res.body; dynamic parsed; @@ -547,6 +576,42 @@ class Api { return list.map((e) => ZuteilungRow.fromJson(e as Map)).toList(); } + Future setWahlOpen(String wahlId, bool isOpen) => + _patch('/wahl/$wahlId', {'isOpen': isOpen}); + + Future> wahlTeilnehmer(String wahlId) async { + final list = await _get('/wahl/$wahlId/teilnehmer') as List; + return list.map((e) => TeilnehmerRow.fromJson(e as Map)).toList(); + } + + Future forceZuteilung(String wahlId, String teilnehmerId, String workshopId) => + _post('/wahl/$wahlId/force-zuteilung', { + 'teilnehmerId': teilnehmerId, + 'workshopId': workshopId, + }); + + Future zuteilungCsv(String wahlId) async { + final res = await _get('/wahl/$wahlId/zuteilung/csv'); + return res is String ? res : res.toString(); + } + + Future uploadFile( + String kcId, + String filename, + List bytes, + String visibility, + ) async { + final req = http.MultipartRequest('POST', Uri.parse('$kApiBase/files/$kcId')) + ..fields['visibility'] = visibility + ..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename)); + if (token != null) req.headers['Authorization'] = 'Bearer $token'; + final streamed = await req.send(); + final res = await http.Response.fromStream(streamed); + if (res.statusCode < 200 || res.statusCode >= 300) { + _decode(res); // throws ApiException with the server message + } + } + // --- Teamer administration (LT or the responsible Verantwortliche/r) --- Future> teamerFor(String gemeindeId) async { final list = await _get('/gemeinde/$gemeindeId/teamer') as List; diff --git a/client/app/lib/browser_stub.dart b/client/app/lib/browser_stub.dart index 3e02c0f..79f4cfa 100644 --- a/client/app/lib/browser_stub.dart +++ b/client/app/lib/browser_stub.dart @@ -7,3 +7,8 @@ void removeSession(String key) => throw UnsupportedError(_msg); Never redirect(String url) => throw UnsupportedError(_msg); Map currentQueryParameters() => const {}; void clearQuery() {} + +Future<({String name, List bytes})?> pickFile() async => + throw UnsupportedError(_msg); +void downloadText(String filename, String content, {String mime = 'text/plain'}) => + throw UnsupportedError(_msg); diff --git a/client/app/lib/browser_web.dart b/client/app/lib/browser_web.dart index d68ac12..10e507d 100644 --- a/client/app/lib/browser_web.dart +++ b/client/app/lib/browser_web.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:js_interop'; + import 'package:web/web.dart' as web; /// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab @@ -20,3 +23,41 @@ Map currentQueryParameters() => void clearQuery() { web.window.history.replaceState(null, '', '/'); } + +/// Opens the OS file picker and reads the chosen file's bytes. +Future<({String name, List bytes})?> pickFile() { + final completer = Completer<({String name, List bytes})?>(); + final input = web.HTMLInputElement()..type = 'file'; + input.onchange = ((web.Event _) { + final files = input.files; + if (files == null || files.length == 0) { + completer.complete(null); + return; + } + final file = files.item(0)!; + final reader = web.FileReader(); + reader.onload = ((web.Event _) { + final buffer = (reader.result as JSArrayBuffer).toDart; + completer.complete((name: file.name, bytes: buffer.asUint8List())); + }).toJS; + reader.onerror = ((web.Event _) => completer.complete(null)).toJS; + reader.readAsArrayBuffer(file); + }).toJS; + input.click(); + return completer.future; +} + +/// Triggers a browser download of an in-memory string (e.g. the CSV export). +void downloadText( + String filename, + String content, { + String mime = 'text/csv;charset=utf-8', +}) { + final blob = web.Blob([content.toJS].toJS, web.BlobPropertyBag(type: mime)); + final url = web.URL.createObjectURL(blob); + web.HTMLAnchorElement() + ..href = url + ..download = filename + ..click(); + web.URL.revokeObjectURL(url); +} diff --git a/client/app/lib/screens/admin_screen.dart b/client/app/lib/screens/admin_screen.dart index 290417e..adc6382 100644 --- a/client/app/lib/screens/admin_screen.dart +++ b/client/app/lib/screens/admin_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../api.dart'; import '../main.dart'; +import 'files_admin_screen.dart'; import 'teamer_admin_screen.dart'; import 'ui.dart'; import 'wahl_admin_screen.dart'; @@ -146,6 +147,16 @@ class _KcDetailScreenState extends State { ), ), ), + Card( + child: ListTile( + leading: const Icon(Icons.folder_shared), + title: const Text('Dateien'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => FilesAdminScreen(kcId: widget.kc.id)), + ), + ), + ), const SizedBox(height: 16), SectionHeader('Gemeinden', action: TextButton.icon( onPressed: _addGemeinde, diff --git a/client/app/lib/screens/files_admin_screen.dart b/client/app/lib/screens/files_admin_screen.dart new file mode 100644 index 0000000..7648b14 --- /dev/null +++ b/client/app/lib/screens/files_admin_screen.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../browser.dart' as browser; +import '../main.dart'; +import 'ui.dart'; + +/// LT file management for one KC: upload with a visibility tier + list. +class FilesAdminScreen extends StatefulWidget { + const FilesAdminScreen({super.key, required this.kcId}); + final String kcId; + + @override + State createState() => _FilesAdminScreenState(); +} + +class _FilesAdminScreenState extends State { + Future>? _files; + bool _uploading = false; + Api get _api => AppScope.of(context).api; + + static const _visibilities = { + 'ALLE': 'Alle (inkl. Konfis)', + 'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)', + 'NUR_LT': 'Nur Leitungsteam', + }; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _files ??= _api.files(widget.kcId); + } + + void _reload() => setState(() => _files = _api.files(widget.kcId)); + + Future _upload() async { + final api = _api; + final picked = await browser.pickFile(); + if (picked == null || !mounted) return; + final visibility = await showDialog( + context: context, + builder: (_) => SimpleDialog( + title: Text('Sichtbarkeit für „${picked.name}“'), + children: [ + for (final e in _visibilities.entries) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(e.key), + child: Text(e.value), + ), + ], + ), + ); + if (visibility == null || !mounted) return; + setState(() => _uploading = true); + try { + await api.uploadFile(widget.kcId, picked.name, picked.bytes, visibility); + if (mounted) _reload(); + } catch (e) { + if (mounted) toast(context, '$e'); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Dateien (LT)')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _uploading ? null : _upload, + icon: _uploading + ? const SizedBox( + height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.upload_file), + label: const Text('Hochladen'), + ), + body: FutureBuilder>( + future: _files, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload); + final files = snap.data!; + if (files.isEmpty) { + return const Center(child: Text('Noch keine Dateien.')); + } + return ListView( + children: [ + for (final f in files) + ListTile( + leading: const Icon(Icons.insert_drive_file_outlined), + title: Text(f.filename), + subtitle: Text(_visibilities[f.visibility] ?? f.visibility), + ), + ], + ); + }, + ), + ); + } +} diff --git a/client/app/lib/screens/wahl_admin_screen.dart b/client/app/lib/screens/wahl_admin_screen.dart index 28a09b5..1e4f0f5 100644 --- a/client/app/lib/screens/wahl_admin_screen.dart +++ b/client/app/lib/screens/wahl_admin_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../api.dart'; +import '../browser.dart' as browser; import '../main.dart'; import 'ui.dart'; @@ -92,20 +93,80 @@ class WahlDetailScreen extends StatefulWidget { class _WahlDetailScreenState extends State { Future>? _workshops; Future>? _results; + Future>? _teilnehmer; + late bool _isOpen = widget.wahl.isOpen; + List _workshopCache = const []; bool _running = false; Api get _api => AppScope.of(context).api; @override void didChangeDependencies() { super.didChangeDependencies(); - _workshops ??= _api.workshopsForWahl(widget.wahl.id); + _workshops ??= _api.workshopsForWahl(widget.wahl.id).then((w) { + _workshopCache = w; + return w; + }); _results ??= _api.zuteilungResults(widget.wahl.id); + _teilnehmer ??= _api.wahlTeilnehmer(widget.wahl.id); } - void _reloadWorkshops() => - setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id)); + void _reloadWorkshops() => setState(() { + _workshops = _api.workshopsForWahl(widget.wahl.id).then((w) { + _workshopCache = w; + return w; + }); + }); void _reloadResults() => setState(() => _results = _api.zuteilungResults(widget.wahl.id)); + void _reloadTeilnehmer() => + setState(() => _teilnehmer = _api.wahlTeilnehmer(widget.wahl.id)); + + Future _toggleOpen(bool value) async { + final api = _api; + setState(() => _isOpen = value); + try { + await api.setWahlOpen(widget.wahl.id, value); + } catch (e) { + if (mounted) { + setState(() => _isOpen = !value); + toast(context, '$e'); + } + } + } + + Future _exportCsv() async { + final api = _api; + try { + final csv = await api.zuteilungCsv(widget.wahl.id); + browser.downloadText('zuteilung-${widget.wahl.name}.csv', csv); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } + + Future _forceFor(TeilnehmerRow t) async { + final api = _api; + final workshopId = await showDialog( + context: context, + builder: (_) => SimpleDialog( + title: Text('Zuteilung für ${t.name}'), + children: [ + for (final w in _workshopCache) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(w.id), + child: Text(w.name), + ), + ], + ), + ); + if (workshopId == null || !mounted) return; + try { + await api.forceZuteilung(widget.wahl.id, t.id, workshopId); + if (mounted) _reloadTeilnehmer(); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } Future _addWorkshop() async { final api = _api; @@ -142,6 +203,17 @@ class _WahlDetailScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ + Card( + child: SwitchListTile( + title: const Text('Wahl geöffnet'), + subtitle: Text(_isOpen + ? 'Konfis können Wünsche abgeben' + : 'Geschlossen — keine neuen Einreichungen'), + value: _isOpen, + onChanged: _toggleOpen, + ), + ), + const SizedBox(height: 8), SectionHeader('Workshops', action: TextButton.icon( onPressed: _addWorkshop, icon: const Icon(Icons.add), @@ -170,12 +242,47 @@ class _WahlDetailScreenState extends State { }, ), const Divider(height: 40), + SectionHeader('Teilnehmer:innen'), + const SizedBox(height: 4), + FutureBuilder>( + future: _teilnehmer, + 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 Einreichungen.'); + return Column( + children: [ + for (final t in rows) + ListTile( + dense: true, + leading: const Icon(Icons.person), + title: Text(t.name), + subtitle: Text('Wünsche: ${t.prioritaeten.length}' + '${t.forcedWorkshopId != null ? ' · fest zugeteilt' : ''}'), + trailing: TextButton( + onPressed: () => _forceFor(t), + child: const Text('Zuteilen'), + ), + ), + ], + ); + }, + ), + const Divider(height: 40), Row( children: [ Expanded( child: Text('Zuteilung', style: Theme.of(context).textTheme.titleMedium), ), + IconButton( + tooltip: 'CSV exportieren', + onPressed: _exportCsv, + icon: const Icon(Icons.download), + ), FilledButton.icon( onPressed: _running ? null : _run, icon: _running diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml index fc9abf1..70d1f7c 100644 --- a/client/app/pubspec.yaml +++ b/client/app/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: web_socket_channel: ^3.0.1 crypto: ^3.0.6 web: ^1.1.0 - dev_dependencies: flutter_test: sdk: flutter