hr feedbacks

This commit is contained in:
Surendiran 2026-08-03 10:20:03 +05:30
parent 1e97c41545
commit 5ab200618c
7 changed files with 717 additions and 399 deletions

View File

@ -7,7 +7,6 @@ import 'package:nhancepolicy/service/api_service.dart';
import 'package:nhancepolicy/service/svg_service.dart'; import 'package:nhancepolicy/service/svg_service.dart';
import 'package:nhancepolicy/service/token_storage_service.dart'; import 'package:nhancepolicy/service/token_storage_service.dart';
import 'package:nhancepolicy/logger.dart'; import 'package:nhancepolicy/logger.dart';
import 'package:url_launcher/url_launcher.dart';
class NhanceSideBar extends StatefulWidget { class NhanceSideBar extends StatefulWidget {
const NhanceSideBar({super.key}); 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 // CLAIMS only when module 4 and at least one sub-menu is allowed
if (postModules.contains(4) && if (postModules.contains(4) && _claimsSubMenuRoutes.isNotEmpty) {
(claimsSubMenu.contains('EB') || claimsSubMenu.contains('Non-EB'))) {
items.add({ items.add({
'route': 'ClaimsPolicies', 'route': 'ClaimsPolicies',
'label': 'Claims', '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 { Future<void> logout(BuildContext context) async {
// final prefs = await SharedPreferences.getInstance(); // final prefs = await SharedPreferences.getInstance();
// final String? hrtoken = prefs.getString('_postToken'); // final String? hrtoken = prefs.getString('_postToken');
@ -149,6 +167,8 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
} }
void _showClaimsSubMenu(GlobalKey key) { void _showClaimsSubMenu(GlobalKey key) {
if (!_hasMultipleClaimsSubMenus) return;
if (_claimsMenuSuppressUntil != null && if (_claimsMenuSuppressUntil != null &&
DateTime.now().isBefore(_claimsMenuSuppressUntil!)) { DateTime.now().isBefore(_claimsMenuSuppressUntil!)) {
return; return;
@ -161,6 +181,7 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
if (box == null) return; if (box == null) return;
final offset = box.localToGlobal(Offset.zero); final offset = box.localToGlobal(Offset.zero);
final subMenus = _claimsSubMenuRoutes;
_claimsOverlayEntry?.remove(); _claimsOverlayEntry?.remove();
_claimsOverlayEntry = OverlayEntry( _claimsOverlayEntry = OverlayEntry(
builder: (context) => Positioned( builder: (context) => Positioned(
@ -177,7 +198,7 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Container( child: Container(
width: 120, width: 200,
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
@ -193,10 +214,8 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (claimsSubMenu.contains('EB')) for (final item in subMenus)
_buildClaimsMenuItem('EB', 'ClaimsPolicies'), _buildClaimsMenuItem(item.label, item.route),
if (claimsSubMenu.contains('Non-EB'))
_buildClaimsMenuItem('Non-EB', 'nonEBClaimsList'),
], ],
), ),
), ),
@ -341,6 +360,9 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
final isClaimsActive = final isClaimsActive =
activeRoute == 'ClaimsPolicies' || activeRoute == 'nonEBClaimsList'; activeRoute == 'ClaimsPolicies' || activeRoute == 'nonEBClaimsList';
final itemKey = GlobalKey(); final itemKey = GlobalKey();
final showClaimsSubMenu = isClaims && _hasMultipleClaimsSubMenus;
final claimsDirectRoute =
isClaims ? (_singleClaimsRoute ?? 'ClaimsPolicies') : null;
return _SideItem( return _SideItem(
key: itemKey, key: itemKey,
@ -355,14 +377,18 @@ class _NhanceSideBarState extends State<NhanceSideBar> {
), ),
label: item['label'], label: item['label'],
isActive: isClaims ? isClaimsActive : activeRoute == item['route'], isActive: isClaims ? isClaimsActive : activeRoute == item['route'],
onTap: isClaims ? null : () => _navigate(item['route']), onTap: showClaimsSubMenu
onHoverEnter: isClaims ? null
: () => _navigate(
claimsDirectRoute ?? item['route'] as String,
),
onHoverEnter: showClaimsSubMenu
? () { ? () {
_isHoveringClaimsItem = true; _isHoveringClaimsItem = true;
_showClaimsSubMenu(itemKey); _showClaimsSubMenu(itemKey);
} }
: null, : null,
onHoverExit: isClaims onHoverExit: showClaimsSubMenu
? () { ? () {
_isHoveringClaimsItem = false; _isHoveringClaimsItem = false;
_scheduleCloseClaimsMenu(); _scheduleCloseClaimsMenu();
@ -443,7 +469,6 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
bool _isLoading = false; bool _isLoading = false;
List<Map<String, dynamic>> _level1Contacts = []; List<Map<String, dynamic>> _level1Contacts = [];
List<Map<String, dynamic>> _level2Contacts = []; List<Map<String, dynamic>> _level2Contacts = [];
Map<String, String> _hospitalLinks = {};
String? _errorMessage; String? _errorMessage;
@override @override
@ -487,7 +512,6 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
setState(() { setState(() {
_level1Contacts = _parseContacts(data['level_1']); _level1Contacts = _parseContacts(data['level_1']);
_level2Contacts = _parseContacts(data['level_2']); _level2Contacts = _parseContacts(data['level_2']);
_hospitalLinks = _parseHospitalLinks(data['tpa_network_hospitals']);
_isLoading = false; _isLoading = false;
}); });
} else { } else {
@ -516,25 +540,6 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
.toList(); .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) { Widget _buildContactCard(Map<String, dynamic> contact) {
final name = contact['first_name']?.toString() ?? '-'; final name = contact['first_name']?.toString() ?? '-';
final email = contact['email']?.toString() ?? '-'; final email = contact['email']?.toString() ?? '-';
@ -649,139 +654,24 @@ 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxHeight = MediaQuery.of(context).size.height * 0.65; final maxHeight = MediaQuery.of(context).size.height * 0.65;
final cappedMaxHeight = maxHeight.clamp(280.0, 520.0); final cappedMaxHeight = maxHeight.clamp(280.0, 520.0);
return DefaultTabController( return AlertDialog(
length: 2,
child: AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Column( title: Text(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Help', 'Help',
style: style: GoogleFonts.poppins(fontWeight: FontWeight.w600, fontSize: 18),
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( content: SizedBox(
width: 560, width: 560,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: cappedMaxHeight), constraints: BoxConstraints(maxHeight: cappedMaxHeight),
child: TabBarView( child: Scrollbar(
children: [
Scrollbar(
child: SingleChildScrollView(child: _buildContent()), child: SingleChildScrollView(child: _buildContent()),
), ),
Scrollbar(
child: SingleChildScrollView(
child: _buildHospitalListContent(),
),
),
],
),
), ),
), ),
actions: [ actions: [
@ -793,7 +683,6 @@ class _HelpContactDialogState extends State<_HelpContactDialog> {
child: Text('Close', style: GoogleFonts.poppins(color: Colors.white)), child: Text('Close', style: GoogleFonts.poppins(color: Colors.white)),
), ),
], ],
),
); );
} }
} }

View File

@ -1,6 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config/environment.dart';
import '../service/token_storage_service.dart'; import '../service/token_storage_service.dart';
class NhanceTopBar extends StatefulWidget implements PreferredSizeWidget { class NhanceTopBar extends StatefulWidget implements PreferredSizeWidget {
@ -19,6 +22,9 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
List<Map<String, dynamic>> branches = []; List<Map<String, dynamic>> branches = [];
Map<String, dynamic>? selectedBranch; Map<String, dynamic>? selectedBranch;
/// client_id logo URL
final Map<String, String> _logoByClientId = {};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -29,6 +35,98 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
branches = tokenStorage.getCombinedBranches(); branches = tokenStorage.getCombinedBranches();
selectedBranch = tokenStorage.getSelectedBranch(); selectedBranch = tokenStorage.getSelectedBranch();
setState(() {}); 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 { Future<String> _getBranchSwitchRoute() async {
@ -60,19 +158,26 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectedLogo = selectedBranch == null
? null
: _logoByClientId[_clientKey(selectedBranch!)];
return AppBar( return AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
backgroundColor: const Color(0xFFBFEFEF), backgroundColor: const Color(0xFFBFEFEF),
elevation: 0, elevation: 0,
title: Row( title: Row(
children: [ children: [
Image.asset('assets/nhance_client_logo.png', height: 36), Image.asset('assets/nhance_client_logo.png', height: 32),
const Spacer(), const Spacer(),
if (selectedBranch != null) if (selectedBranch != null)
_BranchPopup( _BranchPopup(
clientName: selectedBranch!['client_name'], clientName: selectedBranch!['client_name']?.toString() ?? '',
branchName: selectedBranch!['branch_name'], branchName: selectedBranch!['branch_name']?.toString() ?? '',
selectedLogoUrl: selectedLogo,
branches: branches, branches: branches,
logoByClientId: _logoByClientId,
clientKeyBuilder: _clientKey,
onSelected: _onBranchSelected, onSelected: _onBranchSelected,
), ),
], ],
@ -84,13 +189,19 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
class _BranchPopup extends StatelessWidget { class _BranchPopup extends StatelessWidget {
final String clientName; final String clientName;
final String branchName; final String branchName;
final String? selectedLogoUrl;
final List<Map<String, dynamic>> branches; final List<Map<String, dynamic>> branches;
final Map<String, String> logoByClientId;
final String Function(Map<String, dynamic>) clientKeyBuilder;
final Function(Map<String, dynamic>) onSelected; final Function(Map<String, dynamic>) onSelected;
const _BranchPopup({ const _BranchPopup({
required this.clientName, required this.clientName,
required this.branchName, required this.branchName,
required this.selectedLogoUrl,
required this.branches, required this.branches,
required this.logoByClientId,
required this.clientKeyBuilder,
required this.onSelected, required this.onSelected,
}); });
@ -102,13 +213,16 @@ class _BranchPopup extends StatelessWidget {
onSelected: onSelected, onSelected: onSelected,
itemBuilder: (context) { itemBuilder: (context) {
return branches.map((branch) { return branches.map((branch) {
final logoUrl = logoByClientId[clientKeyBuilder(branch)];
return PopupMenuItem<Map<String, dynamic>>( return PopupMenuItem<Map<String, dynamic>>(
value: branch, value: branch,
child: Row( child: Row(
children: [ children: [
_BranchLogo(logoUrl: logoUrl, size: 24),
const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
branch['client_name'], branch['client_name']?.toString() ?? '',
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -116,21 +230,29 @@ class _BranchPopup extends StatelessWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
const Icon(Icons.location_on, size: 14, color: Colors.grey), const Icon(Icons.location_on, size: 14, color: Colors.grey),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Flexible(
branch['branch_name'], child: Text(
branch['branch_name']?.toString() ?? '',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey, color: Colors.grey,
), ),
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),
); );
}).toList(); }).toList();
}, },
child: Container( child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_BranchLogo(logoUrl: selectedLogoUrl, size: 32),
const SizedBox(width: 8),
Container(
height: 40, height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
@ -144,7 +266,6 @@ class _BranchPopup extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// CLIENT NAME
Text( Text(
clientName, clientName,
style: const TextStyle( style: const TextStyle(
@ -152,10 +273,7 @@ class _BranchPopup extends StatelessWidget {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// 📍 BRANCH NAME (SELECTED)
Row( Row(
children: [ children: [
const Icon( const Icon(
@ -173,10 +291,7 @@ class _BranchPopup extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
// DROPDOWN ARROW
const Icon( const Icon(
Icons.keyboard_arrow_down, Icons.keyboard_arrow_down,
size: 20, size: 20,
@ -185,6 +300,65 @@ class _BranchPopup extends StatelessWidget {
], ],
), ),
), ),
],
),
);
}
}
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),
),
); );
} }
} }

View File

@ -6,7 +6,8 @@ class ClaimsOverviewCacheEntry {
final String policyId; final String policyId;
final Map<String, dynamic> claimsKpiBySlug; final Map<String, dynamic> claimsKpiBySlug;
final Map<String, dynamic> enrollmentKpiBySlug; 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? claimsLoadError;
final String? enrollmentLoadError; final String? enrollmentLoadError;
@ -14,19 +15,19 @@ class ClaimsOverviewCacheEntry {
required this.policyId, required this.policyId,
required this.claimsKpiBySlug, required this.claimsKpiBySlug,
required this.enrollmentKpiBySlug, required this.enrollmentKpiBySlug,
required this.generatedAt, this.generatedAt,
this.claimsLoadError, this.claimsLoadError,
this.enrollmentLoadError, this.enrollmentLoadError,
}); });
factory ClaimsOverviewCacheEntry.fromJson(Map<String, dynamic> json) { factory ClaimsOverviewCacheEntry.fromJson(Map<String, dynamic> json) {
final raw = json['generatedAt']?.toString().trim();
return ClaimsOverviewCacheEntry( return ClaimsOverviewCacheEntry(
policyId: json['policyId']?.toString() ?? '', policyId: json['policyId']?.toString() ?? '',
claimsKpiBySlug: Map<String, dynamic>.from(json['claimsKpiBySlug'] ?? {}), claimsKpiBySlug: Map<String, dynamic>.from(json['claimsKpiBySlug'] ?? {}),
enrollmentKpiBySlug: enrollmentKpiBySlug:
Map<String, dynamic>.from(json['enrollmentKpiBySlug'] ?? {}), Map<String, dynamic>.from(json['enrollmentKpiBySlug'] ?? {}),
generatedAt: DateTime.tryParse(json['generatedAt']?.toString() ?? '') ?? generatedAt: (raw != null && raw.isNotEmpty) ? raw : null,
DateTime.now(),
claimsLoadError: json['claimsLoadError']?.toString(), claimsLoadError: json['claimsLoadError']?.toString(),
enrollmentLoadError: json['enrollmentLoadError']?.toString(), enrollmentLoadError: json['enrollmentLoadError']?.toString(),
); );
@ -36,14 +37,14 @@ class ClaimsOverviewCacheEntry {
'policyId': policyId, 'policyId': policyId,
'claimsKpiBySlug': claimsKpiBySlug, 'claimsKpiBySlug': claimsKpiBySlug,
'enrollmentKpiBySlug': enrollmentKpiBySlug, 'enrollmentKpiBySlug': enrollmentKpiBySlug,
'generatedAt': generatedAt.toIso8601String(), 'generatedAt': generatedAt,
'claimsLoadError': claimsLoadError, 'claimsLoadError': claimsLoadError,
'enrollmentLoadError': enrollmentLoadError, 'enrollmentLoadError': enrollmentLoadError,
}; };
} }
abstract final class ClaimsOverviewCache { 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) => static String _key(String branchId, String policyId) =>
'$_keyPrefix${branchId}_$policyId'; '$_keyPrefix${branchId}_$policyId';

View File

@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import '../../customAppBar/base_layout.dart'; import '../../customAppBar/base_layout.dart';
import '../../customAppBar/toastHelper.dart'; import '../../customAppBar/toastHelper.dart';
import '../../logger.dart';
import '../../service/api_service.dart'; import '../../service/api_service.dart';
import '../../service/secure_pop_scope.dart'; import '../../service/secure_pop_scope.dart';
import '../../service/token_storage_service.dart'; import '../../service/token_storage_service.dart';
@ -43,6 +44,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
bool _isExportingPdf = false; bool _isExportingPdf = false;
bool _sessionChecked = false; bool _sessionChecked = false;
int _replayToken = 0; int _replayToken = 0;
int _loadGeneration = 0;
int _currentTab = 0; int _currentTab = 0;
ClaimsOverviewViewData _viewData = ClaimsOverviewViewData.empty(); ClaimsOverviewViewData _viewData = ClaimsOverviewViewData.empty();
@ -53,12 +55,34 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
String? _selectedPolicyId; String? _selectedPolicyId;
List<Map<String, dynamic>> _activePolicies = []; List<Map<String, dynamic>> _activePolicies = [];
Uint8List? _clientLogoBytes; Uint8List? _clientLogoBytes;
DateTime? _dataGeneratedAt; /// Raw `generated_at` from claims API (e.g. `01-07-2026`).
String? _apiGeneratedAt;
String? get _formattedGeneratedAt { String? get _formattedGeneratedAt {
final generatedAt = _dataGeneratedAt; final raw = _apiGeneratedAt?.trim();
if (generatedAt == null) return null; if (raw == null || raw.isEmpty) return null;
return DateFormat('d MMM yyyy, h:mm a').format(generatedAt.toLocal()); 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 = [ static const _tabs = [
@ -108,9 +132,29 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
if (!mounted) return; if (!mounted) return;
setState(() => _sessionChecked = true); setState(() => _sessionChecked = true);
_logHrActivity('opened_hr_dashboard');
await _loadDashboard(); 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() { void _onTabChanged() {
if (_tabController.indexIsChanging) return; if (_tabController.indexIsChanging) return;
final index = _tabController.index; 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({ Future<void> _loadDashboard({
bool reloadPolicies = false, bool reloadPolicies = false,
bool forceRefresh = false, bool forceRefresh = false,
}) async { }) async {
final loadId = ++_loadGeneration;
final isInitialLoad = _replayToken == 0; final isInitialLoad = _replayToken == 0;
setState(() { setState(() {
if (isInitialLoad) { if (isInitialLoad) {
@ -151,28 +202,27 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
_clientBranchId = await _tokenService.readValue('empClientBranchId'); _clientBranchId = await _tokenService.readValue('empClientBranchId');
final hrId = await _tokenService.readValue('empHrId'); final hrId = await _tokenService.readValue('empHrId');
final token = await _tokenService.getCurrentToken(); final token = await _tokenService.getCurrentToken();
if (_isStaleLoad(loadId)) return;
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
await _apiService.getTokenLoadAPI(token); await _apiService.getTokenLoadAPI(token);
} }
if (_isStaleLoad(loadId)) return;
if (isInitialLoad || reloadPolicies || _activePolicies.isEmpty) { if (isInitialLoad || reloadPolicies || _activePolicies.isEmpty) {
await _loadPolicyList(token ?? '', hrId ?? ''); await _loadPolicyList(token ?? '', hrId ?? '');
if (isInitialLoad && _activePolicies.isNotEmpty) { // Keep an existing user selection; only default to first when none set.
_selectedPolicyId = if (_isStaleLoad(loadId)) return;
_activePolicies.first['client_policy_id'].toString();
}
if (!mounted) return;
setState(() {}); setState(() {});
} }
if (_selectedPolicyId == null) { if (_selectedPolicyId == null) {
if (!mounted) return; if (_isStaleLoad(loadId)) return;
setState(() { setState(() {
_viewData = ClaimsOverviewViewData.empty( _viewData = ClaimsOverviewViewData.empty(
error: 'No active policy found', error: 'No active policy found',
); );
_enrollmentViewData = EnrollmentOverviewViewData.empty(); _enrollmentViewData = EnrollmentOverviewViewData.empty();
_dataGeneratedAt = null; _apiGeneratedAt = null;
_isLoading = false; _isLoading = false;
_isRefreshing = false; _isRefreshing = false;
}); });
@ -185,12 +235,13 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
if (forceRefresh) { if (forceRefresh) {
await ClaimsOverviewCache.clear(branchId, policyId); await ClaimsOverviewCache.clear(branchId, policyId);
} }
if (_isStaleLoad(loadId, policyId)) return;
if (!forceRefresh) { if (!forceRefresh) {
final cached = await ClaimsOverviewCache.read(branchId, policyId); final cached = await ClaimsOverviewCache.read(branchId, policyId);
if (cached != null) { if (cached != null) {
if (_isStaleLoad(loadId, policyId)) return;
_applyCacheEntry(cached); _applyCacheEntry(cached);
if (!mounted) return;
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_isRefreshing = false; _isRefreshing = false;
@ -205,13 +256,15 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
final response = await _apiService.getClaimsCollectionV2All( final response = await _apiService.getClaimsCollectionV2All(
clientPolicyId: policyId, clientPolicyId: policyId,
); );
if (_isStaleLoad(loadId, policyId)) return;
final enrollmentResponse = final enrollmentResponse =
await _apiService.getEnrollmentCollectionV1All( await _apiService.getEnrollmentCollectionV1All(
clientPolicyId: policyId, clientPolicyId: policyId,
); );
if (!mounted) return; if (_isStaleLoad(loadId, policyId)) return;
final apiGeneratedAt = _parseApiGeneratedAt(response);
final claimsOk = ClaimsKpiParser.isSuccessResponse(response); final claimsOk = ClaimsKpiParser.isSuccessResponse(response);
var claimsData = await ClaimsOverviewViewData.enrichFromApiResponse( var claimsData = await ClaimsOverviewViewData.enrichFromApiResponse(
response, response,
@ -220,6 +273,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
clientPolicyId: policyId, clientPolicyId: policyId,
), ),
); );
if (_isStaleLoad(loadId, policyId)) return;
var enrollmentData = _parseEnrollmentResponse(enrollmentResponse); var enrollmentData = _parseEnrollmentResponse(enrollmentResponse);
final selectedPolicy = _selectedPolicy(); final selectedPolicy = _selectedPolicy();
@ -236,11 +290,11 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
loadError: message, loadError: message,
); );
if (claimsData.kpiBySlug.isEmpty) { if (claimsData.kpiBySlug.isEmpty) {
if (!mounted) return; if (_isStaleLoad(loadId, policyId)) return;
setState(() { setState(() {
_viewData = claimsData; _viewData = claimsData;
_enrollmentViewData = enrollmentData; _enrollmentViewData = enrollmentData;
_dataGeneratedAt = DateTime.now(); _apiGeneratedAt = apiGeneratedAt;
_isLoading = false; _isLoading = false;
_isRefreshing = false; _isRefreshing = false;
_replayToken++; _replayToken++;
@ -250,35 +304,34 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
} }
} }
final generatedAt = DateTime.now();
await ClaimsOverviewCache.write( await ClaimsOverviewCache.write(
branchId, branchId,
ClaimsOverviewCacheEntry( ClaimsOverviewCacheEntry(
policyId: policyId, policyId: policyId,
claimsKpiBySlug: claimsData.kpiBySlug, claimsKpiBySlug: claimsData.kpiBySlug,
enrollmentKpiBySlug: enrollmentData.kpiBySlug, enrollmentKpiBySlug: enrollmentData.kpiBySlug,
generatedAt: generatedAt, generatedAt: apiGeneratedAt,
claimsLoadError: claimsData.loadError, claimsLoadError: claimsData.loadError,
enrollmentLoadError: enrollmentData.loadError, enrollmentLoadError: enrollmentData.loadError,
), ),
); );
if (!mounted) return; if (_isStaleLoad(loadId, policyId)) return;
setState(() { setState(() {
_viewData = claimsData; _viewData = claimsData;
_enrollmentViewData = enrollmentData; _enrollmentViewData = enrollmentData;
_dataGeneratedAt = generatedAt; _apiGeneratedAt = apiGeneratedAt;
_isLoading = false; _isLoading = false;
_isRefreshing = false; _isRefreshing = false;
_replayToken++; _replayToken++;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (_isStaleLoad(loadId)) return;
setState(() { setState(() {
_viewData = ClaimsOverviewViewData.empty(error: e.toString()); _viewData = ClaimsOverviewViewData.empty(error: e.toString());
_enrollmentViewData = _enrollmentViewData =
EnrollmentOverviewViewData.empty(error: e.toString()); EnrollmentOverviewViewData.empty(error: e.toString());
_dataGeneratedAt = null; _apiGeneratedAt = null;
_isLoading = false; _isLoading = false;
_isRefreshing = false; _isRefreshing = false;
}); });
@ -295,7 +348,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
kpiBySlug: Map<String, dynamic>.from(entry.enrollmentKpiBySlug), kpiBySlug: Map<String, dynamic>.from(entry.enrollmentKpiBySlug),
loadError: entry.enrollmentLoadError, loadError: entry.enrollmentLoadError,
); );
_dataGeneratedAt = entry.generatedAt; _apiGeneratedAt = entry.generatedAt;
} }
Future<void> _loadPolicyList(String token, String hrId) async { Future<void> _loadPolicyList(String token, String hrId) async {
@ -468,6 +521,8 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
policyId: _selectedPolicyId ?? '', policyId: _selectedPolicyId ?? '',
dashboardInfo: ClaimsPdfDashboardInfo( dashboardInfo: ClaimsPdfDashboardInfo(
policyLabel: _selectedPolicyLabel(), policyLabel: _selectedPolicyLabel(),
clientName: _tokenService.getSelectedBranch()?['client_name']?.toString(),
branchName: _tokenService.getSelectedBranch()?['branch_name']?.toString(),
misCreationDate: _formattedGeneratedAt, misCreationDate: _formattedGeneratedAt,
), ),
clientLogoBytes: _clientLogoBytes, clientLogoBytes: _clientLogoBytes,
@ -477,6 +532,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
}); });
}, },
); );
await _logHrActivity('export_hr_dashboard_data_pdf');
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -636,7 +692,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
Row( Row(
children: [ children: [
Text( Text(
'Real-time policy & claims analytics', 'Policy & Claims Analytics',
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
fontSize: 13, fontSize: 13,
color: ClaimsOverviewTheme.textSecondary, color: ClaimsOverviewTheme.textSecondary,

View File

@ -50,15 +50,21 @@ const _minCaptureHeight = 360.0;
const _captureTimeout = Duration(seconds: 20); const _captureTimeout = Duration(seconds: 20);
const _pdfEmbedMaxWidth = 960; const _pdfEmbedMaxWidth = 960;
const _logoAsset = 'assets/Nhance-Logo-Final 1.png'; 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); typedef ClaimsPdfExportProgress = void Function(int current, int total, String label);
class ClaimsPdfDashboardInfo { class ClaimsPdfDashboardInfo {
final String policyLabel; final String policyLabel;
final String? clientName;
final String? branchName;
final String? misCreationDate; final String? misCreationDate;
const ClaimsPdfDashboardInfo({ const ClaimsPdfDashboardInfo({
required this.policyLabel, required this.policyLabel,
this.clientName,
this.branchName,
this.misCreationDate, this.misCreationDate,
}); });
} }
@ -128,27 +134,30 @@ Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
final doc = pw.Document(); 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 embed = _compressTabImageForPdf(capture.png);
final isFirstPage = i == 0;
doc.addPage( doc.addPage(
pw.Page( pw.Page(
pageFormat: PdfPageFormat.a4.landscape, pageFormat: PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(24), margin: _pdfPageMargin,
build: (ctx) => pw.Column( build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
if (isFirstPage) ...[
_pdfDashboardHeader( _pdfDashboardHeader(
clientLogo: clientLogo, clientLogo: clientLogo,
logo: logo, logo: logo,
info: input.dashboardInfo, info: input.dashboardInfo,
), ),
pw.SizedBox(height: 8), pw.SizedBox(height: 8),
],
pw.Expanded( pw.Expanded(
child: pw.Center(
child: pw.Image( child: pw.Image(
pw.MemoryImage(embed), pw.MemoryImage(embed),
fit: pw.BoxFit.contain, fit: pw.BoxFit.fitWidth,
), alignment: pw.Alignment.topCenter,
), ),
), ),
], ],
@ -183,25 +192,27 @@ Future<Uint8List> _buildPdfBytesWithYields(
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final embed = _compressTabImageForPdf(capture.png); final embed = _compressTabImageForPdf(capture.png);
final isFirstPage = i == 0;
doc.addPage( doc.addPage(
pw.Page( pw.Page(
pageFormat: PdfPageFormat.a4.landscape, pageFormat: PdfPageFormat.a4.landscape,
margin: const pw.EdgeInsets.all(24), margin: _pdfPageMargin,
build: (ctx) => pw.Column( build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
if (isFirstPage) ...[
_pdfDashboardHeader( _pdfDashboardHeader(
clientLogo: clientLogo, clientLogo: clientLogo,
logo: logo, logo: logo,
info: input.dashboardInfo, info: input.dashboardInfo,
), ),
pw.SizedBox(height: 8), pw.SizedBox(height: 8),
],
pw.Expanded( pw.Expanded(
child: pw.Center(
child: pw.Image( child: pw.Image(
pw.MemoryImage(embed), pw.MemoryImage(embed),
fit: pw.BoxFit.contain, fit: pw.BoxFit.fitWidth,
), alignment: pw.Alignment.topCenter,
), ),
), ),
], ],
@ -410,25 +421,60 @@ pw.Widget _pdfDashboardHeader({
}) { }) {
final misDate = info.misCreationDate?.trim(); final misDate = info.misCreationDate?.trim();
final hasMisDate = misDate != null && misDate.isNotEmpty; 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( return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
pw.Row( pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center, crossAxisAlignment: pw.CrossAxisAlignment.center,
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
if (clientLogo != null) if (clientLogo != null)
pw.Container( pw.Container(
height: 28, height: _pdfLogoHeight,
constraints: const pw.BoxConstraints(maxWidth: 120),
alignment: pw.Alignment.centerLeft, alignment: pw.Alignment.centerLeft,
child: pw.Image(clientLogo, fit: pw.BoxFit.contain), child: pw.Image(clientLogo, height: _pdfLogoHeight, fit: pw.BoxFit.contain),
) )
else 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) 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), pw.SizedBox(height: 10),
@ -451,7 +497,7 @@ pw.Widget _pdfDashboardHeader({
crossAxisAlignment: pw.WrapCrossAlignment.center, crossAxisAlignment: pw.WrapCrossAlignment.center,
children: [ children: [
pw.Text( pw.Text(
'Real-time policy & claims analytics', 'Policy & Claims Analytics',
style: const pw.TextStyle( style: const pw.TextStyle(
fontSize: 9, fontSize: 9,
color: _pdfTextSecondary, color: _pdfTextSecondary,
@ -486,7 +532,7 @@ pw.Widget _pdfDashboardHeader({
), ),
pw.SizedBox(width: 12), pw.SizedBox(width: 12),
pw.Text( pw.Text(
'Branch : ${info.policyLabel}', 'Policy No : ${info.policyLabel}',
style: pw.TextStyle( style: pw.TextStyle(
fontSize: 9, fontSize: 9,
fontWeight: pw.FontWeight.bold, fontWeight: pw.FontWeight.bold,

View File

@ -105,6 +105,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
bool _isLoading = false; bool _isLoading = false;
bool _isSendingReminder = false; bool _isSendingReminder = false;
bool _isLoadingPolicyTerms = false; bool _isLoadingPolicyTerms = false;
bool _isLoadingHospitalList = false;
// dynamic clintID; // dynamic clintID;
late TabController _tabController; late TabController _tabController;
// List<dynamic> dataPolicy = []; // List<dynamic> dataPolicy = [];
@ -594,6 +595,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
row['emp_code']?.toString().toLowerCase().contains(lowerQuery) == row['emp_code']?.toString().toLowerCase().contains(lowerQuery) ==
true || true ||
row['uhid']?.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) == row['relationship']?.toString().toLowerCase().contains(lowerQuery) ==
true || true ||
row['formatted_dob'] row['formatted_dob']
@ -652,12 +654,14 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
void exportToCsv(List<Map<String, dynamic>> data) { void exportToCsv(List<Map<String, dynamic>> data) {
List<List<String>> rows = []; List<List<String>> rows = [];
final isPost = localTokenType == 'post';
final idHeader = isPost ? 'TPA ID' : 'UHID';
// Header // Header
rows.add([ rows.add([
'Emp Code', 'Emp Code',
'Name', 'Name',
'UHID', idHeader,
'Relationship', 'Relationship',
'Date Of Birth', 'Date Of Birth',
'Gender', 'Gender',
@ -671,7 +675,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
rows.add([ rows.add([
item['emp_code'] ?? '', item['emp_code'] ?? '',
item['name'] ?? '', item['name'] ?? '',
item['uhid'] ?? '', isPost ? (item['tpa_id'] ?? '') : (item['uhid'] ?? ''),
item['relationship'] ?? '', item['relationship'] ?? '',
item['formatted_dob'] ?? '', item['formatted_dob'] ?? '',
item['gender'] ?? '', item['gender'] ?? '',
@ -915,6 +919,76 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
return _capitalize(status); 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 { Future<void> _openPolicyTermsDialog() async {
setState(() => _isLoadingPolicyTerms = true); setState(() => _isLoadingPolicyTerms = true);
@ -1329,7 +1403,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox( SizedBox(
width: 142, width: 210,
height: 37, height: 37,
child: ElevatedButton( child: ElevatedButton(
onPressed: _isSendingReminder onPressed: _isSendingReminder
@ -1355,7 +1429,7 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
), ),
) )
: Text( : Text(
'Reminder', 'Reminder Mail Template',
maxLines: 1, maxLines: 1,
softWrap: false, softWrap: false,
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
@ -1410,6 +1484,46 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
), ),
), ),
const SizedBox(width: 12), 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( SizedBox(
@ -1561,28 +1675,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
if (localTokenType == "post") if (localTokenType == "post")
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Align(
crossAxisAlignment: CrossAxisAlignment.center, alignment: Alignment.centerRight,
children: [ child: _buildCompactSearchField(),
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(),
],
), ),
), ),
@ -1615,20 +1710,6 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
const SizedBox(height: 48), 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, pinned: true,
delegate: _CDHeaderDelegate( delegate: _CDHeaderDelegate(
showUHID: localPolicyTypeId != '6' && localPolicyTypeId != '7', showUHID: localPolicyTypeId != '6' && localPolicyTypeId != '7',
uhidHeaderLabel: localTokenType == 'post' ? 'TPA ID' : 'UHID',
showLoggedIn: localTokenType == "pre", showLoggedIn: localTokenType == "pre",
showAction: showAction:
localTokenType != "pre" && (hasAnyEcardLink || hasModule), localTokenType != "pre" && (hasAnyEcardLink || hasModule),
@ -2185,11 +2267,16 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
), ),
), ),
/// UHID /// UHID / TPA ID
if (localPolicyTypeId != '6' && localPolicyTypeId != '7') if (localPolicyTypeId != '6' && localPolicyTypeId != '7')
Expanded( Expanded(
flex: 2, flex: 2,
child: Text(item['uhid'] ?? '-', style: _dataBold), child: Text(
localTokenType == 'post'
? (item['tpa_id'] ?? '-')
: (item['uhid'] ?? '-'),
style: _dataBold,
),
), ),
Expanded( Expanded(
@ -3176,11 +3263,13 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
class _CDHeaderDelegate extends SliverPersistentHeaderDelegate { class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
final bool showUHID; final bool showUHID;
final String uhidHeaderLabel;
final bool showLoggedIn; final bool showLoggedIn;
final bool showAction; final bool showAction;
_CDHeaderDelegate({ _CDHeaderDelegate({
required this.showUHID, required this.showUHID,
this.uhidHeaderLabel = 'UHID',
required this.showLoggedIn, required this.showLoggedIn,
required this.showAction, required this.showAction,
}); });
@ -3201,7 +3290,7 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
child: Row( child: Row(
children: [ children: [
_headerCell('Name', 3), _headerCell('Name', 3),
if (showUHID) _headerCell('UHID', 2), if (showUHID) _headerCell(uhidHeaderLabel, 2),
_headerCell('Relationship', 2), _headerCell('Relationship', 2),
_headerCell('Date Of Birth', 2), _headerCell('Date Of Birth', 2),
_headerCell('Gender', 2), _headerCell('Gender', 2),
@ -3234,6 +3323,101 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
true; 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 { class _PolicyTermsDialog extends StatelessWidget {
final Map<String, String> terms; final Map<String, String> terms;

View File

@ -544,14 +544,6 @@ class _policiesState extends State<policies>
), ),
const SizedBox(height: 8), 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; final Function(String clientPolicyId)? onBulkDownload;
static const double _enrollmentCardHeight = 156; static const double _enrollmentCardHeight = 156;
static const double _activePolicyCardHeight = 170; static const double _activePolicyCardHeight = 148;
const _PolicyGrid({ const _PolicyGrid({
super.key, super.key,
@ -647,7 +639,7 @@ class _PolicyGrid extends StatelessWidget {
/// EB section /// EB section
if (ebPolicies.isNotEmpty) ...[ if (ebPolicies.isNotEmpty) ...[
Text( Text(
'EB Policies', 'Employee Benefits',
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -666,7 +658,7 @@ class _PolicyGrid extends StatelessWidget {
/// Non-EB section /// Non-EB section
if (nonEbPolicies.isNotEmpty) ...[ if (nonEbPolicies.isNotEmpty) ...[
Text( Text(
'Non-EB Policies', 'Non-Employee Benefits',
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -1129,30 +1121,26 @@ class _ActivePolicyCardNew extends StatelessWidget {
: const Color(0xFFE9F6FB), // EB : const Color(0xFFE9F6FB), // EB
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
/// PREMIUM /// POLICY NO + optional ecard bulk download
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
'Premium - ₹${data['total_premium'] ?? ''}*', '${data['type']} - ${data['policy_no']}',
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
fontSize: 12, fontSize: 14,
color: Color(0xFF009195), fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600, ),
overflow: TextOverflow.ellipsis,
), ),
), ),
),
// 🔥 ICON FLOATING ABOVE CARD
if (data['is_ecard_bulk_download'] == 1) if (data['is_ecard_bulk_download'] == 1)
Positioned( GestureDetector(
top: 10,
right: 10,
child: GestureDetector(
onTap: () { onTap: () {
logDebug( logDebug(
'ICON CLICKED ${data['client_policy_id']}'); 'ICON CLICKED ${data['client_policy_id']}');
@ -1173,30 +1161,10 @@ class _ActivePolicyCardNew extends StatelessWidget {
), ),
), ),
), ),
),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 2),
/// POLICY NO + ICON
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'${data['type']} - ${data['policy_no']}',
style: GoogleFonts.poppins(
fontSize: 14,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
/// Insurer /// Insurer
Tooltip( Tooltip(
@ -1232,7 +1200,7 @@ class _ActivePolicyCardNew extends StatelessWidget {
), ),
], ],
const SizedBox(height: 4), const SizedBox(height: 2),
/// DATE RANGE /// DATE RANGE
Text( Text(
@ -1243,7 +1211,7 @@ class _ActivePolicyCardNew extends StatelessWidget {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
/// ACTIVE / INACTIVE /// ACTIVE / INACTIVE
Row( Row(