diff --git a/lib/features/reporting/data/datasources/reporting_remote_datasource.dart b/lib/features/reporting/data/datasources/reporting_remote_datasource.dart new file mode 100644 index 0000000..32a8a9d --- /dev/null +++ b/lib/features/reporting/data/datasources/reporting_remote_datasource.dart @@ -0,0 +1,114 @@ +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/rapport_trimestriel_model.dart'; + +abstract class ReportingRemoteDataSource { + Future> lister({String? orgId, int? annee}); + Future generer({String? orgId, required int annee, required int trimestre}); + Future signer(String id, String signataireId); + Future archiver(String id); + Future> telechargerPdf(String id); +} + +@Injectable(as: ReportingRemoteDataSource) +class ReportingRemoteDataSourceImpl implements ReportingRemoteDataSource { + final ApiClient apiClient; + ReportingRemoteDataSourceImpl(this.apiClient); + + static const _basePath = '/api/rapports/trimestriel'; + + @override + Future> lister({String? orgId, int? annee}) async { + try { + final response = await apiClient.get( + _basePath, + queryParameters: { + if (orgId != null) 'orgId': orgId, + if (annee != null) 'annee': annee, + }, + ); + if (response.statusCode == 200 && response.data is List) { + return (response.data as List) + .whereType>() + .map(RapportTrimestrielModel.fromJson) + .toList(); + } + throw ServerException('Liste rapports HTTP ${response.statusCode}'); + } on DioException catch (e) { + AppLogger.error('Reporting: lister', error: e); + rethrow; + } + } + + @override + Future generer({String? orgId, required int annee, required int trimestre}) async { + try { + final response = await apiClient.post( + '$_basePath/generer', + queryParameters: { + if (orgId != null) 'orgId': orgId, + 'annee': annee, + 'trimestre': trimestre, + }, + ); + if (response.statusCode == 200 && response.data is Map) { + return RapportTrimestrielModel.fromJson(response.data as Map); + } + throw ServerException('Generer rapport HTTP ${response.statusCode}'); + } on DioException catch (e) { + AppLogger.error('Reporting: generer', error: e); + rethrow; + } + } + + @override + Future signer(String id, String signataireId) async { + try { + final response = await apiClient.post( + '$_basePath/$id/signer', + queryParameters: {'signataireId': signataireId}, + ); + if (response.statusCode == 200 && response.data is Map) { + return RapportTrimestrielModel.fromJson(response.data as Map); + } + throw ServerException('Signer rapport HTTP ${response.statusCode}'); + } on DioException catch (e) { + AppLogger.error('Reporting: signer', error: e); + rethrow; + } + } + + @override + Future archiver(String id) async { + try { + final response = await apiClient.post('$_basePath/$id/archiver'); + if (response.statusCode == 200 && response.data is Map) { + return RapportTrimestrielModel.fromJson(response.data as Map); + } + throw ServerException('Archiver rapport HTTP ${response.statusCode}'); + } on DioException catch (e) { + AppLogger.error('Reporting: archiver', error: e); + rethrow; + } + } + + @override + Future> telechargerPdf(String id) async { + try { + final response = await apiClient.get>( + '$_basePath/$id/pdf', + options: Options(responseType: ResponseType.bytes), + ); + if (response.statusCode == 200 && response.data != null) { + return response.data!; + } + throw ServerException('Telecharger PDF HTTP ${response.statusCode}'); + } on DioException catch (e) { + AppLogger.error('Reporting: telechargerPdf', error: e); + rethrow; + } + } +} diff --git a/lib/features/reporting/data/models/rapport_trimestriel_model.dart b/lib/features/reporting/data/models/rapport_trimestriel_model.dart new file mode 100644 index 0000000..0ba53ca --- /dev/null +++ b/lib/features/reporting/data/models/rapport_trimestriel_model.dart @@ -0,0 +1,40 @@ +import '../../domain/entities/rapport_trimestriel.dart'; + +class RapportTrimestrielModel extends RapportTrimestriel { + const RapportTrimestrielModel({ + required super.id, + required super.organisationId, + required super.annee, + required super.trimestre, + required super.dateGeneration, + required super.statut, + required super.scoreConformite, + super.signataireId, + super.dateSignature, + super.hashSha256, + }); + + factory RapportTrimestrielModel.fromJson(Map json) { + return RapportTrimestrielModel( + id: json['id']?.toString() ?? '', + organisationId: json['organisationId']?.toString() ?? '', + annee: (json['annee'] as num?)?.toInt() ?? 0, + trimestre: (json['trimestre'] as num?)?.toInt() ?? 0, + dateGeneration: _parseDate(json['dateGeneration']), + statut: StatutRapportX.fromCode(json['statut']?.toString()), + scoreConformite: (json['scoreConformite'] as num?)?.toInt() ?? 0, + signataireId: json['signataireId']?.toString(), + dateSignature: _parseDate(json['dateSignature']), + hashSha256: json['hashSha256']?.toString(), + ); + } + + static DateTime? _parseDate(dynamic raw) { + if (raw == null) return null; + try { + return DateTime.parse(raw.toString()); + } catch (_) { + return null; + } + } +} diff --git a/lib/features/reporting/data/repositories/reporting_repository_impl.dart b/lib/features/reporting/data/repositories/reporting_repository_impl.dart new file mode 100644 index 0000000..4a26679 --- /dev/null +++ b/lib/features/reporting/data/repositories/reporting_repository_impl.dart @@ -0,0 +1,29 @@ +import 'package:injectable/injectable.dart'; +import '../../domain/entities/rapport_trimestriel.dart'; +import '../../domain/repositories/reporting_repository.dart'; +import '../datasources/reporting_remote_datasource.dart'; + +@Injectable(as: ReportingRepository) +class ReportingRepositoryImpl implements ReportingRepository { + final ReportingRemoteDataSource remote; + ReportingRepositoryImpl(this.remote); + + @override + Future> lister({String? orgId, int? annee}) => + remote.lister(orgId: orgId, annee: annee); + + @override + Future generer({String? orgId, required int annee, required int trimestre}) => + remote.generer(orgId: orgId, annee: annee, trimestre: trimestre); + + @override + Future signer(String id, String signataireId) => + remote.signer(id, signataireId); + + @override + Future archiver(String id) => + remote.archiver(id); + + @override + Future> telechargerPdf(String id) => remote.telechargerPdf(id); +} diff --git a/lib/features/reporting/domain/entities/rapport_trimestriel.dart b/lib/features/reporting/domain/entities/rapport_trimestriel.dart new file mode 100644 index 0000000..2be86d4 --- /dev/null +++ b/lib/features/reporting/domain/entities/rapport_trimestriel.dart @@ -0,0 +1,83 @@ +import 'package:equatable/equatable.dart'; + +/// Statut d'un rapport trimestriel Contrôleur Interne. +enum StatutRapport { draft, signe, archive, inconnu } + +extension StatutRapportX on StatutRapport { + String get code => switch (this) { + StatutRapport.draft => 'DRAFT', + StatutRapport.signe => 'SIGNE', + StatutRapport.archive => 'ARCHIVE', + StatutRapport.inconnu => '', + }; + + String get libelle => switch (this) { + StatutRapport.draft => 'Brouillon', + StatutRapport.signe => 'Signé', + StatutRapport.archive => 'Archivé', + StatutRapport.inconnu => 'Inconnu', + }; + + /// Couleur sémantique Material 3. + String get severite => switch (this) { + StatutRapport.draft => 'warning', + StatutRapport.signe => 'info', + StatutRapport.archive => 'success', + StatutRapport.inconnu => 'secondary', + }; + + static StatutRapport fromCode(String? code) { + return switch (code) { + 'DRAFT' => StatutRapport.draft, + 'SIGNE' => StatutRapport.signe, + 'ARCHIVE' => StatutRapport.archive, + _ => StatutRapport.inconnu, + }; + } +} + +/// Rapport trimestriel Contrôleur Interne (miroir mobile du backend P2-NEW-3). +class RapportTrimestriel extends Equatable { + final String id; + final String organisationId; + final int annee; + final int trimestre; + final DateTime? dateGeneration; + final StatutRapport statut; + final int scoreConformite; + final String? signataireId; + final DateTime? dateSignature; + final String? hashSha256; + + const RapportTrimestriel({ + required this.id, + required this.organisationId, + required this.annee, + required this.trimestre, + required this.dateGeneration, + required this.statut, + required this.scoreConformite, + this.signataireId, + this.dateSignature, + this.hashSha256, + }); + + bool get estDraft => statut == StatutRapport.draft; + bool get estSigne => statut == StatutRapport.signe; + bool get estArchive => statut == StatutRapport.archive; + + String get libellePeriode => 'T$trimestre / $annee'; + + /// Couleur sémantique du score (success >= 80, warning >= 60, danger < 60). + String get scoreSeverite { + if (scoreConformite >= 80) return 'success'; + if (scoreConformite >= 60) return 'warning'; + return 'danger'; + } + + @override + List get props => [ + id, organisationId, annee, trimestre, dateGeneration, statut, + scoreConformite, signataireId, dateSignature, hashSha256, + ]; +} diff --git a/lib/features/reporting/domain/repositories/reporting_repository.dart b/lib/features/reporting/domain/repositories/reporting_repository.dart new file mode 100644 index 0000000..928a73e --- /dev/null +++ b/lib/features/reporting/domain/repositories/reporting_repository.dart @@ -0,0 +1,9 @@ +import '../entities/rapport_trimestriel.dart'; + +abstract class ReportingRepository { + Future> lister({String? orgId, int? annee}); + Future generer({String? orgId, required int annee, required int trimestre}); + Future signer(String id, String signataireId); + Future archiver(String id); + Future> telechargerPdf(String id); +} diff --git a/lib/features/reporting/presentation/bloc/reporting_bloc.dart b/lib/features/reporting/presentation/bloc/reporting_bloc.dart new file mode 100644 index 0000000..767bac0 --- /dev/null +++ b/lib/features/reporting/presentation/bloc/reporting_bloc.dart @@ -0,0 +1,129 @@ +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/rapport_trimestriel.dart'; +import '../../domain/repositories/reporting_repository.dart'; + +// ── Events ─────────────────────────────────────────────────────────── +abstract class ReportingEvent extends Equatable { + const ReportingEvent(); + @override + List get props => []; +} + +class LoadRapports extends ReportingEvent { + final int? annee; + const LoadRapports({this.annee}); + @override + List get props => [annee]; +} + +class GenererRapport extends ReportingEvent { + final int annee; + final int trimestre; + const GenererRapport({required this.annee, required this.trimestre}); + @override + List get props => [annee, trimestre]; +} + +class SignerRapport extends ReportingEvent { + final String id; + final String signataireId; + const SignerRapport({required this.id, required this.signataireId}); + @override + List get props => [id, signataireId]; +} + +class ArchiverRapport extends ReportingEvent { + final String id; + const ArchiverRapport(this.id); + @override + List get props => [id]; +} + +// ── States ─────────────────────────────────────────────────────────── +abstract class ReportingState extends Equatable { + const ReportingState(); + @override + List get props => []; +} + +class ReportingInitial extends ReportingState { + const ReportingInitial(); +} + +class ReportingLoading extends ReportingState { + const ReportingLoading(); +} + +class ReportingLoaded extends ReportingState { + final List rapports; + final int annee; + const ReportingLoaded({required this.rapports, required this.annee}); + @override + List get props => [rapports, annee]; +} + +class ReportingError extends ReportingState { + final String message; + const ReportingError(this.message); + @override + List get props => [message]; +} + +// ── Bloc ───────────────────────────────────────────────────────────── +@injectable +class ReportingBloc extends Bloc { + final ReportingRepository repository; + + ReportingBloc(this.repository) : super(const ReportingInitial()) { + on(_onLoad); + on(_onGenerer); + on(_onSigner); + on(_onArchiver); + } + + Future _onLoad(LoadRapports event, Emitter emit) async { + emit(const ReportingLoading()); + try { + final annee = event.annee ?? DateTime.now().year; + final rapports = await repository.lister(annee: annee); + emit(ReportingLoaded(rapports: rapports, annee: annee)); + } catch (e, st) { + AppLogger.error('ReportingBloc: load', error: e, stackTrace: st); + emit(ReportingError(e.toString())); + } + } + + Future _onGenerer(GenererRapport event, Emitter emit) async { + final current = state; + try { + await repository.generer(annee: event.annee, trimestre: event.trimestre); + // Reload list + add(LoadRapports(annee: current is ReportingLoaded ? current.annee : event.annee)); + } catch (e) { + emit(ReportingError(e.toString())); + } + } + + Future _onSigner(SignerRapport event, Emitter emit) async { + final current = state; + try { + await repository.signer(event.id, event.signataireId); + add(LoadRapports(annee: current is ReportingLoaded ? current.annee : null)); + } catch (e) { + emit(ReportingError(e.toString())); + } + } + + Future _onArchiver(ArchiverRapport event, Emitter emit) async { + final current = state; + try { + await repository.archiver(event.id); + add(LoadRapports(annee: current is ReportingLoaded ? current.annee : null)); + } catch (e) { + emit(ReportingError(e.toString())); + } + } +} diff --git a/lib/features/reporting/presentation/pages/rapports_trimestriels_page.dart b/lib/features/reporting/presentation/pages/rapports_trimestriels_page.dart new file mode 100644 index 0000000..3ba8626 --- /dev/null +++ b/lib/features/reporting/presentation/pages/rapports_trimestriels_page.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/di/injection.dart'; +import '../../domain/entities/rapport_trimestriel.dart'; +import '../bloc/reporting_bloc.dart'; + +/// Liste + actions sur les rapports trimestriels Contrôleur Interne (Sprint 13.C). +class RapportsTrimestrielsPage extends StatelessWidget { + const RapportsTrimestrielsPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => getIt()..add(const LoadRapports()), + child: Scaffold( + appBar: AppBar( + title: const Text('Rapports trimestriels'), + actions: [ + Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ctx.read().add(const LoadRapports()), + ), + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state is ReportingLoading || state is ReportingInitial) { + return const Center(child: CircularProgressIndicator()); + } + if (state is ReportingError) { + return _ErrorView(message: state.message); + } + if (state is ReportingLoaded) { + if (state.rapports.isEmpty) { + return _EmptyView(annee: state.annee); + } + return _ListView(rapports: state.rapports); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _ListView extends StatelessWidget { + final List rapports; + const _ListView({required this.rapports}); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: rapports.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) => _RapportCard(rapport: rapports[index]), + ); + } +} + +class _RapportCard extends StatelessWidget { + final RapportTrimestriel rapport; + const _RapportCard({required this.rapport}); + + @override + Widget build(BuildContext context) { + final scoreColor = _color(rapport.scoreSeverite); + final statutColor = _color(rapport.statut.severite); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + rapport.libellePeriode, + style: Theme.of(context).textTheme.titleLarge, + ), + _Tag(label: rapport.statut.libelle, color: statutColor), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Score : ${rapport.scoreConformite}/100', + style: TextStyle(color: scoreColor, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 12), + if (rapport.dateGeneration != null) + Expanded( + child: Text( + 'Généré le ${_formatDate(rapport.dateGeneration!)}', + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (rapport.hashSha256 != null && rapport.hashSha256!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Hash : ${rapport.hashSha256!.substring(0, 16)}…', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ], + ], + ), + ), + ); + } + + String _formatDate(DateTime d) => + '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; + + Color _color(String s) => switch (s) { + 'success' => Colors.green, + 'warning' => Colors.orange, + 'danger' => Colors.red, + 'info' => Colors.blue, + _ => Colors.grey, + }; +} + +class _Tag extends StatelessWidget { + final String label; + final Color color; + const _Tag({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 12), + ), + ); + } +} + +class _EmptyView extends StatelessWidget { + final int annee; + const _EmptyView({required this.annee}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.description_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Aucun rapport pour $annee', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Les rapports trimestriels sont générés automatiquement\nle 1er du mois suivant chaque trimestre.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + const _ErrorView({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 les rapports', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/test/features/reporting/rapport_trimestriel_test.dart b/test/features/reporting/rapport_trimestriel_test.dart new file mode 100644 index 0000000..eb8886b --- /dev/null +++ b/test/features/reporting/rapport_trimestriel_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:unionflow_mobile_apps/features/reporting/data/models/rapport_trimestriel_model.dart'; +import 'package:unionflow_mobile_apps/features/reporting/domain/entities/rapport_trimestriel.dart'; + +void main() { + group('StatutRapportX', () { + test('fromCode parse correct', () { + expect(StatutRapportX.fromCode('DRAFT'), StatutRapport.draft); + expect(StatutRapportX.fromCode('SIGNE'), StatutRapport.signe); + expect(StatutRapportX.fromCode('ARCHIVE'), StatutRapport.archive); + expect(StatutRapportX.fromCode(null), StatutRapport.inconnu); + expect(StatutRapportX.fromCode('AUTRE'), StatutRapport.inconnu); + }); + + test('libellé / sévérité / code', () { + expect(StatutRapport.draft.code, 'DRAFT'); + expect(StatutRapport.draft.libelle, 'Brouillon'); + expect(StatutRapport.draft.severite, 'warning'); + expect(StatutRapport.signe.code, 'SIGNE'); + expect(StatutRapport.signe.severite, 'info'); + expect(StatutRapport.archive.code, 'ARCHIVE'); + expect(StatutRapport.archive.severite, 'success'); + expect(StatutRapport.inconnu.code, ''); + expect(StatutRapport.inconnu.severite, 'secondary'); + }); + }); + + group('RapportTrimestriel', () { + RapportTrimestriel build({int score = 75, StatutRapport statut = StatutRapport.draft}) { + return RapportTrimestriel( + id: 'r1', organisationId: 'o1', annee: 2026, trimestre: 1, + dateGeneration: DateTime(2026, 4, 1), + statut: statut, scoreConformite: score, + ); + } + + test('libellePeriode formaté', () { + expect(build().libellePeriode, 'T1 / 2026'); + }); + + test('estDraft / estSigne / estArchive', () { + expect(build(statut: StatutRapport.draft).estDraft, isTrue); + expect(build(statut: StatutRapport.signe).estSigne, isTrue); + expect(build(statut: StatutRapport.archive).estArchive, isTrue); + }); + + 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'); + }); + }); + + group('RapportTrimestrielModel.fromJson', () { + test('parsing complet', () { + final json = { + 'id': 'r-uuid', 'organisationId': 'o-uuid', + 'annee': 2026, 'trimestre': 2, + 'dateGeneration': '2026-04-01T10:00:00', + 'statut': 'SIGNE', 'scoreConformite': 82, + 'signataireId': 's-uuid', + 'dateSignature': '2026-04-05T14:30:00', + 'hashSha256': 'a' * 64, + }; + final m = RapportTrimestrielModel.fromJson(json); + expect(m.id, 'r-uuid'); + expect(m.annee, 2026); + expect(m.trimestre, 2); + expect(m.statut, StatutRapport.signe); + expect(m.scoreConformite, 82); + expect(m.signataireId, 's-uuid'); + expect(m.hashSha256, 'a' * 64); + }); + + test('valeurs manquantes → fallbacks', () { + final m = RapportTrimestrielModel.fromJson({}); + expect(m.id, ''); + expect(m.statut, StatutRapport.inconnu); + expect(m.scoreConformite, 0); + expect(m.dateGeneration, isNull); + }); + + test('date invalide → null', () { + final m = RapportTrimestrielModel.fromJson({'dateGeneration': 'not-a-date'}); + expect(m.dateGeneration, isNull); + }); + }); +}