feat(mobile): consolidation modules epargne, messaging, organisations
- Epargne: badge LCB-FT (bouclier ambre) sur comptes avec fonds bloques + note recap - EpargneDetail: historique pagine (page/size), affichage soldeAvant/soldeApres/motif dans chaque transaction, bouton "Charger plus" - TransactionEpargneRepository: getByCompte accepte page et size, gere reponse paginee Spring (content[]) - MessagingDatasource: markMessageAsRead silencieuse (pas d'endpoint unitaire), getUnreadCount somme unreadCount des conversations - OrganizationDetail: _memberCount charge le vrai nombre depuis GET /membres/count, affiche la valeur reelle au lieu de nombreMembres (toujours 0)
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
library messaging_remote_datasource;
|
library messaging_remote_datasource;
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
@@ -303,14 +304,18 @@ class MessagingRemoteDatasource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> markMessageAsRead(String messageId) async {
|
Future<void> markMessageAsRead(String messageId) async {
|
||||||
// Backend uses conversation mark-read, not individual message
|
// Backend has no per-message read endpoint — use markConversationAsRead
|
||||||
// This method is deprecated - use markConversationAsRead instead
|
if (AppConfig.enableLogging) {
|
||||||
throw UnimplementedError('Use markConversationAsRead instead');
|
debugPrint('[Messaging] markMessageAsRead ignored (no per-message endpoint), messageId=$messageId');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> getUnreadCount({String? organizationId}) async {
|
Future<int> getUnreadCount({String? organizationId}) async {
|
||||||
// Backend provides unreadCount in conversation response
|
try {
|
||||||
// This method is deprecated - get count from conversation list
|
final conversations = await getConversations(organizationId: organizationId);
|
||||||
throw UnimplementedError('Get unread count from conversation list');
|
return conversations.fold<int>(0, (sum, c) => sum + c.unreadCount);
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,12 +111,23 @@ class TransactionEpargneRepository {
|
|||||||
throw Exception('Erreur transfert: ${response.statusCode}');
|
throw Exception('Erreur transfert: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Historique des transactions d'un compte.
|
/// Historique des transactions d'un compte (paginé).
|
||||||
Future<List<Map<String, dynamic>>> getByCompte(String compteId) async {
|
Future<List<Map<String, dynamic>>> getByCompte(
|
||||||
final response = await _apiClient.get('$_base/compte/$compteId');
|
String compteId, {
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'$_base/compte/$compteId',
|
||||||
|
queryParameters: {'page': page, 'size': size},
|
||||||
|
);
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = response.data;
|
final data = response.data;
|
||||||
if (data is List) return List<Map<String, dynamic>>.from(data.map((e) => e as Map<String, dynamic>));
|
if (data is List) return List<Map<String, dynamic>>.from(data.map((e) => e as Map<String, dynamic>));
|
||||||
|
if (data is Map && data.containsKey('content')) {
|
||||||
|
final content = data['content'] as List? ?? [];
|
||||||
|
return List<Map<String, dynamic>>.from(content.map((e) => e as Map<String, dynamic>));
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
throw Exception('Erreur chargement historique: ${response.statusCode}');
|
throw Exception('Erreur chargement historique: ${response.statusCode}');
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ class EpargneDetailPage extends StatefulWidget {
|
|||||||
class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
||||||
List<TransactionEpargneModel> _transactions = [];
|
List<TransactionEpargneModel> _transactions = [];
|
||||||
bool _loadingTx = true;
|
bool _loadingTx = true;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
bool _hasMore = true;
|
||||||
|
int _page = 0;
|
||||||
|
static const int _pageSize = 20;
|
||||||
String? _errorTx;
|
String? _errorTx;
|
||||||
CompteEpargneModel? _compte; // rafraîchi après actions
|
CompteEpargneModel? _compte; // rafraîchi après actions
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_compte = widget.compte;
|
_compte = widget.compte;
|
||||||
_loadTransactions();
|
_loadTransactions(reset: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshCompte() async {
|
Future<void> _refreshCompte() async {
|
||||||
@@ -59,33 +63,41 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadTransactions() async {
|
Future<void> _loadTransactions({bool reset = false}) async {
|
||||||
if (_compte?.id == null) {
|
if (_compte?.id == null) {
|
||||||
setState(() {
|
setState(() { _loadingTx = false; _transactions = []; });
|
||||||
_loadingTx = false;
|
|
||||||
_transactions = [];
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
if (reset) {
|
||||||
_loadingTx = true;
|
setState(() { _loadingTx = true; _errorTx = null; _page = 0; _hasMore = true; });
|
||||||
_errorTx = null;
|
} else {
|
||||||
});
|
if (_loadingMore || !_hasMore) return;
|
||||||
|
setState(() => _loadingMore = true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final repo = GetIt.I<TransactionEpargneRepository>();
|
final repo = GetIt.I<TransactionEpargneRepository>();
|
||||||
final list = await repo.getByCompte(_compte!.id!);
|
final list = await repo.getByCompte(_compte!.id!, page: _page, size: _pageSize);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final parsed = list.map((e) => TransactionEpargneModel.fromJson(e)).toList();
|
||||||
setState(() {
|
setState(() {
|
||||||
_transactions = list.map((e) => TransactionEpargneModel.fromJson(e)).toList();
|
if (reset) {
|
||||||
|
_transactions = parsed;
|
||||||
|
} else {
|
||||||
|
_transactions = [..._transactions, ...parsed];
|
||||||
|
}
|
||||||
|
_hasMore = parsed.length == _pageSize;
|
||||||
|
_page = _page + 1;
|
||||||
_loadingTx = false;
|
_loadingTx = false;
|
||||||
|
_loadingMore = false;
|
||||||
_errorTx = null;
|
_errorTx = null;
|
||||||
});
|
});
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
AppLogger.error('EpargneDetailPage: _loadTransactions échoué', error: e, stackTrace: st);
|
AppLogger.error('EpargneDetailPage: _loadTransactions échoué', error: e, stackTrace: st);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_transactions = [];
|
if (reset) _transactions = [];
|
||||||
_loadingTx = false;
|
_loadingTx = false;
|
||||||
|
_loadingMore = false;
|
||||||
_errorTx = e.toString().replaceFirst('Exception: ', '');
|
_errorTx = e.toString().replaceFirst('Exception: ', '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,7 +111,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
compteId: _compte!.id!,
|
compteId: _compte!.id!,
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
_refreshCompte();
|
_refreshCompte();
|
||||||
_loadTransactions();
|
_loadTransactions(reset: true);
|
||||||
widget.onDataChanged?.call();
|
widget.onDataChanged?.call();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -117,7 +129,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
soldeDisponible: soldeDispo,
|
soldeDisponible: soldeDispo,
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
_refreshCompte();
|
_refreshCompte();
|
||||||
_loadTransactions();
|
_loadTransactions(reset: true);
|
||||||
widget.onDataChanged?.call();
|
widget.onDataChanged?.call();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -133,7 +145,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
tousLesComptes: widget.tousLesComptes,
|
tousLesComptes: widget.tousLesComptes,
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
_refreshCompte();
|
_refreshCompte();
|
||||||
_loadTransactions();
|
_loadTransactions(reset: true);
|
||||||
widget.onDataChanged?.call();
|
widget.onDataChanged?.call();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -191,7 +203,7 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await _refreshCompte();
|
await _refreshCompte();
|
||||||
await _loadTransactions();
|
await _loadTransactions(reset: true);
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
@@ -332,10 +344,13 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
Card(
|
Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: _transactions.take(10).map((t) {
|
children: _transactions.map((t) {
|
||||||
|
final dateStr = t.dateTransaction != null
|
||||||
|
? '${t.dateTransaction!.day.toString().padLeft(2, '0')}/${t.dateTransaction!.month.toString().padLeft(2, '0')}/${t.dateTransaction!.year} ${t.dateTransaction!.hour.toString().padLeft(2, '0')}:${t.dateTransaction!.minute.toString().padLeft(2, '0')}'
|
||||||
|
: null;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: t.isCredit ? ColorTokens.success.withOpacity(0.2) : ColorTokens.error.withOpacity(0.2),
|
backgroundColor: t.isCredit ? ColorTokens.success.withOpacity(0.2) : ColorTokens.error.withOpacity(0.2),
|
||||||
@@ -345,16 +360,28 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(_libelleType(t.type), style: TypographyTokens.bodyMedium),
|
||||||
_libelleType(t.type),
|
subtitle: Column(
|
||||||
style: TypographyTokens.bodyMedium,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
subtitle: t.dateTransaction != null
|
if (dateStr != null)
|
||||||
? Text(
|
Text(dateStr, style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant)),
|
||||||
'${t.dateTransaction!.day.toString().padLeft(2, '0')}/${t.dateTransaction!.month.toString().padLeft(2, '0')}/${t.dateTransaction!.year} ${t.dateTransaction!.hour.toString().padLeft(2, '0')}:${t.dateTransaction!.minute.toString().padLeft(2, '0')}',
|
Text(
|
||||||
|
'Avant: ${t.soldeAvant.toStringAsFixed(0)} → Après: ${t.soldeApres.toStringAsFixed(0)} XOF',
|
||||||
style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant),
|
style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||||
)
|
),
|
||||||
: null,
|
if (t.motif != null && t.motif!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
t.motif!,
|
||||||
|
style: TypographyTokens.bodySmall.copyWith(
|
||||||
|
color: ColorTokens.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
|
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
|
||||||
style: TypographyTokens.titleSmall.copyWith(
|
style: TypographyTokens.titleSmall.copyWith(
|
||||||
@@ -362,10 +389,22 @@ class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
isThreeLine: t.motif != null && t.motif!.isNotEmpty,
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_hasMore)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
||||||
|
child: _loadingMore
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: TextButton(
|
||||||
|
onPressed: () => _loadTransactions(),
|
||||||
|
child: const Text('Charger plus'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -186,6 +186,19 @@ class _EpargnePageState extends State<EpargnePage> {
|
|||||||
'Solde disponible total • ${_comptes.length} compte${_comptes.length > 1 ? 's' : ''}',
|
'Solde disponible total • ${_comptes.length} compte${_comptes.length > 1 ? 's' : ''}',
|
||||||
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
|
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
|
||||||
),
|
),
|
||||||
|
if (_comptes.any((c) => c.soldeBloque > 0)) ...[
|
||||||
|
const SizedBox(height: SpacingTokens.xs),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.shield_outlined, size: 12, color: Colors.amber.shade700),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Certains fonds sont sous surveillance LCB-FT',
|
||||||
|
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: Colors.amber.shade700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -231,6 +244,29 @@ class _EpargnePageState extends State<EpargnePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (c.soldeBloque > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: SpacingTokens.xs),
|
||||||
|
child: Tooltip(
|
||||||
|
message: 'Fonds bloqués — surveillance LCB-FT',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.amber.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.amber.shade700, width: 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.shield_outlined, size: 12, color: Colors.amber.shade700),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text('LCB-FT', style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: Colors.amber.shade700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (c.statut != null)
|
if (c.statut != null)
|
||||||
InfoBadge(
|
InfoBadge(
|
||||||
text: c.statut!,
|
text: c.statut!,
|
||||||
|
|||||||
@@ -345,4 +345,20 @@ class OrganizationRepositoryImpl implements IOrganizationRepository {
|
|||||||
throw Exception('Erreur inattendue lors de la récupération des statistiques: $e');
|
throw Exception('Erreur inattendue lors de la récupération des statistiques: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> getMembreCount(String organizationId) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('$_baseUrl/$organizationId/membres/count');
|
||||||
|
if (response.statusCode == 200 && response.data is Map) {
|
||||||
|
return (response.data['count'] as num?)?.toInt() ?? 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.type == DioExceptionType.cancel) rethrow;
|
||||||
|
return 0;
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,7 @@ abstract class IOrganizationRepository {
|
|||||||
|
|
||||||
/// Récupère les statistiques des organisations
|
/// Récupère les statistiques des organisations
|
||||||
Future<Map<String, dynamic>> getOrganizationsStats();
|
Future<Map<String, dynamic>> getOrganizationsStats();
|
||||||
|
|
||||||
|
/// Retourne le nombre réel de membres actifs d'une organisation.
|
||||||
|
Future<int> getMembreCount(String organizationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ library organisation_detail_page;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../data/models/organization_model.dart';
|
import '../../data/models/organization_model.dart';
|
||||||
import '../../bloc/organizations_bloc.dart';
|
import '../../bloc/organizations_bloc.dart';
|
||||||
import '../../bloc/organizations_event.dart';
|
import '../../bloc/organizations_event.dart';
|
||||||
import '../../bloc/organizations_state.dart';
|
import '../../bloc/organizations_state.dart';
|
||||||
|
import '../../domain/repositories/organization_repository.dart';
|
||||||
import 'edit_organization_page.dart';
|
import 'edit_organization_page.dart';
|
||||||
import '../../../../shared/design_system/tokens/app_colors.dart';
|
import '../../../../shared/design_system/tokens/app_colors.dart';
|
||||||
import '../../../../features/authentication/presentation/bloc/auth_bloc.dart';
|
import '../../../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||||
@@ -23,10 +25,20 @@ class OrganizationDetailPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
|
class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
|
||||||
|
int? _memberCount;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
context.read<OrganizationsBloc>().add(LoadOrganizationById(widget.organizationId));
|
context.read<OrganizationsBloc>().add(LoadOrganizationById(widget.organizationId));
|
||||||
|
_loadMemberCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMemberCount() async {
|
||||||
|
try {
|
||||||
|
final count = await GetIt.I<IOrganizationRepository>().getMembreCount(widget.organizationId);
|
||||||
|
if (mounted) setState(() => _memberCount = count);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -214,7 +226,7 @@ class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
|
|||||||
Widget _buildStatsCard(OrganizationModel org) {
|
Widget _buildStatsCard(OrganizationModel org) {
|
||||||
return _buildCard('Statistiques', Icons.bar_chart, [
|
return _buildCard('Statistiques', Icons.bar_chart, [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Expanded(child: _buildStatItem(Icons.people, 'Membres', org.nombreMembres.toString(), AppColors.primaryGreen)),
|
Expanded(child: _buildStatItem(Icons.people, 'Membres', (_memberCount ?? org.nombreMembres).toString(), AppColors.primaryGreen)),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(child: _buildStatItem(Icons.admin_panel_settings, 'Admins', org.nombreAdministrateurs.toString(), AppColors.brandGreen)),
|
Expanded(child: _buildStatItem(Icons.admin_panel_settings, 'Admins', org.nombreAdministrateurs.toString(), AppColors.brandGreen)),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
|||||||
Reference in New Issue
Block a user