feat(ui): jurist review page (Freigaben) — confirm/reject approvals

A new 'Freigaben' nav destination lists the hub's pending
system.approval tasks (listApprovals), shows each prompt + norm
payload, and lets a jurist confirm ('Korrekt — freigeben') or reject
with a reason via the SDK (approve/reject). The reviewer's name is
carried into the hub's hash-chained audit log. Review is optional and
live-only; the trusted-jurist registry + login is enforced hub-side
(chain handoff). Verified against the live hub: lists 3 pending.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-19 02:46:50 +02:00
parent 4e00c83591
commit b4461cca13
2 changed files with 401 additions and 0 deletions

View file

@ -0,0 +1,394 @@
import 'package:chain_client_sdk/chain_client_sdk.dart' as chain;
import 'package:flutter/material.dart';
import '../data/hub_repository.dart';
import '../data/repository.dart';
import '../theme/reclaim_tokens.dart';
import '../widgets/mode_banner.dart';
import '../widgets/reclaim_card.dart';
/// Jurist review the human-in-the-loop Freigabe. Lists the hub's
/// pending approvals (a flow's `system.approval` step posts one with
/// the norm + sources as payload); a jurist reads the context and
/// confirms ("Haken") or rejects. The decision lands in the hub's
/// hash-chained audit log under the reviewer's name — non-repudiable.
///
/// Review is OPTIONAL: the platform's figures stand on their own; a
/// jurist pass only *confirms* a topic. Login + a trusted-jurist
/// registry (who may decide) is enforced hub-side see the chain
/// handoff. Here the reviewer types their identity (the audit `by`).
class JuristReviewPage extends StatefulWidget {
const JuristReviewPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
State<JuristReviewPage> createState() => _JuristReviewPageState();
}
class _JuristReviewPageState extends State<JuristReviewPage> {
final _reviewer = TextEditingController();
Future<List<chain.PendingApprovalEntry>>? _future;
chain.HubClient? get _hub {
final r = widget.repository;
return r is HubRepository && r.isHealthy ? r.client : null;
}
@override
void initState() {
super.initState();
_reload();
}
void _reload() {
final hub = _hub;
setState(() {
_future = hub == null
? Future.value(const [])
: hub.listApprovals(statuses: const ['pending']);
});
}
@override
void dispose() {
_reviewer.dispose();
super.dispose();
}
Future<void> _decide(
chain.PendingApprovalEntry e, {
required bool approve,
}) async {
final hub = _hub;
if (hub == null) return;
final reviewer = _reviewer.text.trim();
if (reviewer.isEmpty) {
_snack('Bitte zuerst den Namen des prüfenden Juristen eintragen.');
return;
}
String? reason;
if (!approve) {
reason = await _askReason();
if (reason == null) return; // cancelled
}
try {
if (approve) {
await hub.approve(approvalId: e.approvalId, reviewer: reviewer);
} else {
await hub.reject(
approvalId: e.approvalId,
reviewer: reviewer,
reason: reason!.isEmpty ? 'ohne Angabe' : reason,
);
}
_snack(approve
? 'Freigegeben — als juristisch bestätigt protokolliert.'
: 'Abgelehnt — im Audit-Log vermerkt.');
_reload();
} catch (err) {
_snack('Fehler: $err');
}
}
Future<String?> _askReason() {
final ctrl = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ablehnung begründen'),
content: TextField(
controller: ctrl,
autofocus: true,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Warum ist die Auswertung nicht korrekt?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, ctrl.text.trim()),
child: const Text('Ablehnen'),
),
],
),
);
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Column(
children: [
ModeBanner(repository: widget.repository),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(ReclaimSpace.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Juristische Freigaben',
style: t.displaySmall),
const SizedBox(height: ReclaimSpace.xs),
Text(
'Offene Review-Aufgaben aus dem Hub. Prüfen, '
'ob die Auswertung mit dem Gesetzestext '
'übereinstimmt, dann Haken setzen oder ablehnen.',
style: t.bodyLarge
?.copyWith(color: ReclaimColors.mute),
),
],
),
),
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Aktualisieren',
),
],
),
const SizedBox(height: ReclaimSpace.lg),
_ReviewerField(controller: _reviewer),
const SizedBox(height: ReclaimSpace.lg),
if (_hub == null)
const _HintCard(
icon: Icons.hub_outlined,
title: 'Freigaben brauchen den Live-Hub',
body: 'Juristen-Reviews kommen aus laufenden Flows. '
'Im Hub-Reiter den Live-Modus aktivieren — dann '
'erscheinen hier die offenen system.approval-'
'Aufgaben.',
)
else
FutureBuilder<List<chain.PendingApprovalEntry>>(
future: _future,
builder: (context, snap) {
if (!snap.hasData) {
return const Padding(
padding: EdgeInsets.all(ReclaimSpace.xl),
child:
Center(child: CircularProgressIndicator()),
);
}
final items = snap.data!;
if (items.isEmpty) {
return const _HintCard(
icon: Icons.inbox_outlined,
title: 'Keine offenen Freigaben',
body: 'Aktuell wartet keine Auswertung auf eine '
'juristische Bestätigung.',
);
}
return Column(
children: [
for (final e in items)
Padding(
padding: const EdgeInsets.only(
bottom: ReclaimSpace.md),
child: _ApprovalCard(
entry: e,
onApprove: () =>
_decide(e, approve: true),
onReject: () =>
_decide(e, approve: false),
),
),
],
);
},
),
],
),
),
),
],
);
}
}
class _ReviewerField extends StatelessWidget {
const _ReviewerField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ReclaimCard(
child: Row(
children: [
const Icon(Icons.badge_outlined, color: ReclaimColors.signal),
const SizedBox(width: ReclaimSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Prüfender Jurist',
style: t.labelSmall
?.copyWith(color: ReclaimColors.mute)),
TextField(
controller: controller,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Name / Kanzlei — wird im Audit-Log vermerkt',
),
),
],
),
),
const Tooltip(
message: 'Die Liste vertrauenswürdiger Juristen + Login '
'erzwingt der Hub (chain). Hier wird der Name als '
'Audit-Unterschrift mitgeführt.',
child: Icon(Icons.info_outline,
size: 18, color: ReclaimColors.mute),
),
],
),
);
}
}
class _ApprovalCard extends StatelessWidget {
const _ApprovalCard({
required this.entry,
required this.onApprove,
required this.onReject,
});
final chain.PendingApprovalEntry entry;
final VoidCallback onApprove;
final VoidCallback onReject;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final prompt = entry.prompt.isNotEmpty
? entry.prompt
: 'Freigabe für ${entry.flowName}';
return ReclaimCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.gavel_outlined,
size: 18, color: ReclaimColors.signal),
const SizedBox(width: ReclaimSpace.sm),
Expanded(child: Text(prompt, style: t.titleMedium)),
],
),
const SizedBox(height: 4),
Text(
'${entry.flowName} · ${entry.stepId}'
'${entry.createdAt.isNotEmpty ? ' · ${entry.createdAt}' : ''}',
style: t.labelSmall?.copyWith(
fontFamily: ReclaimTypography.mono,
color: ReclaimColors.mute,
),
),
if (entry.payloadPreview.isNotEmpty) ...[
const SizedBox(height: ReclaimSpace.md),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 180),
padding: const EdgeInsets.all(ReclaimSpace.md),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.12),
),
borderRadius: const BorderRadius.all(ReclaimRadius.sm),
),
child: SingleChildScrollView(
child: SelectableText(
entry.payloadPreview,
style: t.labelSmall?.copyWith(
fontFamily: ReclaimTypography.mono,
height: 1.4,
),
),
),
),
],
const SizedBox(height: ReclaimSpace.md),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close, size: 18),
label: const Text('Ablehnen'),
style: TextButton.styleFrom(
foregroundColor: ReclaimColors.harmWarm,
),
),
const SizedBox(width: ReclaimSpace.sm),
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check, size: 18),
label: const Text('Korrekt — freigeben'),
),
],
),
],
),
);
}
}
class _HintCard extends StatelessWidget {
const _HintCard({
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ReclaimCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: ReclaimColors.mute),
const SizedBox(width: ReclaimSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: t.titleMedium),
const SizedBox(height: 4),
Text(body,
style: t.bodyLarge
?.copyWith(color: ReclaimColors.mute, height: 1.4)),
],
),
),
],
),
);
}
}

View file

@ -7,6 +7,7 @@ import '../widgets/delta_mark.dart';
import 'about_page.dart';
import 'heatmap_page.dart';
import 'hub_status_page.dart';
import 'jurist_review_page.dart';
import 'legal_page.dart';
import 'norms_list_page.dart';
@ -30,6 +31,7 @@ class _ShellPageState extends State<ShellPage> {
final pages = <Widget>[
HeatmapPage(repository: widget.repository),
NormsListPage(repository: widget.repository),
JuristReviewPage(repository: widget.repository),
HubStatusPage(repository: widget.repository),
const AboutPage(),
const LegalPage(),
@ -78,6 +80,11 @@ class _ShellPageState extends State<ShellPage> {
selectedIcon: Icon(Icons.list_alt),
label: Text('Normen'),
),
NavigationRailDestination(
icon: Icon(Icons.fact_check_outlined),
selectedIcon: Icon(Icons.fact_check),
label: Text('Freigaben'),
),
NavigationRailDestination(
icon: Icon(Icons.hub_outlined),
selectedIcon: Icon(Icons.hub),