Apporte aux compliance officers et controleurs internes l'accès mobile au tableau de bord de conformité backend (P1-NEW-7). Et prépare la diaspora avec Devise enum + sélecteur préférence persisté. Feature Compliance (Clean Architecture) - Domain : ComplianceSnapshot + ConformiteIndicateur (Equatable), helpers scoreSeverite + hasAlertesCritiques - Data : ComplianceSnapshotModel.fromJson (parsing tolerant aux nullables), ComplianceRemoteDataSourceImpl Dio (GET /api/compliance/dashboard), ComplianceRepositoryImpl @Injectable - Presentation : ComplianceBloc (Load/Refresh events, Initial/Loading/Loaded/Error states), ConformiteDashboardPage (Material 3, ScoreCard 0-100 colorée, 9 IndicateurTile, AlertesCard rouge si critiques) Feature Devise - Devise enum (10 valeurs miroirs backend, code/libelle/zone) - fromCode tolérant casse + null/vide → XOF - estInternationale pour AML - DeviseSelector widget DropdownButtonFormField + readPreferred/writePreferred via FlutterSecureStorage (clé unionflow.devise.preferee) Tests (17/17 verts) - ComplianceSnapshot : 7 tests (scoreSeverite × 3, hasAlertesCritiques × 4) - ComplianceSnapshotModel.fromJson : 4 tests (complet, fallbacks, string→double, indicateur invalide) - Devise enum : 6 tests (reference, fromCode parse, fromCode null/inconnu, estInternationale × 2, intégrité valeurs) Note : feature reporting trimestriel mobile (PDF viewer + bloc liste) reportée à un sprint dédié — nécessite intégration pdf viewer + cache local non triviale.
281 lines
8.7 KiB
Dart
281 lines
8.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import '../../../../core/di/injection.dart';
|
|
import '../../domain/entities/compliance_snapshot.dart';
|
|
import '../bloc/compliance_bloc.dart';
|
|
|
|
/// Page tableau de bord de conformité — affiche le snapshot agrégé pour
|
|
/// l'organisation active du membre connecté.
|
|
///
|
|
/// Cible : Compliance Officer, Contrôleur Interne, Président, Trésorier, Admin Org.
|
|
class ConformiteDashboardPage extends StatelessWidget {
|
|
const ConformiteDashboardPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider(
|
|
create: (_) => getIt<ComplianceBloc>()..add(const LoadComplianceSnapshot()),
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Conformité'),
|
|
actions: [
|
|
Builder(
|
|
builder: (ctx) => IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: () =>
|
|
ctx.read<ComplianceBloc>().add(const RefreshComplianceSnapshot()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: BlocBuilder<ComplianceBloc, ComplianceState>(
|
|
builder: (context, state) {
|
|
if (state is ComplianceLoading || state is ComplianceInitial) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (state is ComplianceError) {
|
|
return _Error(message: state.message);
|
|
}
|
|
if (state is ComplianceLoaded) {
|
|
return _Content(snapshot: state.snapshot);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Content extends StatelessWidget {
|
|
final ComplianceSnapshot snapshot;
|
|
const _Content({required this.snapshot});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_ScoreCard(snapshot: snapshot),
|
|
const SizedBox(height: 16),
|
|
_IndicateurTile(
|
|
label: 'Compliance Officer',
|
|
value: snapshot.complianceOfficerDesigne ? 'Désigné' : 'ABSENT',
|
|
severite: snapshot.complianceOfficerDesigne ? 'success' : 'danger',
|
|
),
|
|
_IndicateurTile(
|
|
label: 'AG annuelle',
|
|
value: snapshot.agAnnuelle.statut,
|
|
sous: snapshot.agAnnuelle.message,
|
|
severite: _statutSeverite(snapshot.agAnnuelle.statut),
|
|
),
|
|
_IndicateurTile(
|
|
label: 'Rapport AIRMS',
|
|
value: snapshot.rapportAirms.statut,
|
|
severite: _statutSeverite(snapshot.rapportAirms.statut),
|
|
),
|
|
_IndicateurTile(
|
|
label: 'Dirigeants enrôlés CMU',
|
|
value: snapshot.dirigeantsAvecCmu.toString(),
|
|
severite: snapshot.dirigeantsAvecCmu >= 3 ? 'success' : 'warning',
|
|
),
|
|
_IndicateurTile(
|
|
label: 'Taux KYC à jour',
|
|
value: '${snapshot.tauxKycAJourPct.toStringAsFixed(1)} %',
|
|
),
|
|
_IndicateurTile(
|
|
label: 'Taux formation LBC/FT',
|
|
value: '${snapshot.tauxFormationLbcFtPct.toStringAsFixed(1)} %',
|
|
),
|
|
_IndicateurTile(
|
|
label: 'Couverture UBO',
|
|
value: '${snapshot.couvertureUboPct.toStringAsFixed(1)} %',
|
|
),
|
|
_IndicateurTile(
|
|
label: 'Commissaire aux comptes',
|
|
value: snapshot.commissaireAuxComptes.statut,
|
|
sous: snapshot.commissaireAuxComptes.message,
|
|
),
|
|
_IndicateurTile(
|
|
label: 'FOMUS-CI',
|
|
value: snapshot.fomusCi.statut,
|
|
sous: snapshot.fomusCi.message,
|
|
),
|
|
if (snapshot.hasAlertesCritiques) ...[
|
|
const SizedBox(height: 16),
|
|
_AlertesCard(snapshot: snapshot),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _statutSeverite(String s) {
|
|
if (s == 'OK') return 'success';
|
|
if (s == 'RETARD') return 'danger';
|
|
return 'warning';
|
|
}
|
|
}
|
|
|
|
class _ScoreCard extends StatelessWidget {
|
|
final ComplianceSnapshot snapshot;
|
|
const _ScoreCard({required this.snapshot});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = _color(snapshot.scoreSeverite);
|
|
return Card(
|
|
elevation: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
children: [
|
|
Text(snapshot.organisationNom,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
textAlign: TextAlign.center),
|
|
const SizedBox(height: 4),
|
|
Text('Référentiel : ${snapshot.referentielComptable}',
|
|
style: Theme.of(context).textTheme.bodySmall),
|
|
const SizedBox(height: 16),
|
|
Text('Score conformité',
|
|
style: Theme.of(context).textTheme.bodyLarge),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${snapshot.scoreGlobal} / 100',
|
|
style: TextStyle(
|
|
fontSize: 48,
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _color(String s) => switch (s) {
|
|
'success' => Colors.green,
|
|
'warning' => Colors.orange,
|
|
'danger' => Colors.red,
|
|
_ => Colors.grey,
|
|
};
|
|
}
|
|
|
|
class _IndicateurTile extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final String? sous;
|
|
final String? severite;
|
|
const _IndicateurTile({
|
|
required this.label,
|
|
required this.value,
|
|
this.sous,
|
|
this.severite,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = severite != null ? _color(severite!) : Colors.grey;
|
|
return Card(
|
|
child: ListTile(
|
|
title: Text(label),
|
|
subtitle: sous != null ? Text(sous!, maxLines: 2, overflow: TextOverflow.ellipsis) : null,
|
|
trailing: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
value,
|
|
style: TextStyle(color: color, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _color(String s) => switch (s) {
|
|
'success' => Colors.green,
|
|
'warning' => Colors.orange,
|
|
'danger' => Colors.red,
|
|
_ => Colors.grey,
|
|
};
|
|
}
|
|
|
|
class _AlertesCard extends StatelessWidget {
|
|
final ComplianceSnapshot snapshot;
|
|
const _AlertesCard({required this.snapshot});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final alertes = <String>[];
|
|
if (!snapshot.complianceOfficerDesigne) {
|
|
alertes.add('Compliance Officer non désigné (Instr. BCEAO 001-03-2025)');
|
|
}
|
|
if (snapshot.agAnnuelle.statut == 'RETARD') {
|
|
alertes.add('AG annuelle en retard (échéance 30/06)');
|
|
}
|
|
if (snapshot.scoreGlobal < 60) {
|
|
alertes.add('Score conformité < 60 % — risque inspection');
|
|
}
|
|
|
|
return Card(
|
|
color: Colors.red.shade50,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
const Icon(Icons.warning, color: Colors.red),
|
|
const SizedBox(width: 8),
|
|
Text('Alertes critiques',
|
|
style: Theme.of(context).textTheme.titleMedium),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
for (final a in alertes)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Text('• $a', style: const TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Error extends StatelessWidget {
|
|
final String message;
|
|
const _Error({required this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Impossible de charger le tableau de bord',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(message,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|