Files
unionflow-mobile-apps/lib/features/compliance/presentation/pages/conformite_dashboard_page.dart
dahoud 8c1a254e80 feat(sprint-9 mobile 2026-04-25): feature compliance dashboard + Devise enum + selector + tests
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.
2026-04-25 11:11:03 +00:00

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),
],
),
),
);
}
}