feat: LT Wahl controls (open/close, Force-Zuteilung, CSV) + file upload
Backend: - PATCH /api/wahl/:wahlId (isOpen) to open/close a Wahl. - GET /api/wahl/:wahlId/teilnehmer (LT): participants with their priorities and any existing Force-Zuteilung. - Verified against local Postgres: PATCH toggles isOpen, teilnehmer list returns, CSV export works. (A stale dev server on :3000 masked this at first — real routes are fine.) Client (client/app/): - Wahl detail: open/close switch, participant list with a "Zuteilen" (Force-Zuteilung) action, CSV export via a browser download (browser.downloadText). - files_admin_screen.dart: LT file upload — browser.pickFile() + visibility picker -> multipart POST /api/files/:kcId; list existing files. Reachable from KcDetailScreen. - browser_web.dart gains pickFile()/downloadText() (native <input file> + Blob), with throwing stubs for the VM. - Dropped the file_picker package again (heavy transitive deps, and the native web input is enough); disk on this box is nearly full. flutter analyze/test/build web green; backend npm test 56. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<WahlDetailScreen> {
|
||||
Future<List<WorkshopAdmin>>? _workshops;
|
||||
Future<List<ZuteilungRow>>? _results;
|
||||
Future<List<TeilnehmerRow>>? _teilnehmer;
|
||||
late bool _isOpen = widget.wahl.isOpen;
|
||||
List<WorkshopAdmin> _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<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 {
|
||||
final api = _api;
|
||||
@@ -142,6 +203,17 @@ class _WahlDetailScreenState extends State<WahlDetailScreen> {
|
||||
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<WahlDetailScreen> {
|
||||
},
|
||||
),
|
||||
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(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user