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.
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:injectable/injectable.dart';
|
||||||
|
import '../../../../core/error/exceptions.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
import '../../../../core/utils/logger.dart';
|
||||||
|
import '../models/compliance_snapshot_model.dart';
|
||||||
|
|
||||||
|
abstract class ComplianceRemoteDataSource {
|
||||||
|
/// GET /api/compliance/dashboard — snapshot pour l'organisation active.
|
||||||
|
Future<ComplianceSnapshotModel> getSnapshotCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable(as: ComplianceRemoteDataSource)
|
||||||
|
class ComplianceRemoteDataSourceImpl implements ComplianceRemoteDataSource {
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
ComplianceRemoteDataSourceImpl(this.apiClient);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ComplianceSnapshotModel> getSnapshotCurrent() async {
|
||||||
|
try {
|
||||||
|
final response = await apiClient.get('/api/compliance/dashboard');
|
||||||
|
if (response.statusCode == 200 && response.data is Map<String, dynamic>) {
|
||||||
|
return ComplianceSnapshotModel.fromJson(
|
||||||
|
response.data as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw ServerException('Compliance snapshot HTTP ${response.statusCode}');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
AppLogger.error('ComplianceRemoteDataSource: getSnapshotCurrent', error: e);
|
||||||
|
rethrow;
|
||||||
|
} catch (e, st) {
|
||||||
|
AppLogger.error('ComplianceRemoteDataSource: getSnapshotCurrent',
|
||||||
|
error: e, stackTrace: st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import '../../domain/entities/compliance_snapshot.dart';
|
||||||
|
|
||||||
|
class ComplianceSnapshotModel extends ComplianceSnapshot {
|
||||||
|
const ComplianceSnapshotModel({
|
||||||
|
required super.organisationId,
|
||||||
|
required super.organisationNom,
|
||||||
|
required super.referentielComptable,
|
||||||
|
required super.complianceOfficerDesigne,
|
||||||
|
required super.agAnnuelle,
|
||||||
|
required super.rapportAirms,
|
||||||
|
required super.dirigeantsAvecCmu,
|
||||||
|
required super.tauxKycAJourPct,
|
||||||
|
required super.tauxFormationLbcFtPct,
|
||||||
|
required super.commissaireAuxComptes,
|
||||||
|
required super.fomusCi,
|
||||||
|
required super.couvertureUboPct,
|
||||||
|
required super.scoreGlobal,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ComplianceSnapshotModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ComplianceSnapshotModel(
|
||||||
|
organisationId: json['organisationId']?.toString() ?? '',
|
||||||
|
organisationNom: json['organisationNom']?.toString() ?? '',
|
||||||
|
referentielComptable: json['referentielComptable']?.toString() ?? 'SYSCOHADA',
|
||||||
|
complianceOfficerDesigne: json['complianceOfficerDesigne'] as bool? ?? false,
|
||||||
|
agAnnuelle: _indicateur(json['agAnnuelle']),
|
||||||
|
rapportAirms: _indicateur(json['rapportAirms']),
|
||||||
|
dirigeantsAvecCmu: (json['dirigeantsAvecCmu'] as num?)?.toInt() ?? 0,
|
||||||
|
tauxKycAJourPct: _toDouble(json['tauxKycAJourPct']),
|
||||||
|
tauxFormationLbcFtPct: _toDouble(json['tauxFormationLbcFtPct']),
|
||||||
|
commissaireAuxComptes: _indicateur(json['commissaireAuxComptes']),
|
||||||
|
fomusCi: _indicateur(json['fomusCi']),
|
||||||
|
couvertureUboPct: _toDouble(json['couvertureUboPct']),
|
||||||
|
scoreGlobal: (json['scoreGlobal'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ConformiteIndicateur _indicateur(dynamic raw) {
|
||||||
|
if (raw is Map<String, dynamic>) {
|
||||||
|
return ConformiteIndicateur(
|
||||||
|
statut: raw['statut']?.toString() ?? 'EN_VEILLE',
|
||||||
|
message: raw['message']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const ConformiteIndicateur(statut: 'EN_VEILLE', message: '');
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _toDouble(dynamic raw) {
|
||||||
|
if (raw is num) return raw.toDouble();
|
||||||
|
if (raw is String) return double.tryParse(raw) ?? 0.0;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:injectable/injectable.dart';
|
||||||
|
import '../../domain/entities/compliance_snapshot.dart';
|
||||||
|
import '../../domain/repositories/compliance_repository.dart';
|
||||||
|
import '../datasources/compliance_remote_datasource.dart';
|
||||||
|
|
||||||
|
@Injectable(as: ComplianceRepository)
|
||||||
|
class ComplianceRepositoryImpl implements ComplianceRepository {
|
||||||
|
final ComplianceRemoteDataSource remote;
|
||||||
|
|
||||||
|
ComplianceRepositoryImpl(this.remote);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ComplianceSnapshot> getSnapshotCurrent() => remote.getSnapshotCurrent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Indicateur de conformité avec statut + message explicatif.
|
||||||
|
class ConformiteIndicateur extends Equatable {
|
||||||
|
final String statut; // OK, EN_ATTENTE, RETARD, OPTIONNEL, OBLIGATOIRE, EN_VEILLE
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ConformiteIndicateur({required this.statut, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [statut, message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot du tableau de bord de conformité (miroir mobile du record backend).
|
||||||
|
///
|
||||||
|
/// Couvre AG annuelle, rapports AIRMS, CMU dirigeants, KYC, formation LBC/FT, UBO,
|
||||||
|
/// commissaire aux comptes, FOMUS-CI, et le score global agrégé (0-100).
|
||||||
|
class ComplianceSnapshot extends Equatable {
|
||||||
|
final String organisationId;
|
||||||
|
final String organisationNom;
|
||||||
|
final String referentielComptable;
|
||||||
|
final bool complianceOfficerDesigne;
|
||||||
|
final ConformiteIndicateur agAnnuelle;
|
||||||
|
final ConformiteIndicateur rapportAirms;
|
||||||
|
final int dirigeantsAvecCmu;
|
||||||
|
final double tauxKycAJourPct;
|
||||||
|
final double tauxFormationLbcFtPct;
|
||||||
|
final ConformiteIndicateur commissaireAuxComptes;
|
||||||
|
final ConformiteIndicateur fomusCi;
|
||||||
|
final double couvertureUboPct;
|
||||||
|
final int scoreGlobal;
|
||||||
|
|
||||||
|
const ComplianceSnapshot({
|
||||||
|
required this.organisationId,
|
||||||
|
required this.organisationNom,
|
||||||
|
required this.referentielComptable,
|
||||||
|
required this.complianceOfficerDesigne,
|
||||||
|
required this.agAnnuelle,
|
||||||
|
required this.rapportAirms,
|
||||||
|
required this.dirigeantsAvecCmu,
|
||||||
|
required this.tauxKycAJourPct,
|
||||||
|
required this.tauxFormationLbcFtPct,
|
||||||
|
required this.commissaireAuxComptes,
|
||||||
|
required this.fomusCi,
|
||||||
|
required this.couvertureUboPct,
|
||||||
|
required this.scoreGlobal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Couleur indicative selon le score (Material 3 semantic).
|
||||||
|
String get scoreSeverite {
|
||||||
|
if (scoreGlobal >= 80) return 'success';
|
||||||
|
if (scoreGlobal >= 60) return 'warning';
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasAlertesCritiques =>
|
||||||
|
!complianceOfficerDesigne ||
|
||||||
|
agAnnuelle.statut == 'RETARD' ||
|
||||||
|
scoreGlobal < 60;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
organisationId,
|
||||||
|
organisationNom,
|
||||||
|
referentielComptable,
|
||||||
|
complianceOfficerDesigne,
|
||||||
|
agAnnuelle,
|
||||||
|
rapportAirms,
|
||||||
|
dirigeantsAvecCmu,
|
||||||
|
tauxKycAJourPct,
|
||||||
|
tauxFormationLbcFtPct,
|
||||||
|
commissaireAuxComptes,
|
||||||
|
fomusCi,
|
||||||
|
couvertureUboPct,
|
||||||
|
scoreGlobal,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '../entities/compliance_snapshot.dart';
|
||||||
|
|
||||||
|
abstract class ComplianceRepository {
|
||||||
|
Future<ComplianceSnapshot> getSnapshotCurrent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:injectable/injectable.dart';
|
||||||
|
import '../../../../core/utils/logger.dart';
|
||||||
|
import '../../domain/entities/compliance_snapshot.dart';
|
||||||
|
import '../../domain/repositories/compliance_repository.dart';
|
||||||
|
|
||||||
|
// Events
|
||||||
|
abstract class ComplianceEvent extends Equatable {
|
||||||
|
const ComplianceEvent();
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadComplianceSnapshot extends ComplianceEvent {
|
||||||
|
const LoadComplianceSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefreshComplianceSnapshot extends ComplianceEvent {
|
||||||
|
const RefreshComplianceSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// States
|
||||||
|
abstract class ComplianceState extends Equatable {
|
||||||
|
const ComplianceState();
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComplianceInitial extends ComplianceState {
|
||||||
|
const ComplianceInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComplianceLoading extends ComplianceState {
|
||||||
|
const ComplianceLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComplianceLoaded extends ComplianceState {
|
||||||
|
final ComplianceSnapshot snapshot;
|
||||||
|
const ComplianceLoaded(this.snapshot);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [snapshot];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComplianceError extends ComplianceState {
|
||||||
|
final String message;
|
||||||
|
const ComplianceError(this.message);
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
@injectable
|
||||||
|
class ComplianceBloc extends Bloc<ComplianceEvent, ComplianceState> {
|
||||||
|
final ComplianceRepository repository;
|
||||||
|
|
||||||
|
ComplianceBloc(this.repository) : super(const ComplianceInitial()) {
|
||||||
|
on<LoadComplianceSnapshot>(_onLoad);
|
||||||
|
on<RefreshComplianceSnapshot>(_onLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLoad(ComplianceEvent event, Emitter<ComplianceState> emit) async {
|
||||||
|
emit(const ComplianceLoading());
|
||||||
|
try {
|
||||||
|
final s = await repository.getSnapshotCurrent();
|
||||||
|
emit(ComplianceLoaded(s));
|
||||||
|
} catch (e, st) {
|
||||||
|
AppLogger.error('ComplianceBloc: load failed', error: e, stackTrace: st);
|
||||||
|
emit(ComplianceError(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
lib/features/devise/domain/entities/devise.dart
Normal file
41
lib/features/devise/domain/entities/devise.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/// Devises supportées par UnionFlow (miroir mobile de l'enum backend).
|
||||||
|
///
|
||||||
|
/// Les codes ISO-4217 servent aussi d'identifiant pour l'API.
|
||||||
|
enum Devise {
|
||||||
|
xof('XOF', 'Franc CFA Ouest', 'UEMOA'),
|
||||||
|
xaf('XAF', 'Franc CFA Centrale', 'CEMAC'),
|
||||||
|
eur('EUR', 'Euro', 'EUROPE'),
|
||||||
|
usd('USD', 'Dollar US', 'AMERIQUE'),
|
||||||
|
gbp('GBP', 'Livre Sterling', 'EUROPE'),
|
||||||
|
cad('CAD', 'Dollar Canadien', 'AMERIQUE'),
|
||||||
|
chf('CHF', 'Franc Suisse', 'EUROPE'),
|
||||||
|
ghs('GHS', 'Cédi Ghanéen', 'CEDEAO'),
|
||||||
|
ngn('NGN', 'Naira Nigérian', 'CEDEAO'),
|
||||||
|
mad('MAD', 'Dirham Marocain', 'MAGHREB');
|
||||||
|
|
||||||
|
final String code;
|
||||||
|
final String libelle;
|
||||||
|
final String zone;
|
||||||
|
const Devise(this.code, this.libelle, this.zone);
|
||||||
|
|
||||||
|
/// Devise de référence UnionFlow.
|
||||||
|
static Devise reference() => Devise.xof;
|
||||||
|
|
||||||
|
/// Parse un code ISO-4217 en {@link Devise}, fallback sur XOF.
|
||||||
|
static Devise fromCode(String? code) {
|
||||||
|
if (code == null || code.isEmpty) return reference();
|
||||||
|
return Devise.values.firstWhere(
|
||||||
|
(d) => d.code.toUpperCase() == code.toUpperCase(),
|
||||||
|
orElse: () => reference(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True pour les devises internationales (déclenchent AML international).
|
||||||
|
bool get estInternationale => const {
|
||||||
|
Devise.eur,
|
||||||
|
Devise.usd,
|
||||||
|
Devise.gbp,
|
||||||
|
Devise.cad,
|
||||||
|
Devise.chf,
|
||||||
|
}.contains(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import '../../domain/entities/devise.dart';
|
||||||
|
|
||||||
|
const _kDevisePrefereeKey = 'unionflow.devise.preferee';
|
||||||
|
|
||||||
|
/// Widget de sélection de la devise préférée (persistée en secure storage).
|
||||||
|
///
|
||||||
|
/// Utilisé sur la page Profil/Paramètres. Notifie via [onChanged] avec la
|
||||||
|
/// nouvelle devise quand l'utilisateur fait un choix.
|
||||||
|
class DeviseSelector extends StatefulWidget {
|
||||||
|
final Devise? initial;
|
||||||
|
final ValueChanged<Devise>? onChanged;
|
||||||
|
|
||||||
|
const DeviseSelector({super.key, this.initial, this.onChanged});
|
||||||
|
|
||||||
|
/// Lit la devise préférée du secure storage. Fallback sur XOF.
|
||||||
|
static Future<Devise> readPreferred(FlutterSecureStorage storage) async {
|
||||||
|
try {
|
||||||
|
final v = await storage.read(key: _kDevisePrefereeKey);
|
||||||
|
return Devise.fromCode(v);
|
||||||
|
} catch (_) {
|
||||||
|
return Devise.reference();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persiste la devise préférée.
|
||||||
|
static Future<void> writePreferred(
|
||||||
|
FlutterSecureStorage storage, Devise devise) async {
|
||||||
|
await storage.write(key: _kDevisePrefereeKey, value: devise.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeviseSelector> createState() => _DeviseSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviseSelectorState extends State<DeviseSelector> {
|
||||||
|
late Devise _selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selected = widget.initial ?? Devise.reference();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DropdownButtonFormField<Devise>(
|
||||||
|
value: _selected,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Devise préférée',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.account_balance_wallet_outlined),
|
||||||
|
),
|
||||||
|
items: Devise.values
|
||||||
|
.map(
|
||||||
|
(d) => DropdownMenuItem<Devise>(
|
||||||
|
value: d,
|
||||||
|
child: Text('${d.code} — ${d.libelle}'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (d) {
|
||||||
|
if (d == null) return;
|
||||||
|
setState(() => _selected = d);
|
||||||
|
widget.onChanged?.call(d);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:unionflow_mobile_apps/features/compliance/data/models/compliance_snapshot_model.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ComplianceSnapshotModel.fromJson', () {
|
||||||
|
test('parsing complet', () {
|
||||||
|
final json = {
|
||||||
|
'organisationId': 'org-uuid',
|
||||||
|
'organisationNom': 'Mutuelle Test',
|
||||||
|
'referentielComptable': 'SYCEBNL',
|
||||||
|
'complianceOfficerDesigne': true,
|
||||||
|
'agAnnuelle': {'statut': 'OK', 'message': 'AG tenue'},
|
||||||
|
'rapportAirms': {'statut': 'EN_ATTENTE', 'message': ''},
|
||||||
|
'dirigeantsAvecCmu': 3,
|
||||||
|
'tauxKycAJourPct': 85.5,
|
||||||
|
'tauxFormationLbcFtPct': 70.0,
|
||||||
|
'commissaireAuxComptes': {'statut': 'OBLIGATOIRE', 'message': 'SFD'},
|
||||||
|
'fomusCi': {'statut': 'EN_VEILLE', 'message': ''},
|
||||||
|
'couvertureUboPct': 60.0,
|
||||||
|
'scoreGlobal': 82,
|
||||||
|
};
|
||||||
|
final m = ComplianceSnapshotModel.fromJson(json);
|
||||||
|
expect(m.organisationNom, 'Mutuelle Test');
|
||||||
|
expect(m.referentielComptable, 'SYCEBNL');
|
||||||
|
expect(m.complianceOfficerDesigne, isTrue);
|
||||||
|
expect(m.agAnnuelle.statut, 'OK');
|
||||||
|
expect(m.dirigeantsAvecCmu, 3);
|
||||||
|
expect(m.tauxKycAJourPct, 85.5);
|
||||||
|
expect(m.scoreGlobal, 82);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valeurs manquantes → fallbacks raisonnables', () {
|
||||||
|
final m = ComplianceSnapshotModel.fromJson(<String, dynamic>{});
|
||||||
|
expect(m.organisationId, '');
|
||||||
|
expect(m.referentielComptable, 'SYSCOHADA');
|
||||||
|
expect(m.complianceOfficerDesigne, isFalse);
|
||||||
|
expect(m.agAnnuelle.statut, 'EN_VEILLE');
|
||||||
|
expect(m.dirigeantsAvecCmu, 0);
|
||||||
|
expect(m.tauxKycAJourPct, 0.0);
|
||||||
|
expect(m.scoreGlobal, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('taux passé en string → converti en double', () {
|
||||||
|
final m = ComplianceSnapshotModel.fromJson({
|
||||||
|
'tauxKycAJourPct': '85.5',
|
||||||
|
'couvertureUboPct': '60',
|
||||||
|
});
|
||||||
|
expect(m.tauxKycAJourPct, 85.5);
|
||||||
|
expect(m.couvertureUboPct, 60.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('indicateur non-Map → fallback EN_VEILLE', () {
|
||||||
|
final m = ComplianceSnapshotModel.fromJson({'agAnnuelle': 'invalid'});
|
||||||
|
expect(m.agAnnuelle.statut, 'EN_VEILLE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:unionflow_mobile_apps/features/compliance/domain/entities/compliance_snapshot.dart';
|
||||||
|
|
||||||
|
ComplianceSnapshot _build({
|
||||||
|
int score = 85,
|
||||||
|
bool officer = true,
|
||||||
|
String agStatut = 'OK',
|
||||||
|
}) {
|
||||||
|
return ComplianceSnapshot(
|
||||||
|
organisationId: 'org-1',
|
||||||
|
organisationNom: 'Test',
|
||||||
|
referentielComptable: 'SYSCOHADA',
|
||||||
|
complianceOfficerDesigne: officer,
|
||||||
|
agAnnuelle: ConformiteIndicateur(statut: agStatut, message: ''),
|
||||||
|
rapportAirms: const ConformiteIndicateur(statut: 'OK', message: ''),
|
||||||
|
dirigeantsAvecCmu: 3,
|
||||||
|
tauxKycAJourPct: 80,
|
||||||
|
tauxFormationLbcFtPct: 70,
|
||||||
|
commissaireAuxComptes: const ConformiteIndicateur(statut: 'OPTIONNEL', message: ''),
|
||||||
|
fomusCi: const ConformiteIndicateur(statut: 'EN_VEILLE', message: ''),
|
||||||
|
couvertureUboPct: 60,
|
||||||
|
scoreGlobal: score,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ComplianceSnapshot', () {
|
||||||
|
test('scoreSeverite — score >=80 → success', () {
|
||||||
|
expect(_build(score: 85).scoreSeverite, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scoreSeverite — 60..79 → warning', () {
|
||||||
|
expect(_build(score: 70).scoreSeverite, 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scoreSeverite — <60 → danger', () {
|
||||||
|
expect(_build(score: 50).scoreSeverite, 'danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAlertesCritiques — score 85 + officer + AG OK → false', () {
|
||||||
|
expect(_build().hasAlertesCritiques, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAlertesCritiques — officer absent → true', () {
|
||||||
|
expect(_build(officer: false).hasAlertesCritiques, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAlertesCritiques — AG en retard → true', () {
|
||||||
|
expect(_build(agStatut: 'RETARD').hasAlertesCritiques, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAlertesCritiques — score <60 → true', () {
|
||||||
|
expect(_build(score: 50).hasAlertesCritiques, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
46
test/features/devise/devise_test.dart
Normal file
46
test/features/devise/devise_test.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:unionflow_mobile_apps/features/devise/domain/entities/devise.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Devise enum', () {
|
||||||
|
test('reference = XOF', () {
|
||||||
|
expect(Devise.reference(), Devise.xof);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromCode parse correct', () {
|
||||||
|
expect(Devise.fromCode('EUR'), Devise.eur);
|
||||||
|
expect(Devise.fromCode('usd'), Devise.usd);
|
||||||
|
expect(Devise.fromCode('GBP'), Devise.gbp);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromCode null/vide/inconnu → XOF (référence)', () {
|
||||||
|
expect(Devise.fromCode(null), Devise.xof);
|
||||||
|
expect(Devise.fromCode(''), Devise.xof);
|
||||||
|
expect(Devise.fromCode('XYZ'), Devise.xof);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('estInternationale — EUR/USD/GBP/CAD/CHF → true', () {
|
||||||
|
expect(Devise.eur.estInternationale, isTrue);
|
||||||
|
expect(Devise.usd.estInternationale, isTrue);
|
||||||
|
expect(Devise.gbp.estInternationale, isTrue);
|
||||||
|
expect(Devise.cad.estInternationale, isTrue);
|
||||||
|
expect(Devise.chf.estInternationale, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('estInternationale — XOF/XAF/GHS/NGN/MAD → false', () {
|
||||||
|
expect(Devise.xof.estInternationale, isFalse);
|
||||||
|
expect(Devise.xaf.estInternationale, isFalse);
|
||||||
|
expect(Devise.ghs.estInternationale, isFalse);
|
||||||
|
expect(Devise.ngn.estInternationale, isFalse);
|
||||||
|
expect(Devise.mad.estInternationale, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Toutes les devises ont code 3 lettres + libellé non vide + zone', () {
|
||||||
|
for (final d in Devise.values) {
|
||||||
|
expect(d.code.length, 3);
|
||||||
|
expect(d.libelle.isNotEmpty, isTrue);
|
||||||
|
expect(d.zone.isNotEmpty, isTrue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user