hr feedbacks
This commit is contained in:
parent
1e97c41545
commit
5ab200618c
@ -7,7 +7,6 @@ import 'package:nhancepolicy/service/api_service.dart';
|
||||
import 'package:nhancepolicy/service/svg_service.dart';
|
||||
import 'package:nhancepolicy/service/token_storage_service.dart';
|
||||
import 'package:nhancepolicy/logger.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class NhanceSideBar extends StatefulWidget {
|
||||
const NhanceSideBar({super.key});
|
||||
@ -102,8 +101,7 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
}
|
||||
|
||||
// CLAIMS — only when module 4 and at least one sub-menu is allowed
|
||||
if (postModules.contains(4) &&
|
||||
(claimsSubMenu.contains('EB') || claimsSubMenu.contains('Non-EB'))) {
|
||||
if (postModules.contains(4) && _claimsSubMenuRoutes.isNotEmpty) {
|
||||
items.add({
|
||||
'route': 'ClaimsPolicies',
|
||||
'label': 'Claims',
|
||||
@ -116,6 +114,26 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Allowed Claims destinations based on claims_sub_menu.
|
||||
List<({String label, String route})> get _claimsSubMenuRoutes {
|
||||
final items = <({String label, String route})>[];
|
||||
if (claimsSubMenu.contains('EB')) {
|
||||
items.add((label: 'Employee Benefits', route: 'ClaimsPolicies'));
|
||||
}
|
||||
if (claimsSubMenu.contains('Non-EB')) {
|
||||
items.add((label: 'Non-Employee Benefits', route: 'nonEBClaimsList'));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
bool get _hasMultipleClaimsSubMenus => _claimsSubMenuRoutes.length > 1;
|
||||
|
||||
String? get _singleClaimsRoute {
|
||||
final routes = _claimsSubMenuRoutes;
|
||||
if (routes.length == 1) return routes.first.route;
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> logout(BuildContext context) async {
|
||||
// final prefs = await SharedPreferences.getInstance();
|
||||
// final String? hrtoken = prefs.getString('_postToken');
|
||||
@ -149,6 +167,8 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
}
|
||||
|
||||
void _showClaimsSubMenu(GlobalKey key) {
|
||||
if (!_hasMultipleClaimsSubMenus) return;
|
||||
|
||||
if (_claimsMenuSuppressUntil != null &&
|
||||
DateTime.now().isBefore(_claimsMenuSuppressUntil!)) {
|
||||
return;
|
||||
@ -161,6 +181,7 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
if (box == null) return;
|
||||
|
||||
final offset = box.localToGlobal(Offset.zero);
|
||||
final subMenus = _claimsSubMenuRoutes;
|
||||
_claimsOverlayEntry?.remove();
|
||||
_claimsOverlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
@ -177,7 +198,7 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: 120,
|
||||
width: 200,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
@ -193,10 +214,8 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (claimsSubMenu.contains('EB'))
|
||||
_buildClaimsMenuItem('EB', 'ClaimsPolicies'),
|
||||
if (claimsSubMenu.contains('Non-EB'))
|
||||
_buildClaimsMenuItem('Non-EB', 'nonEBClaimsList'),
|
||||
for (final item in subMenus)
|
||||
_buildClaimsMenuItem(item.label, item.route),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -341,6 +360,9 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
final isClaimsActive =
|
||||
activeRoute == 'ClaimsPolicies' || activeRoute == 'nonEBClaimsList';
|
||||
final itemKey = GlobalKey();
|
||||
final showClaimsSubMenu = isClaims && _hasMultipleClaimsSubMenus;
|
||||
final claimsDirectRoute =
|
||||
isClaims ? (_singleClaimsRoute ?? 'ClaimsPolicies') : null;
|
||||
|
||||
return _SideItem(
|
||||
key: itemKey,
|
||||
@ -355,14 +377,18 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
|
||||
),
|
||||
label: item['label'],
|
||||
isActive: isClaims ? isClaimsActive : activeRoute == item['route'],
|
||||
onTap: isClaims ? null : () => _navigate(item['route']),
|
||||
onHoverEnter: isClaims
|
||||
onTap: showClaimsSubMenu
|
||||
? null
|
||||
: () => _navigate(
|
||||
claimsDirectRoute ?? item['route'] as String,
|
||||
),
|
||||
onHoverEnter: showClaimsSubMenu
|
||||
? () {
|
||||
_isHoveringClaimsItem = true;
|
||||
_showClaimsSubMenu(itemKey);
|
||||
}
|
||||
: null,
|
||||
onHoverExit: isClaims
|
||||
onHoverExit: showClaimsSubMenu
|
||||
? () {
|
||||
_isHoveringClaimsItem = false;
|
||||
_scheduleCloseClaimsMenu();
|
||||
@ -443,7 +469,6 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
|
||||
bool _isLoading = false;
|
||||
List<Map<String, dynamic>> _level1Contacts = [];
|
||||
List<Map<String, dynamic>> _level2Contacts = [];
|
||||
Map<String, String> _hospitalLinks = {};
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
@ -487,7 +512,6 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
|
||||
setState(() {
|
||||
_level1Contacts = _parseContacts(data['level_1']);
|
||||
_level2Contacts = _parseContacts(data['level_2']);
|
||||
_hospitalLinks = _parseHospitalLinks(data['tpa_network_hospitals']);
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
@ -516,25 +540,6 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
Map<String, String> _parseHospitalLinks(dynamic raw) {
|
||||
if (raw is! Map) return {};
|
||||
final parsed = <String, String>{};
|
||||
raw.forEach((key, value) {
|
||||
final name = key.toString().trim();
|
||||
final url = value?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty && url.isNotEmpty) {
|
||||
parsed[name] = url;
|
||||
}
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
|
||||
Future<void> _openHospitalUrl(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
await launchUrl(uri, webOnlyWindowName: '_blank');
|
||||
}
|
||||
|
||||
Widget _buildContactCard(Map<String, dynamic> contact) {
|
||||
final name = contact['first_name']?.toString() ?? '-';
|
||||
final email = contact['email']?.toString() ?? '-';
|
||||
@ -649,151 +654,35 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHospitalListContent() {
|
||||
if (!widget.isPostUser) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No Network Hospital List',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading) {
|
||||
return const SizedBox(
|
||||
height: 180,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.poppins(fontSize: 14, color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hospitalLinks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No Network Hospital List',
|
||||
style: GoogleFonts.poppins(fontSize: 14, color: Colors.black54),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _hospitalLinks.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _hospitalLinks.entries.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => _openHospitalUrl(entry.value),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE8E8E8)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.key,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF009195),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.open_in_new,
|
||||
size: 16,
|
||||
color: Color(0xFF009195),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.65;
|
||||
final cappedMaxHeight = maxHeight.clamp(280.0, 520.0);
|
||||
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Help',
|
||||
style:
|
||||
GoogleFonts.poppins(fontWeight: FontWeight.w600, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TabBar(
|
||||
labelColor: const Color(0xFF009195),
|
||||
unselectedLabelColor: Colors.black54,
|
||||
indicatorColor: const Color(0xFF009195),
|
||||
labelStyle: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: 'Contacts'),
|
||||
Tab(text: 'Network Hospital List'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: cappedMaxHeight),
|
||||
child: TabBarView(
|
||||
children: [
|
||||
Scrollbar(
|
||||
child: SingleChildScrollView(child: _buildContent()),
|
||||
),
|
||||
Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
child: _buildHospitalListContent(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF009195),
|
||||
),
|
||||
child: Text('Close', style: GoogleFonts.poppins(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Text(
|
||||
'Help',
|
||||
style: GoogleFonts.poppins(fontWeight: FontWeight.w600, fontSize: 18),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: cappedMaxHeight),
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(child: _buildContent()),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF009195),
|
||||
),
|
||||
child: Text('Close', style: GoogleFonts.poppins(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/environment.dart';
|
||||
import '../service/token_storage_service.dart';
|
||||
|
||||
class NhanceTopBar extends StatefulWidget implements PreferredSizeWidget {
|
||||
@ -19,6 +22,9 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
||||
List<Map<String, dynamic>> branches = [];
|
||||
Map<String, dynamic>? selectedBranch;
|
||||
|
||||
/// client_id → logo URL
|
||||
final Map<String, String> _logoByClientId = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -29,6 +35,98 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
||||
branches = tokenStorage.getCombinedBranches();
|
||||
selectedBranch = tokenStorage.getSelectedBranch();
|
||||
setState(() {});
|
||||
_loadBranchLogos();
|
||||
}
|
||||
|
||||
String _clientKey(Map<String, dynamic> branch) {
|
||||
return (branch['client_id'] ?? branch['id'] ?? '').toString();
|
||||
}
|
||||
|
||||
String? _logoFromBranch(Map<String, dynamic> branch) {
|
||||
final candidates = [
|
||||
branch['client_logo'],
|
||||
branch['logo'],
|
||||
branch['clientLogo'],
|
||||
];
|
||||
for (final value in candidates) {
|
||||
final url = value?.toString().trim() ?? '';
|
||||
if (url.isNotEmpty) return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _loadBranchLogos() async {
|
||||
final uniqueBranches = <String, Map<String, dynamic>>{};
|
||||
for (final branch in branches) {
|
||||
final key = _clientKey(branch);
|
||||
if (key.isEmpty) continue;
|
||||
uniqueBranches.putIfAbsent(key, () => branch);
|
||||
}
|
||||
|
||||
for (final entry in uniqueBranches.entries) {
|
||||
final key = entry.key;
|
||||
if (_logoByClientId.containsKey(key)) continue;
|
||||
|
||||
final fromBranch = _logoFromBranch(entry.value);
|
||||
if (fromBranch != null) {
|
||||
if (!mounted) return;
|
||||
setState(() => _logoByClientId[key] = fromBranch);
|
||||
continue;
|
||||
}
|
||||
|
||||
final logoUrl = await _fetchClientLogoUrl(entry.value);
|
||||
if (!mounted) return;
|
||||
if (logoUrl != null && logoUrl.isNotEmpty) {
|
||||
setState(() => _logoByClientId[key] = logoUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _fetchClientLogoUrl(Map<String, dynamic> branch) async {
|
||||
try {
|
||||
final token = branch['token']?.toString() ??
|
||||
tokenStorage.getCurrentToken() ??
|
||||
'';
|
||||
if (token.isEmpty) return null;
|
||||
|
||||
final clientId = branch['client_id']?.toString() ?? '';
|
||||
final branchId = branch['client_branch_id']?.toString() ?? '';
|
||||
final preBranchId = branch['pre_branch_id']?.toString() ?? branchId;
|
||||
if (clientId.isEmpty || branchId.isEmpty) return null;
|
||||
|
||||
final isPre = branch['enrollment_type']?.toString() == 'pre';
|
||||
final postClientId = isPre ? '' : clientId;
|
||||
final postBranchId = isPre ? '' : branchId;
|
||||
final preClientId = isPre ? clientId : clientId;
|
||||
final preBranch = isPre ? branchId : preBranchId;
|
||||
|
||||
final url = Uri.parse(
|
||||
'${Environment.apiUrl}getClientDetails'
|
||||
'?post_client_id=$postClientId'
|
||||
'&post_branch_id=$postBranchId'
|
||||
'&pre_client_id=$preClientId'
|
||||
'&pre_branch_id=$preBranch',
|
||||
);
|
||||
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'APP-SIGNATURE':
|
||||
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) return null;
|
||||
final data = jsonDecode(response.body);
|
||||
if (data is! Map || data['status'] != 'success') return null;
|
||||
|
||||
final logo = data['data']?['client']?['client_logo']?.toString().trim();
|
||||
if (logo == null || logo.isEmpty) return null;
|
||||
return logo;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _getBranchSwitchRoute() async {
|
||||
@ -60,19 +158,26 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedLogo = selectedBranch == null
|
||||
? null
|
||||
: _logoByClientId[_clientKey(selectedBranch!)];
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: const Color(0xFFBFEFEF),
|
||||
elevation: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
Image.asset('assets/nhance_client_logo.png', height: 36),
|
||||
Image.asset('assets/nhance_client_logo.png', height: 32),
|
||||
const Spacer(),
|
||||
if (selectedBranch != null)
|
||||
_BranchPopup(
|
||||
clientName: selectedBranch!['client_name'],
|
||||
branchName: selectedBranch!['branch_name'],
|
||||
clientName: selectedBranch!['client_name']?.toString() ?? '',
|
||||
branchName: selectedBranch!['branch_name']?.toString() ?? '',
|
||||
selectedLogoUrl: selectedLogo,
|
||||
branches: branches,
|
||||
logoByClientId: _logoByClientId,
|
||||
clientKeyBuilder: _clientKey,
|
||||
onSelected: _onBranchSelected,
|
||||
),
|
||||
],
|
||||
@ -84,13 +189,19 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
||||
class _BranchPopup extends StatelessWidget {
|
||||
final String clientName;
|
||||
final String branchName;
|
||||
final String? selectedLogoUrl;
|
||||
final List<Map<String, dynamic>> branches;
|
||||
final Map<String, String> logoByClientId;
|
||||
final String Function(Map<String, dynamic>) clientKeyBuilder;
|
||||
final Function(Map<String, dynamic>) onSelected;
|
||||
|
||||
const _BranchPopup({
|
||||
required this.clientName,
|
||||
required this.branchName,
|
||||
required this.selectedLogoUrl,
|
||||
required this.branches,
|
||||
required this.logoByClientId,
|
||||
required this.clientKeyBuilder,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@ -102,13 +213,16 @@ class _BranchPopup extends StatelessWidget {
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (context) {
|
||||
return branches.map((branch) {
|
||||
final logoUrl = logoByClientId[clientKeyBuilder(branch)];
|
||||
return PopupMenuItem<Map<String, dynamic>>(
|
||||
value: branch,
|
||||
child: Row(
|
||||
children: [
|
||||
_BranchLogo(logoUrl: logoUrl, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
branch['client_name'],
|
||||
branch['client_name']?.toString() ?? '',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@ -116,11 +230,14 @@ class _BranchPopup extends StatelessWidget {
|
||||
const SizedBox(width: 10),
|
||||
const Icon(Icons.location_on, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
branch['branch_name'],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
Flexible(
|
||||
child: Text(
|
||||
branch['branch_name']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -128,62 +245,119 @@ class _BranchPopup extends StatelessWidget {
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// CLIENT NAME
|
||||
Text(
|
||||
clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 📍 BRANCH NAME (SELECTED)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
branchName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchLogo(logoUrl: selectedLogoUrl, size: 32),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(width: 6),
|
||||
|
||||
// DROPDOWN ARROW
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 20,
|
||||
color: Colors.orange,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
branchName,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 20,
|
||||
color: Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BranchLogo extends StatelessWidget {
|
||||
final String? logoUrl;
|
||||
final double size;
|
||||
|
||||
const _BranchLogo({
|
||||
required this.logoUrl,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = logoUrl?.trim() ?? '';
|
||||
if (url.isEmpty) {
|
||||
return _placeholder();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => _placeholder(),
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE8F5F5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFFBFEFEF)),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.business,
|
||||
size: size * 0.6,
|
||||
color: const Color(0xFF00999E),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@ class ClaimsOverviewCacheEntry {
|
||||
final String policyId;
|
||||
final Map<String, dynamic> claimsKpiBySlug;
|
||||
final Map<String, dynamic> enrollmentKpiBySlug;
|
||||
final DateTime generatedAt;
|
||||
/// Raw `generated_at` from the claims API (e.g. `01-07-2026`), if present.
|
||||
final String? generatedAt;
|
||||
final String? claimsLoadError;
|
||||
final String? enrollmentLoadError;
|
||||
|
||||
@ -14,19 +15,19 @@ class ClaimsOverviewCacheEntry {
|
||||
required this.policyId,
|
||||
required this.claimsKpiBySlug,
|
||||
required this.enrollmentKpiBySlug,
|
||||
required this.generatedAt,
|
||||
this.generatedAt,
|
||||
this.claimsLoadError,
|
||||
this.enrollmentLoadError,
|
||||
});
|
||||
|
||||
factory ClaimsOverviewCacheEntry.fromJson(Map<String, dynamic> json) {
|
||||
final raw = json['generatedAt']?.toString().trim();
|
||||
return ClaimsOverviewCacheEntry(
|
||||
policyId: json['policyId']?.toString() ?? '',
|
||||
claimsKpiBySlug: Map<String, dynamic>.from(json['claimsKpiBySlug'] ?? {}),
|
||||
enrollmentKpiBySlug:
|
||||
Map<String, dynamic>.from(json['enrollmentKpiBySlug'] ?? {}),
|
||||
generatedAt: DateTime.tryParse(json['generatedAt']?.toString() ?? '') ??
|
||||
DateTime.now(),
|
||||
generatedAt: (raw != null && raw.isNotEmpty) ? raw : null,
|
||||
claimsLoadError: json['claimsLoadError']?.toString(),
|
||||
enrollmentLoadError: json['enrollmentLoadError']?.toString(),
|
||||
);
|
||||
@ -36,14 +37,14 @@ class ClaimsOverviewCacheEntry {
|
||||
'policyId': policyId,
|
||||
'claimsKpiBySlug': claimsKpiBySlug,
|
||||
'enrollmentKpiBySlug': enrollmentKpiBySlug,
|
||||
'generatedAt': generatedAt.toIso8601String(),
|
||||
'generatedAt': generatedAt,
|
||||
'claimsLoadError': claimsLoadError,
|
||||
'enrollmentLoadError': enrollmentLoadError,
|
||||
};
|
||||
}
|
||||
|
||||
abstract final class ClaimsOverviewCache {
|
||||
static const _keyPrefix = 'claims_overview_cache_v1_';
|
||||
static const _keyPrefix = 'claims_overview_cache_v2_';
|
||||
|
||||
static String _key(String branchId, String policyId) =>
|
||||
'$_keyPrefix${branchId}_$policyId';
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import '../../customAppBar/base_layout.dart';
|
||||
import '../../customAppBar/toastHelper.dart';
|
||||
import '../../logger.dart';
|
||||
import '../../service/api_service.dart';
|
||||
import '../../service/secure_pop_scope.dart';
|
||||
import '../../service/token_storage_service.dart';
|
||||
@ -43,6 +44,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
bool _isExportingPdf = false;
|
||||
bool _sessionChecked = false;
|
||||
int _replayToken = 0;
|
||||
int _loadGeneration = 0;
|
||||
int _currentTab = 0;
|
||||
|
||||
ClaimsOverviewViewData _viewData = ClaimsOverviewViewData.empty();
|
||||
@ -53,12 +55,34 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
String? _selectedPolicyId;
|
||||
List<Map<String, dynamic>> _activePolicies = [];
|
||||
Uint8List? _clientLogoBytes;
|
||||
DateTime? _dataGeneratedAt;
|
||||
/// Raw `generated_at` from claims API (e.g. `01-07-2026`).
|
||||
String? _apiGeneratedAt;
|
||||
|
||||
String? get _formattedGeneratedAt {
|
||||
final generatedAt = _dataGeneratedAt;
|
||||
if (generatedAt == null) return null;
|
||||
return DateFormat('d MMM yyyy, h:mm a').format(generatedAt.toLocal());
|
||||
final raw = _apiGeneratedAt?.trim();
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return _formatApiGeneratedAt(raw);
|
||||
}
|
||||
|
||||
/// Formats API `generated_at` for display. Returns null when empty.
|
||||
static String? _formatApiGeneratedAt(String raw) {
|
||||
final value = raw.trim();
|
||||
if (value.isEmpty) return null;
|
||||
for (final pattern in ['dd-MM-yyyy', 'yyyy-MM-dd', 'dd/MM/yyyy']) {
|
||||
try {
|
||||
final parsed = DateFormat(pattern).parseStrict(value);
|
||||
return DateFormat('d MMM yyyy').format(parsed);
|
||||
} catch (_) {}
|
||||
}
|
||||
final iso = DateTime.tryParse(value);
|
||||
if (iso != null) return DateFormat('d MMM yyyy').format(iso);
|
||||
return value;
|
||||
}
|
||||
|
||||
static String? _parseApiGeneratedAt(Map<String, dynamic> response) {
|
||||
final raw = response['generated_at']?.toString().trim();
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
static const _tabs = [
|
||||
@ -108,9 +132,29 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _sessionChecked = true);
|
||||
_logHrActivity('opened_hr_dashboard');
|
||||
await _loadDashboard();
|
||||
}
|
||||
|
||||
Future<void> _logHrActivity(String activity) async {
|
||||
try {
|
||||
final postId = await _tokenService.readValue('empHrId');
|
||||
final preId = await _tokenService.readValue('enrollmentEmpPrimaryId');
|
||||
final token = await _tokenService.getCurrentToken();
|
||||
if (postId == null ||
|
||||
postId.isEmpty ||
|
||||
preId == null ||
|
||||
preId.isEmpty ||
|
||||
token == null ||
|
||||
token.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _apiService.getPostLogHrActivity(postId, preId, token, activity);
|
||||
} catch (e) {
|
||||
logDebug('logHrActivity ($activity) failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
if (_tabController.indexIsChanging) return;
|
||||
final index = _tabController.index;
|
||||
@ -133,10 +177,17 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
});
|
||||
}
|
||||
|
||||
bool _isStaleLoad(int loadId, [String? policyId]) {
|
||||
if (!mounted || loadId != _loadGeneration) return true;
|
||||
if (policyId != null && policyId != _selectedPolicyId) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _loadDashboard({
|
||||
bool reloadPolicies = false,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final loadId = ++_loadGeneration;
|
||||
final isInitialLoad = _replayToken == 0;
|
||||
setState(() {
|
||||
if (isInitialLoad) {
|
||||
@ -151,28 +202,27 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
_clientBranchId = await _tokenService.readValue('empClientBranchId');
|
||||
final hrId = await _tokenService.readValue('empHrId');
|
||||
final token = await _tokenService.getCurrentToken();
|
||||
if (_isStaleLoad(loadId)) return;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
await _apiService.getTokenLoadAPI(token);
|
||||
}
|
||||
if (_isStaleLoad(loadId)) return;
|
||||
|
||||
if (isInitialLoad || reloadPolicies || _activePolicies.isEmpty) {
|
||||
await _loadPolicyList(token ?? '', hrId ?? '');
|
||||
if (isInitialLoad && _activePolicies.isNotEmpty) {
|
||||
_selectedPolicyId =
|
||||
_activePolicies.first['client_policy_id'].toString();
|
||||
}
|
||||
if (!mounted) return;
|
||||
// Keep an existing user selection; only default to first when none set.
|
||||
if (_isStaleLoad(loadId)) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
if (_selectedPolicyId == null) {
|
||||
if (!mounted) return;
|
||||
if (_isStaleLoad(loadId)) return;
|
||||
setState(() {
|
||||
_viewData = ClaimsOverviewViewData.empty(
|
||||
error: 'No active policy found',
|
||||
);
|
||||
_enrollmentViewData = EnrollmentOverviewViewData.empty();
|
||||
_dataGeneratedAt = null;
|
||||
_apiGeneratedAt = null;
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
@ -185,12 +235,13 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
if (forceRefresh) {
|
||||
await ClaimsOverviewCache.clear(branchId, policyId);
|
||||
}
|
||||
if (_isStaleLoad(loadId, policyId)) return;
|
||||
|
||||
if (!forceRefresh) {
|
||||
final cached = await ClaimsOverviewCache.read(branchId, policyId);
|
||||
if (cached != null) {
|
||||
if (_isStaleLoad(loadId, policyId)) return;
|
||||
_applyCacheEntry(cached);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
@ -205,13 +256,15 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
final response = await _apiService.getClaimsCollectionV2All(
|
||||
clientPolicyId: policyId,
|
||||
);
|
||||
if (_isStaleLoad(loadId, policyId)) return;
|
||||
final enrollmentResponse =
|
||||
await _apiService.getEnrollmentCollectionV1All(
|
||||
clientPolicyId: policyId,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (_isStaleLoad(loadId, policyId)) return;
|
||||
|
||||
final apiGeneratedAt = _parseApiGeneratedAt(response);
|
||||
final claimsOk = ClaimsKpiParser.isSuccessResponse(response);
|
||||
var claimsData = await ClaimsOverviewViewData.enrichFromApiResponse(
|
||||
response,
|
||||
@ -220,6 +273,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
clientPolicyId: policyId,
|
||||
),
|
||||
);
|
||||
if (_isStaleLoad(loadId, policyId)) return;
|
||||
var enrollmentData = _parseEnrollmentResponse(enrollmentResponse);
|
||||
|
||||
final selectedPolicy = _selectedPolicy();
|
||||
@ -236,11 +290,11 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
loadError: message,
|
||||
);
|
||||
if (claimsData.kpiBySlug.isEmpty) {
|
||||
if (!mounted) return;
|
||||
if (_isStaleLoad(loadId, policyId)) return;
|
||||
setState(() {
|
||||
_viewData = claimsData;
|
||||
_enrollmentViewData = enrollmentData;
|
||||
_dataGeneratedAt = DateTime.now();
|
||||
_apiGeneratedAt = apiGeneratedAt;
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
_replayToken++;
|
||||
@ -250,35 +304,34 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
}
|
||||
}
|
||||
|
||||
final generatedAt = DateTime.now();
|
||||
await ClaimsOverviewCache.write(
|
||||
branchId,
|
||||
ClaimsOverviewCacheEntry(
|
||||
policyId: policyId,
|
||||
claimsKpiBySlug: claimsData.kpiBySlug,
|
||||
enrollmentKpiBySlug: enrollmentData.kpiBySlug,
|
||||
generatedAt: generatedAt,
|
||||
generatedAt: apiGeneratedAt,
|
||||
claimsLoadError: claimsData.loadError,
|
||||
enrollmentLoadError: enrollmentData.loadError,
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (_isStaleLoad(loadId, policyId)) return;
|
||||
setState(() {
|
||||
_viewData = claimsData;
|
||||
_enrollmentViewData = enrollmentData;
|
||||
_dataGeneratedAt = generatedAt;
|
||||
_apiGeneratedAt = apiGeneratedAt;
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
_replayToken++;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
if (_isStaleLoad(loadId)) return;
|
||||
setState(() {
|
||||
_viewData = ClaimsOverviewViewData.empty(error: e.toString());
|
||||
_enrollmentViewData =
|
||||
EnrollmentOverviewViewData.empty(error: e.toString());
|
||||
_dataGeneratedAt = null;
|
||||
_apiGeneratedAt = null;
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
});
|
||||
@ -295,7 +348,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
kpiBySlug: Map<String, dynamic>.from(entry.enrollmentKpiBySlug),
|
||||
loadError: entry.enrollmentLoadError,
|
||||
);
|
||||
_dataGeneratedAt = entry.generatedAt;
|
||||
_apiGeneratedAt = entry.generatedAt;
|
||||
}
|
||||
|
||||
Future<void> _loadPolicyList(String token, String hrId) async {
|
||||
@ -468,6 +521,8 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
policyId: _selectedPolicyId ?? '',
|
||||
dashboardInfo: ClaimsPdfDashboardInfo(
|
||||
policyLabel: _selectedPolicyLabel(),
|
||||
clientName: _tokenService.getSelectedBranch()?['client_name']?.toString(),
|
||||
branchName: _tokenService.getSelectedBranch()?['branch_name']?.toString(),
|
||||
misCreationDate: _formattedGeneratedAt,
|
||||
),
|
||||
clientLogoBytes: _clientLogoBytes,
|
||||
@ -477,6 +532,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
});
|
||||
},
|
||||
);
|
||||
await _logHrActivity('export_hr_dashboard_data_pdf');
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@ -636,7 +692,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Real-time policy & claims analytics',
|
||||
'Policy & Claims Analytics',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
color: ClaimsOverviewTheme.textSecondary,
|
||||
|
||||
@ -50,15 +50,21 @@ const _minCaptureHeight = 360.0;
|
||||
const _captureTimeout = Duration(seconds: 20);
|
||||
const _pdfEmbedMaxWidth = 960;
|
||||
const _logoAsset = 'assets/Nhance-Logo-Final 1.png';
|
||||
const _pdfLogoHeight = 28.0;
|
||||
const _pdfPageMargin = pw.EdgeInsets.fromLTRB(10, 14, 10, 14);
|
||||
|
||||
typedef ClaimsPdfExportProgress = void Function(int current, int total, String label);
|
||||
|
||||
class ClaimsPdfDashboardInfo {
|
||||
final String policyLabel;
|
||||
final String? clientName;
|
||||
final String? branchName;
|
||||
final String? misCreationDate;
|
||||
|
||||
const ClaimsPdfDashboardInfo({
|
||||
required this.policyLabel,
|
||||
this.clientName,
|
||||
this.branchName,
|
||||
this.misCreationDate,
|
||||
});
|
||||
}
|
||||
@ -128,27 +134,30 @@ Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
|
||||
|
||||
final doc = pw.Document();
|
||||
|
||||
for (final capture in input.captures) {
|
||||
for (var i = 0; i < input.captures.length; i++) {
|
||||
final capture = input.captures[i];
|
||||
final embed = _compressTabImageForPdf(capture.png);
|
||||
final isFirstPage = i == 0;
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4.landscape,
|
||||
margin: const pw.EdgeInsets.all(24),
|
||||
margin: _pdfPageMargin,
|
||||
build: (ctx) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_pdfDashboardHeader(
|
||||
clientLogo: clientLogo,
|
||||
logo: logo,
|
||||
info: input.dashboardInfo,
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
if (isFirstPage) ...[
|
||||
_pdfDashboardHeader(
|
||||
clientLogo: clientLogo,
|
||||
logo: logo,
|
||||
info: input.dashboardInfo,
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
],
|
||||
pw.Expanded(
|
||||
child: pw.Center(
|
||||
child: pw.Image(
|
||||
pw.MemoryImage(embed),
|
||||
fit: pw.BoxFit.contain,
|
||||
),
|
||||
child: pw.Image(
|
||||
pw.MemoryImage(embed),
|
||||
fit: pw.BoxFit.fitWidth,
|
||||
alignment: pw.Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -183,25 +192,27 @@ Future<Uint8List> _buildPdfBytesWithYields(
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final embed = _compressTabImageForPdf(capture.png);
|
||||
final isFirstPage = i == 0;
|
||||
doc.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4.landscape,
|
||||
margin: const pw.EdgeInsets.all(24),
|
||||
margin: _pdfPageMargin,
|
||||
build: (ctx) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_pdfDashboardHeader(
|
||||
clientLogo: clientLogo,
|
||||
logo: logo,
|
||||
info: input.dashboardInfo,
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
if (isFirstPage) ...[
|
||||
_pdfDashboardHeader(
|
||||
clientLogo: clientLogo,
|
||||
logo: logo,
|
||||
info: input.dashboardInfo,
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
],
|
||||
pw.Expanded(
|
||||
child: pw.Center(
|
||||
child: pw.Image(
|
||||
pw.MemoryImage(embed),
|
||||
fit: pw.BoxFit.contain,
|
||||
),
|
||||
child: pw.Image(
|
||||
pw.MemoryImage(embed),
|
||||
fit: pw.BoxFit.fitWidth,
|
||||
alignment: pw.Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -410,25 +421,60 @@ pw.Widget _pdfDashboardHeader({
|
||||
}) {
|
||||
final misDate = info.misCreationDate?.trim();
|
||||
final hasMisDate = misDate != null && misDate.isNotEmpty;
|
||||
final clientName = info.clientName?.trim() ?? '';
|
||||
final branchName = info.branchName?.trim() ?? '';
|
||||
final hasClientMeta = clientName.isNotEmpty || branchName.isNotEmpty;
|
||||
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (clientLogo != null)
|
||||
pw.Container(
|
||||
height: 28,
|
||||
constraints: const pw.BoxConstraints(maxWidth: 120),
|
||||
height: _pdfLogoHeight,
|
||||
alignment: pw.Alignment.centerLeft,
|
||||
child: pw.Image(clientLogo, fit: pw.BoxFit.contain),
|
||||
child: pw.Image(clientLogo, height: _pdfLogoHeight, fit: pw.BoxFit.contain),
|
||||
)
|
||||
else
|
||||
pw.SizedBox.shrink(),
|
||||
pw.SizedBox(height: _pdfLogoHeight),
|
||||
if (hasClientMeta) ...[
|
||||
pw.SizedBox(width: 4),
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
children: [
|
||||
if (clientName.isNotEmpty)
|
||||
pw.Text(
|
||||
clientName,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _pdfTextPrimary,
|
||||
),
|
||||
),
|
||||
if (branchName.isNotEmpty)
|
||||
pw.Text(
|
||||
branchName,
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 8,
|
||||
color: _pdfTextSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else
|
||||
pw.Spacer(),
|
||||
if (logo != null)
|
||||
pw.Image(logo, height: 28, fit: pw.BoxFit.contain),
|
||||
pw.Container(
|
||||
height: _pdfLogoHeight,
|
||||
width: _pdfLogoHeight * 3.2,
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Image(logo, fit: pw.BoxFit.contain),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
@ -451,7 +497,7 @@ pw.Widget _pdfDashboardHeader({
|
||||
crossAxisAlignment: pw.WrapCrossAlignment.center,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Real-time policy & claims analytics',
|
||||
'Policy & Claims Analytics',
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 9,
|
||||
color: _pdfTextSecondary,
|
||||
@ -486,7 +532,7 @@ pw.Widget _pdfDashboardHeader({
|
||||
),
|
||||
pw.SizedBox(width: 12),
|
||||
pw.Text(
|
||||
'Branch : ${info.policyLabel}',
|
||||
'Policy No : ${info.policyLabel}',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
|
||||
@ -105,6 +105,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
bool _isLoading = false;
|
||||
bool _isSendingReminder = false;
|
||||
bool _isLoadingPolicyTerms = false;
|
||||
bool _isLoadingHospitalList = false;
|
||||
// dynamic clintID;
|
||||
late TabController _tabController;
|
||||
// List<dynamic> dataPolicy = [];
|
||||
@ -594,6 +595,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
row['emp_code']?.toString().toLowerCase().contains(lowerQuery) ==
|
||||
true ||
|
||||
row['uhid']?.toString().toLowerCase().contains(lowerQuery) == true ||
|
||||
row['tpa_id']?.toString().toLowerCase().contains(lowerQuery) == true ||
|
||||
row['relationship']?.toString().toLowerCase().contains(lowerQuery) ==
|
||||
true ||
|
||||
row['formatted_dob']
|
||||
@ -652,12 +654,14 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
|
||||
void exportToCsv(List<Map<String, dynamic>> data) {
|
||||
List<List<String>> rows = [];
|
||||
final isPost = localTokenType == 'post';
|
||||
final idHeader = isPost ? 'TPA ID' : 'UHID';
|
||||
|
||||
// Header
|
||||
rows.add([
|
||||
'Emp Code',
|
||||
'Name',
|
||||
'UHID',
|
||||
idHeader,
|
||||
'Relationship',
|
||||
'Date Of Birth',
|
||||
'Gender',
|
||||
@ -671,7 +675,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
rows.add([
|
||||
item['emp_code'] ?? '',
|
||||
item['name'] ?? '',
|
||||
item['uhid'] ?? '',
|
||||
isPost ? (item['tpa_id'] ?? '') : (item['uhid'] ?? ''),
|
||||
item['relationship'] ?? '',
|
||||
item['formatted_dob'] ?? '',
|
||||
item['gender'] ?? '',
|
||||
@ -915,6 +919,76 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
return _capitalize(status);
|
||||
}
|
||||
|
||||
Future<void> _openNetworkHospitalListDialog() async {
|
||||
setState(() => _isLoadingHospitalList = true);
|
||||
|
||||
try {
|
||||
final clientId = await tokenService.readValue('empClientId');
|
||||
final token = localToken ??
|
||||
widget.Token ??
|
||||
tokenService.getCurrentToken() ??
|
||||
'';
|
||||
|
||||
if (clientId == null ||
|
||||
clientId.toString().isEmpty ||
|
||||
token.toString().isEmpty) {
|
||||
if (mounted) {
|
||||
ToastHelper.showErrorToast(context, 'Client details not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final response = await apiService.getClientRMApi(
|
||||
clientId.toString(),
|
||||
token.toString(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (response['status'] != 'success' || response['data'] is! Map) {
|
||||
ToastHelper.showErrorToast(
|
||||
context,
|
||||
response['message']?.toString() ??
|
||||
'Failed to load network hospital list',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = Map<String, dynamic>.from(response['data'] as Map);
|
||||
final hospitalLinks = _parseHospitalLinks(data['tpa_network_hospitals']);
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _NetworkHospitalListDialog(
|
||||
hospitalLinks: hospitalLinks,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logDebug('Network hospital list failed: $e');
|
||||
if (mounted) {
|
||||
ToastHelper.showErrorToast(
|
||||
context,
|
||||
'Failed to load network hospital list',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingHospitalList = false);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _parseHospitalLinks(dynamic raw) {
|
||||
if (raw is! Map) return {};
|
||||
final parsed = <String, String>{};
|
||||
raw.forEach((key, value) {
|
||||
final name = key.toString().trim();
|
||||
final url = value?.toString().trim() ?? '';
|
||||
if (name.isNotEmpty && url.isNotEmpty) {
|
||||
parsed[name] = url;
|
||||
}
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
|
||||
Future<void> _openPolicyTermsDialog() async {
|
||||
setState(() => _isLoadingPolicyTerms = true);
|
||||
|
||||
@ -1329,7 +1403,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 142,
|
||||
width: 210,
|
||||
height: 37,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSendingReminder
|
||||
@ -1355,7 +1429,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Reminder',
|
||||
'Reminder Mail Template',
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: GoogleFonts.poppins(
|
||||
@ -1410,6 +1484,46 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 190,
|
||||
height: 37,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoadingHospitalList
|
||||
? null
|
||||
: _openNetworkHospitalListDialog,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF009195),
|
||||
elevation: 0,
|
||||
padding: EdgeInsets.zero,
|
||||
disabledBackgroundColor:
|
||||
const Color(0xFF009195).withValues(alpha: 0.6),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: _isLoadingHospitalList
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Network Hospital List',
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
|
||||
SizedBox(
|
||||
@ -1561,28 +1675,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
if (localTokenType == "post")
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF9EBBD),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'Premium - ₹${localTotalPremium}',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF009195),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildCompactSearchField(),
|
||||
],
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _buildCompactSearchField(),
|
||||
),
|
||||
),
|
||||
|
||||
@ -1615,20 +1710,6 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
|
||||
// ===================== FOOTER TEXT =====================
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
right: 16,
|
||||
child: Text(
|
||||
'(* Premium may vary subject to claims)',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11,
|
||||
color: Colors.red,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -2136,6 +2217,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
pinned: true,
|
||||
delegate: _CDHeaderDelegate(
|
||||
showUHID: localPolicyTypeId != '6' && localPolicyTypeId != '7',
|
||||
uhidHeaderLabel: localTokenType == 'post' ? 'TPA ID' : 'UHID',
|
||||
showLoggedIn: localTokenType == "pre",
|
||||
showAction:
|
||||
localTokenType != "pre" && (hasAnyEcardLink || hasModule),
|
||||
@ -2185,11 +2267,16 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
),
|
||||
),
|
||||
|
||||
/// UHID
|
||||
/// UHID / TPA ID
|
||||
if (localPolicyTypeId != '6' && localPolicyTypeId != '7')
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(item['uhid'] ?? '-', style: _dataBold),
|
||||
child: Text(
|
||||
localTokenType == 'post'
|
||||
? (item['tpa_id'] ?? '-')
|
||||
: (item['uhid'] ?? '-'),
|
||||
style: _dataBold,
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
@ -3176,11 +3263,13 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
|
||||
class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
final bool showUHID;
|
||||
final String uhidHeaderLabel;
|
||||
final bool showLoggedIn;
|
||||
final bool showAction;
|
||||
|
||||
_CDHeaderDelegate({
|
||||
required this.showUHID,
|
||||
this.uhidHeaderLabel = 'UHID',
|
||||
required this.showLoggedIn,
|
||||
required this.showAction,
|
||||
});
|
||||
@ -3201,7 +3290,7 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
child: Row(
|
||||
children: [
|
||||
_headerCell('Name', 3),
|
||||
if (showUHID) _headerCell('UHID', 2),
|
||||
if (showUHID) _headerCell(uhidHeaderLabel, 2),
|
||||
_headerCell('Relationship', 2),
|
||||
_headerCell('Date Of Birth', 2),
|
||||
_headerCell('Gender', 2),
|
||||
@ -3234,6 +3323,101 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
true;
|
||||
}
|
||||
|
||||
class _NetworkHospitalListDialog extends StatelessWidget {
|
||||
final Map<String, String> hospitalLinks;
|
||||
|
||||
const _NetworkHospitalListDialog({required this.hospitalLinks});
|
||||
|
||||
Future<void> _openHospitalUrl(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
await launchUrl(uri, webOnlyWindowName: '_blank');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.65;
|
||||
final cappedMaxHeight = maxHeight.clamp(280.0, 520.0);
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Text(
|
||||
'Network Hospital List',
|
||||
style: GoogleFonts.poppins(fontWeight: FontWeight.w600, fontSize: 18),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: cappedMaxHeight),
|
||||
child: hospitalLinks.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No Network Hospital List',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 14,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: hospitalLinks.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = hospitalLinks.entries.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => _openHospitalUrl(entry.value),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE8E8E8)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.key,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF009195),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.open_in_new,
|
||||
size: 16,
|
||||
color: Color(0xFF009195),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF009195),
|
||||
),
|
||||
child: Text(
|
||||
'Close',
|
||||
style: GoogleFonts.poppins(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PolicyTermsDialog extends StatelessWidget {
|
||||
final Map<String, String> terms;
|
||||
|
||||
|
||||
@ -544,14 +544,6 @@ class _policiesState extends State<policies>
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Text(
|
||||
'* Premium may vary subject to claims',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 10, color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -579,7 +571,7 @@ class _PolicyGrid extends StatelessWidget {
|
||||
final Function(String clientPolicyId)? onBulkDownload;
|
||||
|
||||
static const double _enrollmentCardHeight = 156;
|
||||
static const double _activePolicyCardHeight = 170;
|
||||
static const double _activePolicyCardHeight = 148;
|
||||
|
||||
const _PolicyGrid({
|
||||
super.key,
|
||||
@ -647,7 +639,7 @@ class _PolicyGrid extends StatelessWidget {
|
||||
/// ✅ EB section
|
||||
if (ebPolicies.isNotEmpty) ...[
|
||||
Text(
|
||||
'EB Policies',
|
||||
'Employee Benefits',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
@ -666,7 +658,7 @@ class _PolicyGrid extends StatelessWidget {
|
||||
/// ✅ Non-EB section
|
||||
if (nonEbPolicies.isNotEmpty) ...[
|
||||
Text(
|
||||
'Non-EB Policies',
|
||||
'Non-Employee Benefits',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
@ -1129,57 +1121,11 @@ class _ActivePolicyCardNew extends StatelessWidget {
|
||||
: const Color(0xFFE9F6FB), // EB
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// PREMIUM
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Premium - ₹${data['total_premium'] ?? ''}*',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF009195),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 🔥 ICON FLOATING ABOVE CARD
|
||||
if (data['is_ecard_bulk_download'] == 1)
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
logDebug(
|
||||
'ICON CLICKED ${data['client_policy_id']}');
|
||||
onBulkDownload?.call(
|
||||
data['client_policy_id'].toString(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF009195),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.credit_card,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
/// POLICY NO + ICON
|
||||
/// POLICY NO + optional ecard bulk download
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@ -1193,10 +1139,32 @@ class _ActivePolicyCardNew extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (data['is_ecard_bulk_download'] == 1)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
logDebug(
|
||||
'ICON CLICKED ${data['client_policy_id']}');
|
||||
onBulkDownload?.call(
|
||||
data['client_policy_id'].toString(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF009195),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.credit_card,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
/// Insurer
|
||||
Tooltip(
|
||||
@ -1232,7 +1200,7 @@ class _ActivePolicyCardNew extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
/// DATE RANGE
|
||||
Text(
|
||||
@ -1243,7 +1211,7 @@ class _ActivePolicyCardNew extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
/// ACTIVE / INACTIVE
|
||||
Row(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user