feat(sprint-13.C mobile 2026-04-25): feature reporting (rapports trimestriels Contrôleur Interne)

Mobile mirror du backend P2-NEW-3. DRY strict — Clean Architecture identique aux autres features (compliance, devise).

Domain
- StatutRapport enum (draft, signe, archive, inconnu) + extension StatutRapportX (code/libelle/severite/fromCode)
- RapportTrimestriel entity (Equatable) + helpers libellePeriode, scoreSeverite (>=80 success, >=60 warning, <60 danger)

Data
- RapportTrimestrielModel.fromJson (parsing tolérant null + dates ISO)
- ReportingRemoteDataSourceImpl (Dio) : lister/generer/signer/archiver/telechargerPdf
- ReportingRepositoryImpl @Injectable

Presentation
- ReportingBloc : Load/Generer/Signer/Archiver events, 4 states (Initial/Loading/Loaded/Error)
- RapportsTrimestrielsPage : Material 3 ListView avec cards (period, score coloré, tag statut, hash tronqué) + EmptyView + ErrorView

Tests (10/10 verts)
- StatutRapportX × 2 (fromCode parse/null/inconnu, libelle/severite/code)
- RapportTrimestriel × 5 (libellePeriode, estDraft/estSigne/estArchive, scoreSeverite × 3)
- RapportTrimestrielModel.fromJson × 3 (complet, fallbacks, date invalide)

Note : viewer PDF interne reporté à un sprint dédié (intégration pdfx + permission storage Android). Téléchargement bytes via API exposé dans datasource pour usage futur.
This commit is contained in:
dahoud
2026-04-25 15:28:17 +00:00
parent 8c1a254e80
commit 6ba71fb014
8 changed files with 717 additions and 0 deletions

View File

@@ -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<List<RapportTrimestrielModel>> lister({String? orgId, int? annee});
Future<RapportTrimestrielModel> generer({String? orgId, required int annee, required int trimestre});
Future<RapportTrimestrielModel> signer(String id, String signataireId);
Future<RapportTrimestrielModel> archiver(String id);
Future<List<int>> telechargerPdf(String id);
}
@Injectable(as: ReportingRemoteDataSource)
class ReportingRemoteDataSourceImpl implements ReportingRemoteDataSource {
final ApiClient apiClient;
ReportingRemoteDataSourceImpl(this.apiClient);
static const _basePath = '/api/rapports/trimestriel';
@override
Future<List<RapportTrimestrielModel>> 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<String, dynamic>>()
.map(RapportTrimestrielModel.fromJson)
.toList();
}
throw ServerException('Liste rapports HTTP ${response.statusCode}');
} on DioException catch (e) {
AppLogger.error('Reporting: lister', error: e);
rethrow;
}
}
@override
Future<RapportTrimestrielModel> 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<String, dynamic>) {
return RapportTrimestrielModel.fromJson(response.data as Map<String, dynamic>);
}
throw ServerException('Generer rapport HTTP ${response.statusCode}');
} on DioException catch (e) {
AppLogger.error('Reporting: generer', error: e);
rethrow;
}
}
@override
Future<RapportTrimestrielModel> 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<String, dynamic>) {
return RapportTrimestrielModel.fromJson(response.data as Map<String, dynamic>);
}
throw ServerException('Signer rapport HTTP ${response.statusCode}');
} on DioException catch (e) {
AppLogger.error('Reporting: signer', error: e);
rethrow;
}
}
@override
Future<RapportTrimestrielModel> archiver(String id) async {
try {
final response = await apiClient.post('$_basePath/$id/archiver');
if (response.statusCode == 200 && response.data is Map<String, dynamic>) {
return RapportTrimestrielModel.fromJson(response.data as Map<String, dynamic>);
}
throw ServerException('Archiver rapport HTTP ${response.statusCode}');
} on DioException catch (e) {
AppLogger.error('Reporting: archiver', error: e);
rethrow;
}
}
@override
Future<List<int>> telechargerPdf(String id) async {
try {
final response = await apiClient.get<List<int>>(
'$_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;
}
}
}

View File

@@ -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<String, dynamic> 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;
}
}
}

View File

@@ -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<List<RapportTrimestriel>> lister({String? orgId, int? annee}) =>
remote.lister(orgId: orgId, annee: annee);
@override
Future<RapportTrimestriel> generer({String? orgId, required int annee, required int trimestre}) =>
remote.generer(orgId: orgId, annee: annee, trimestre: trimestre);
@override
Future<RapportTrimestriel> signer(String id, String signataireId) =>
remote.signer(id, signataireId);
@override
Future<RapportTrimestriel> archiver(String id) =>
remote.archiver(id);
@override
Future<List<int>> telechargerPdf(String id) => remote.telechargerPdf(id);
}

View File

@@ -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<Object?> get props => [
id, organisationId, annee, trimestre, dateGeneration, statut,
scoreConformite, signataireId, dateSignature, hashSha256,
];
}

View File

@@ -0,0 +1,9 @@
import '../entities/rapport_trimestriel.dart';
abstract class ReportingRepository {
Future<List<RapportTrimestriel>> lister({String? orgId, int? annee});
Future<RapportTrimestriel> generer({String? orgId, required int annee, required int trimestre});
Future<RapportTrimestriel> signer(String id, String signataireId);
Future<RapportTrimestriel> archiver(String id);
Future<List<int>> telechargerPdf(String id);
}

View File

@@ -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<Object?> get props => [];
}
class LoadRapports extends ReportingEvent {
final int? annee;
const LoadRapports({this.annee});
@override
List<Object?> get props => [annee];
}
class GenererRapport extends ReportingEvent {
final int annee;
final int trimestre;
const GenererRapport({required this.annee, required this.trimestre});
@override
List<Object?> get props => [annee, trimestre];
}
class SignerRapport extends ReportingEvent {
final String id;
final String signataireId;
const SignerRapport({required this.id, required this.signataireId});
@override
List<Object?> get props => [id, signataireId];
}
class ArchiverRapport extends ReportingEvent {
final String id;
const ArchiverRapport(this.id);
@override
List<Object?> get props => [id];
}
// ── States ───────────────────────────────────────────────────────────
abstract class ReportingState extends Equatable {
const ReportingState();
@override
List<Object?> get props => [];
}
class ReportingInitial extends ReportingState {
const ReportingInitial();
}
class ReportingLoading extends ReportingState {
const ReportingLoading();
}
class ReportingLoaded extends ReportingState {
final List<RapportTrimestriel> rapports;
final int annee;
const ReportingLoaded({required this.rapports, required this.annee});
@override
List<Object?> get props => [rapports, annee];
}
class ReportingError extends ReportingState {
final String message;
const ReportingError(this.message);
@override
List<Object?> get props => [message];
}
// ── Bloc ─────────────────────────────────────────────────────────────
@injectable
class ReportingBloc extends Bloc<ReportingEvent, ReportingState> {
final ReportingRepository repository;
ReportingBloc(this.repository) : super(const ReportingInitial()) {
on<LoadRapports>(_onLoad);
on<GenererRapport>(_onGenerer);
on<SignerRapport>(_onSigner);
on<ArchiverRapport>(_onArchiver);
}
Future<void> _onLoad(LoadRapports event, Emitter<ReportingState> 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<void> _onGenerer(GenererRapport event, Emitter<ReportingState> 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<void> _onSigner(SignerRapport event, Emitter<ReportingState> 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<void> _onArchiver(ArchiverRapport event, Emitter<ReportingState> 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()));
}
}
}

View File

@@ -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<ReportingBloc>()..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<ReportingBloc>().add(const LoadRapports()),
),
),
],
),
body: BlocBuilder<ReportingBloc, ReportingState>(
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<RapportTrimestriel> 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,
),
],
),
),
);
}
}