feat: client monorepo (Flutter app) + web fallback redesign #1
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateWahlDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isOpen?: boolean;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -14,6 +15,7 @@ import { Response } from 'express';
|
|||||||
import { WahlService } from './wahl.service';
|
import { WahlService } from './wahl.service';
|
||||||
import { ZuteilungService } from './zuteilung.service';
|
import { ZuteilungService } from './zuteilung.service';
|
||||||
import { CreateWahlDto } from './dto/create-wahl.dto';
|
import { CreateWahlDto } from './dto/create-wahl.dto';
|
||||||
|
import { UpdateWahlDto } from './dto/update-wahl.dto';
|
||||||
import { CreateWorkshopDto } from './dto/create-workshop.dto';
|
import { CreateWorkshopDto } from './dto/create-workshop.dto';
|
||||||
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
|
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
|
||||||
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
|
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
|
||||||
@@ -45,6 +47,20 @@ export class WahlController {
|
|||||||
return this.wahl.listWahlen(kcId);
|
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')
|
@Post(':wahlId/workshops')
|
||||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
|||||||
@@ -22,6 +22,33 @@ export class WahlService {
|
|||||||
return this.prisma.wahl.findMany({ where: { kcId } });
|
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
|
/// 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).
|
/// workshops and the guest's own current priorities (null if not submitted).
|
||||||
async guestOverview(kcId: string, guestAccountId: string) {
|
async guestOverview(kcId: string, guestAccountId: string) {
|
||||||
|
|||||||
@@ -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<String> prioritaeten;
|
||||||
|
final String? forcedWorkshopId;
|
||||||
|
factory TeilnehmerRow.fromJson(Map<String, dynamic> j) => TeilnehmerRow(
|
||||||
|
id: j['id'] as String,
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
prioritaeten:
|
||||||
|
(j['prioritaeten'] as List<dynamic>? ?? []).map((e) => e as String).toList(),
|
||||||
|
forcedWorkshopId: j['forcedWorkshopId'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class ZuteilungRow {
|
class ZuteilungRow {
|
||||||
ZuteilungRow({
|
ZuteilungRow({
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -393,6 +413,15 @@ class Api {
|
|||||||
return _decode(res);
|
return _decode(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _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) {
|
dynamic _decode(http.Response res) {
|
||||||
final text = res.body.isEmpty ? '{}' : res.body;
|
final text = res.body.isEmpty ? '{}' : res.body;
|
||||||
dynamic parsed;
|
dynamic parsed;
|
||||||
@@ -547,6 +576,42 @@ class Api {
|
|||||||
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
|
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setWahlOpen(String wahlId, bool isOpen) =>
|
||||||
|
_patch('/wahl/$wahlId', {'isOpen': isOpen});
|
||||||
|
|
||||||
|
Future<List<TeilnehmerRow>> wahlTeilnehmer(String wahlId) async {
|
||||||
|
final list = await _get('/wahl/$wahlId/teilnehmer') as List<dynamic>;
|
||||||
|
return list.map((e) => TeilnehmerRow.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> forceZuteilung(String wahlId, String teilnehmerId, String workshopId) =>
|
||||||
|
_post('/wahl/$wahlId/force-zuteilung', {
|
||||||
|
'teilnehmerId': teilnehmerId,
|
||||||
|
'workshopId': workshopId,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<String> zuteilungCsv(String wahlId) async {
|
||||||
|
final res = await _get('/wahl/$wahlId/zuteilung/csv');
|
||||||
|
return res is String ? res : res.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> uploadFile(
|
||||||
|
String kcId,
|
||||||
|
String filename,
|
||||||
|
List<int> 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) ---
|
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
|
||||||
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
|
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
|
||||||
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
|
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ void removeSession(String key) => throw UnsupportedError(_msg);
|
|||||||
Never redirect(String url) => throw UnsupportedError(_msg);
|
Never redirect(String url) => throw UnsupportedError(_msg);
|
||||||
Map<String, String> currentQueryParameters() => const {};
|
Map<String, String> currentQueryParameters() => const {};
|
||||||
void clearQuery() {}
|
void clearQuery() {}
|
||||||
|
|
||||||
|
Future<({String name, List<int> bytes})?> pickFile() async =>
|
||||||
|
throw UnsupportedError(_msg);
|
||||||
|
void downloadText(String filename, String content, {String mime = 'text/plain'}) =>
|
||||||
|
throw UnsupportedError(_msg);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
|
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
|
||||||
@@ -20,3 +23,41 @@ Map<String, String> currentQueryParameters() =>
|
|||||||
void clearQuery() {
|
void clearQuery() {
|
||||||
web.window.history.replaceState(null, '', '/');
|
web.window.history.replaceState(null, '', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the OS file picker and reads the chosen file's bytes.
|
||||||
|
Future<({String name, List<int> bytes})?> pickFile() {
|
||||||
|
final completer = Completer<({String name, List<int> 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
|
import 'files_admin_screen.dart';
|
||||||
import 'teamer_admin_screen.dart';
|
import 'teamer_admin_screen.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
import 'wahl_admin_screen.dart';
|
import 'wahl_admin_screen.dart';
|
||||||
@@ -146,6 +147,16 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
SectionHeader('Gemeinden', action: TextButton.icon(
|
SectionHeader('Gemeinden', action: TextButton.icon(
|
||||||
onPressed: _addGemeinde,
|
onPressed: _addGemeinde,
|
||||||
|
|||||||
@@ -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<FilesAdminScreen> createState() => _FilesAdminScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilesAdminScreenState extends State<FilesAdminScreen> {
|
||||||
|
Future<List<FileEntry>>? _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<void> _upload() async {
|
||||||
|
final api = _api;
|
||||||
|
final picked = await browser.pickFile();
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
final visibility = await showDialog<String>(
|
||||||
|
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<List<FileEntry>>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
|
import '../browser.dart' as browser;
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import 'ui.dart';
|
import 'ui.dart';
|
||||||
|
|
||||||
@@ -92,20 +93,80 @@ class WahlDetailScreen extends StatefulWidget {
|
|||||||
class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
||||||
Future<List<WorkshopAdmin>>? _workshops;
|
Future<List<WorkshopAdmin>>? _workshops;
|
||||||
Future<List<ZuteilungRow>>? _results;
|
Future<List<ZuteilungRow>>? _results;
|
||||||
|
Future<List<TeilnehmerRow>>? _teilnehmer;
|
||||||
|
late bool _isOpen = widget.wahl.isOpen;
|
||||||
|
List<WorkshopAdmin> _workshopCache = const [];
|
||||||
bool _running = false;
|
bool _running = false;
|
||||||
Api get _api => AppScope.of(context).api;
|
Api get _api => AppScope.of(context).api;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.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);
|
_results ??= _api.zuteilungResults(widget.wahl.id);
|
||||||
|
_teilnehmer ??= _api.wahlTeilnehmer(widget.wahl.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reloadWorkshops() =>
|
void _reloadWorkshops() => setState(() {
|
||||||
setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id));
|
_workshops = _api.workshopsForWahl(widget.wahl.id).then((w) {
|
||||||
|
_workshopCache = w;
|
||||||
|
return w;
|
||||||
|
});
|
||||||
|
});
|
||||||
void _reloadResults() =>
|
void _reloadResults() =>
|
||||||
setState(() => _results = _api.zuteilungResults(widget.wahl.id));
|
setState(() => _results = _api.zuteilungResults(widget.wahl.id));
|
||||||
|
void _reloadTeilnehmer() =>
|
||||||
|
setState(() => _teilnehmer = _api.wahlTeilnehmer(widget.wahl.id));
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _forceFor(TeilnehmerRow t) async {
|
||||||
|
final api = _api;
|
||||||
|
final workshopId = await showDialog<String>(
|
||||||
|
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<void> _addWorkshop() async {
|
Future<void> _addWorkshop() async {
|
||||||
final api = _api;
|
final api = _api;
|
||||||
@@ -142,6 +203,17 @@ class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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(
|
SectionHeader('Workshops', action: TextButton.icon(
|
||||||
onPressed: _addWorkshop,
|
onPressed: _addWorkshop,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
@@ -170,12 +242,47 @@ class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 40),
|
const Divider(height: 40),
|
||||||
|
SectionHeader('Teilnehmer:innen'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
FutureBuilder<List<TeilnehmerRow>>(
|
||||||
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text('Zuteilung',
|
child: Text('Zuteilung',
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'CSV exportieren',
|
||||||
|
onPressed: _exportCsv,
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _running ? null : _run,
|
onPressed: _running ? null : _run,
|
||||||
icon: _running
|
icon: _running
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ dependencies:
|
|||||||
web_socket_channel: ^3.0.1
|
web_socket_channel: ^3.0.1
|
||||||
crypto: ^3.0.6
|
crypto: ^3.0.6
|
||||||
web: ^1.1.0
|
web: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
Reference in New Issue
Block a user