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:
@@ -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<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),
|
||||
SectionHeader('Gemeinden', action: TextButton.icon(
|
||||
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 '../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