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:
parent
4e00c83591
commit
b4461cca13
2 changed files with 401 additions and 0 deletions
394
app/lib/pages/jurist_review_page.dart
Normal file
394
app/lib/pages/jurist_review_page.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue