diff --git a/app/lib/pages/jurist_review_page.dart b/app/lib/pages/jurist_review_page.dart new file mode 100644 index 0000000..beafd4a --- /dev/null +++ b/app/lib/pages/jurist_review_page.dart @@ -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 createState() => _JuristReviewPageState(); +} + +class _JuristReviewPageState extends State { + final _reviewer = TextEditingController(); + Future>? _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 _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 _askReason() { + final ctrl = TextEditingController(); + return showDialog( + 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>( + 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)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/pages/shell_page.dart b/app/lib/pages/shell_page.dart index 01aa278..c80e502 100644 --- a/app/lib/pages/shell_page.dart +++ b/app/lib/pages/shell_page.dart @@ -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 { final pages = [ 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 { 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),