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>
394 lines
13 KiB
Dart
394 lines
13 KiB
Dart
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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|