diff --git a/lib/presentation/claims_overview/claims_overview_dashboard.dart b/lib/presentation/claims_overview/claims_overview_dashboard.dart index c0671bc..4d12b3f 100644 --- a/lib/presentation/claims_overview/claims_overview_dashboard.dart +++ b/lib/presentation/claims_overview/claims_overview_dashboard.dart @@ -1,9 +1,11 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; +import 'package:universal_html/html.dart' as html; import '../../customAppBar/base_layout.dart'; import '../../customAppBar/toastHelper.dart'; @@ -42,6 +44,7 @@ class _ClaimsOverviewDashboardState extends State bool _isLoading = true; bool _isRefreshing = false; bool _isExportingPdf = false; + bool _isExportingExcel = false; bool _sessionChecked = false; int _replayToken = 0; int _loadGeneration = 0; @@ -455,7 +458,7 @@ class _ClaimsOverviewDashboardState extends State } Future _downloadChartsPdf() async { - if (_isExportingPdf || _isLoading) return; + if (_isExportingPdf || _isExportingExcel || _isLoading) return; setState(() => _isExportingPdf = true); @@ -563,6 +566,106 @@ class _ClaimsOverviewDashboardState extends State } } + Future _downloadExcel() async { + if (_isExportingExcel || _isExportingPdf || _isLoading) return; + final policyId = _selectedPolicyId; + if (policyId == null || policyId.isEmpty) { + if (!mounted) return; + ToastHelper.showErrorToast(context, 'Select a policy to download Excel'); + return; + } + + setState(() => _isExportingExcel = true); + try { + final response = await _apiService.downloadClaimsCollectionReportExcel( + clientPolicyId: policyId, + ); + + final apiMessage = _excelDownloadErrorMessage(response); + if (apiMessage != null) { + if (!mounted) return; + ToastHelper.showErrorToast(context, apiMessage); + return; + } + + if (response.statusCode != 200 || response.bodyBytes.isEmpty) { + throw Exception('Download failed (${response.statusCode})'); + } + + final apiContentType = response.headers['content-type'] ?? ''; + final contentDisposition = + response.headers['content-disposition'] ?? ''; + final excelContentType = + apiContentType.contains('spreadsheetml') || + apiContentType.contains('ms-excel') + ? apiContentType + : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + final utf8FileNameMatch = RegExp( + "filename\\*=UTF-8''([^;]+)", + caseSensitive: false, + ).firstMatch(contentDisposition); + final plainFileNameMatch = RegExp( + 'filename="?([^";]+)"?', + caseSensitive: false, + ).firstMatch(contentDisposition); + final rawFileName = + utf8FileNameMatch?.group(1) ?? plainFileNameMatch?.group(1); + final fileName = rawFileName != null && rawFileName.trim().isNotEmpty + ? Uri.decodeComponent(rawFileName.trim()) + : 'claims_collection_report_$policyId.xlsx'; + + final blob = html.Blob([response.bodyBytes], excelContentType); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); + html.Url.revokeObjectUrl(url); + + await _logHrActivity('export_hr_dashboard_data_excel'); + if (!mounted) return; + ToastHelper.showSuccessToast( + context, + 'Claims Overview Excel downloaded', + ); + } catch (e) { + if (!mounted) return; + ToastHelper.showErrorToast( + context, + 'Could not download Excel. Please try again.', + ); + logDebug('Excel download failed: $e'); + } finally { + if (mounted) setState(() => _isExportingExcel = false); + } + } + + /// Returns API error message when the Excel endpoint responds with JSON + /// `{ "status": false, "message": "..." }` instead of a file. + String? _excelDownloadErrorMessage(http.Response response) { + final contentType = response.headers['content-type'] ?? ''; + final body = response.body.trim(); + final looksLikeJson = contentType.contains('application/json') || + body.startsWith('{') || + body.startsWith('['); + if (!looksLikeJson || body.isEmpty) return null; + + try { + final decoded = jsonDecode(body); + if (decoded is! Map) return null; + final status = decoded['status']; + final isFailure = status == false || + status == 0 || + status?.toString().toLowerCase() == 'false' || + status?.toString().toLowerCase() == 'error'; + if (!isFailure) return null; + final message = decoded['message']?.toString().trim(); + if (message != null && message.isNotEmpty) return message; + return 'Could not download Excel.'; + } catch (_) { + return null; + } + } + @override void dispose() { _tabController.removeListener(_onTabChanged); @@ -823,23 +926,55 @@ class _ClaimsOverviewDashboardState extends State } Widget _headerActions() { + final isBusy = + _isRefreshing || _isExportingPdf || _isExportingExcel || _isLoading; + final isExporting = _isExportingPdf || _isExportingExcel; return Padding( padding: const EdgeInsets.only(left: 4), - child: IconButton( - tooltip: 'Download all charts as PDF', - icon: _isExportingPdf + child: PopupMenuButton( + tooltip: 'Download', + enabled: !isBusy, + offset: const Offset(0, 36), + onSelected: (value) { + if (value == 'pdf') { + _downloadChartsPdf(); + } else if (value == 'excel') { + _downloadExcel(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'pdf', + child: Row( + children: [ + const Icon(Icons.picture_as_pdf_outlined, size: 18), + const SizedBox(width: 8), + Text('PDF', style: GoogleFonts.poppins(fontSize: 13)), + ], + ), + ), + PopupMenuItem( + value: 'excel', + child: Row( + children: [ + const Icon(Icons.table_chart_outlined, size: 18), + const SizedBox(width: 8), + Text('Excel', style: GoogleFonts.poppins(fontSize: 13)), + ], + ), + ), + ], + icon: isExporting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.download_outlined, size: 20), - style: IconButton.styleFrom( - foregroundColor: ClaimsOverviewTheme.textSecondary, - ), - onPressed: (_isRefreshing || _isExportingPdf || _isLoading) - ? null - : _downloadChartsPdf, + : const Icon( + Icons.download_outlined, + size: 20, + color: ClaimsOverviewTheme.textSecondary, + ), ), ); } diff --git a/lib/presentation/hrPolicyDetails.dart b/lib/presentation/hrPolicyDetails.dart index dfab720..a01574f 100755 --- a/lib/presentation/hrPolicyDetails.dart +++ b/lib/presentation/hrPolicyDetails.dart @@ -1,4 +1,3 @@ -import 'package:csv/csv.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:nhancepolicy/customAppBar/customAppBar.dart'; @@ -129,9 +128,19 @@ class _HrPolicyDetailsState extends State int _currentPage = 1; int _rowsPerPage = 5; - /// Pre-enrollment status chip filter (null = show all). + /// Selected filter chip key (null = none). Sent as `card_data` on search. String? _preStatusFilter; + /// Filter chips from initial `card_data=all` API response. + List> _filterChips = []; + + /// Employee table is hidden until the user searches. + bool _hasSearched = false; + bool _isSearching = false; + bool _isLoadingCards = false; + bool _isExportingExcel = false; + Timer? _searchDebounce; + /// holds selected employee ids as String or int (be consistent) final Set selectedEmployeeIds = {}; @@ -347,88 +356,348 @@ class _HrPolicyDetailsState extends State } } + Future _loadAllowedModules() async { + modulesString = await tokenService.readValue('empAllowed_modules'); + logDebug("empmodulesString - $modulesString"); + + if (modulesString != null && modulesString!.trim().isNotEmpty) { + final List? moduleList = modulesString + ?.replaceAll('[', '') + .replaceAll(']', '') + .split(',') + .map((e) => int.tryParse(e.trim()) ?? -1) + .where((id) => id != -1) + .toList(); + + hasModule = moduleList?.contains(storeModuleId) ?? false; + } + } + + Future> _fetchEmployees({ + dynamic cardData, + String? searchKey, + }) async { + final clientId = localClientId ?? widget.ClientId; + final policyId = localClientPolicyId ?? widget.ClientPoliyId; + final branchId = localClientBranchId ?? widget.clientBranchId; + final token = localToken ?? widget.Token; + + if (localTokenType == 'post') { + return apiService.getEmployeeAndDependenceToApi( + clientId, + policyId, + branchId, + token, + cardData: cardData, + searchKey: searchKey, + ); + } + + return apiService.getEmployeeAndDependenceToApiPre( + clientId, + policyId, + branchId, + token!, + cardData: cardData, + searchKey: searchKey, + ); + } + + String _chipLabelForKey(String key) { + const labels = { + 'emp_count': 'Emp Count', + 'submitted': 'Submitted', + 'logged_in': 'Logged-In', + 'not_logged_in': 'Not Logged-In', + 'draft': 'Draft', + }; + final normalized = key.toLowerCase().trim(); + return labels[normalized] ?? key; + } + + List> _parseFilterChips(Map response) { + // API returns counts in top-level `card_data`, e.g. + // { "emp_count": 10, "submitted": 2, "logged_in": 6, ... } + final source = response['card_data']; + if (source is! Map) return []; + + const allowedKeys = [ + 'emp_count', + 'submitted', + 'logged_in', + 'not_logged_in', + 'draft', + ]; + + final map = Map.from(source); + final chips = >[]; + + for (final key in allowedKeys) { + if (!map.containsKey(key)) continue; + final count = int.tryParse('${map[key]}') ?? 0; + chips.add({ + 'key': key, + 'label': _chipLabelForKey(key), + 'count': count, + }); + } + + return chips; + } + + List> _parseEmployeeList(Map response) { + final data = response['data']; + if (data is List) { + return data + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + if (data is Map) { + final nested = data['employees'] ?? + data['list'] ?? + data['records'] ?? + data['rows'] ?? + data['data']; + if (nested is List) { + return nested + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + } + return []; + } + + /// Initial load: fetch filter chips only (`card_data=all`). No employee table. Future getCDPoliciesDetails() async { - logDebug('getCDPoliciesDetails started'); + logDebug('getCDPoliciesDetails started (card_data=all)'); setState(() { + _isLoadingCards = true; isLoading = true; + _hasSearched = false; + originalData = []; + filteredData = []; + getCDPolicies = []; }); try { - logDebug('Fetching modules...'); - modulesString = await tokenService.readValue('empAllowed_modules'); - logDebug("empmodulesString - $modulesString"); + await _loadAllowedModules(); - if (modulesString != null && modulesString!.trim().isNotEmpty) { - final List? moduleList = modulesString - ?.replaceAll('[', '') - .replaceAll(']', '') - .split(',') - .map((e) => int.tryParse(e.trim()) ?? -1) - .where((id) => id != -1) - .toList(); - - hasModule = moduleList?.contains(storeModuleId) ?? false; - } - - logDebug('Calling API with TokenType: ${localTokenType}'); - logDebug( - 'ClientId: ${localClientId}, ClientPoliyId: ${localClientPolicyId}'); - - final response = localTokenType == "post" - ? await apiService.getEmployeeAndDependenceToApi( - localClientId ?? widget.ClientId, - localClientPolicyId ?? widget.ClientPoliyId, - localClientBranchId ?? widget.clientBranchId, - localToken ?? widget.Token) - : await apiService.getEmployeeAndDependenceToApiPre( - localClientId ?? widget.ClientId, - localClientPolicyId ?? widget.ClientPoliyId, - localClientBranchId ?? widget.clientBranchId, - localToken ?? widget.Token!); - - logDebug('API Response: ${response.toString()}'); + logDebug('Calling card_data=all API with TokenType: ${localTokenType}'); + final response = await _fetchEmployees(cardData: 'all'); + logDebug('Card API Response: ${response.toString()}'); if (response != null && response['status'] == 'success') { - final data = response['data']; - if (data != null && data is List) { - // Check if it's actually a list + final chips = _parseFilterChips(response); + if (mounted) { setState(() { - getCDPolicies = List>.from(data); - originalData = getCDPolicies; - _refreshFilteredData(); + _filterChips = chips; + _isLoadingCards = false; isLoading = false; }); - } else { - setState(() => isLoading = false); } } else { - final errorCode = response?['code'] ?? 'Unknown'; - // final errorMessage = response?['message'] ?? 'Request failed'; - // logDebug('❌ Request failed - Code: $errorCode, Message: $errorMessage'); - - setState(() { - isLoading = false; - }); - if (mounted) { - // ToastHelper.showErrorToast(context, errorMessage); + setState(() { + _filterChips = []; + _isLoadingCards = false; + isLoading = false; + }); } } } catch (e, stackTrace) { logDebug('❌ Exception occurred: $e'); logDebug('Stack trace: $stackTrace'); - - setState(() { - isLoading = false; - }); - if (mounted) { + setState(() { + _isLoadingCards = false; + isLoading = false; + }); ToastHelper.showErrorToast(context, - 'Failed to load data. Please check your connection and try again.'); + 'Failed to load filters. Please check your connection and try again.'); } } } + /// Search / filter employee load. + /// - Chip selected + search → local filter only (no API). + /// - No chip + search → API with empty `card_data`. + /// - Chip tap → API with chip `card_data`, then apply local search if any. + Future _searchEmployees() async { + final query = searchController.text.trim(); + final hasChip = _preStatusFilter != null; + + if (query.isEmpty && !hasChip) { + if (!mounted) return; + setState(() { + _hasSearched = false; + _isSearching = false; + originalData = []; + filteredData = []; + getCDPolicies = []; + _currentPage = 1; + }); + return; + } + + // Chip already loaded: filter that list on the frontend. + if (hasChip && _hasSearched && originalData.isNotEmpty && query.isNotEmpty) { + _applyLocalSearchFilter(); + return; + } + + if (hasChip) { + await _loadEmployeesByChip(); + return; + } + + await _searchEmployeesViaApi(query); + } + + /// No chip selected — search via API (`card_data` empty). + Future _searchEmployeesViaApi(String query) async { + if (!mounted) return; + setState(() { + _isSearching = true; + isLoading = true; + }); + + try { + final response = await _fetchEmployees( + cardData: '', + searchKey: query, + ); + logDebug('Search API Response: ${response.toString()}'); + + if (!mounted) return; + + if (response != null && response['status'] == 'success') { + final employees = _parseEmployeeList(response); + final chips = _parseFilterChips(response); + + setState(() { + getCDPolicies = employees; + originalData = employees; + filteredData = List>.from(employees); + hasAnyEcardLink = filteredData.any( + (item) => item['ecard_download_link'] != null, + ); + _hasSearched = true; + _currentPage = 1; + if (chips.isNotEmpty) { + _filterChips = chips; + } + _isSearching = false; + isLoading = false; + }); + } else { + setState(() { + getCDPolicies = []; + originalData = []; + filteredData = []; + _hasSearched = true; + _isSearching = false; + isLoading = false; + }); + } + } catch (e, stackTrace) { + logDebug('❌ Search exception: $e'); + logDebug('Stack trace: $stackTrace'); + if (mounted) { + setState(() { + _isSearching = false; + isLoading = false; + _hasSearched = true; + }); + ToastHelper.showErrorToast( + context, 'Failed to search members. Please try again.'); + } + } + } + + /// Chip selected — load that filter from API (no search param). + Future _loadEmployeesByChip() async { + final chip = _preStatusFilter; + if (chip == null) return; + + if (!mounted) return; + setState(() { + _isSearching = true; + isLoading = true; + }); + + try { + final response = await _fetchEmployees( + cardData: chip, + searchKey: null, + ); + logDebug('Chip API Response: ${response.toString()}'); + + if (!mounted) return; + + if (response != null && response['status'] == 'success') { + final employees = _parseEmployeeList(response); + final chips = _parseFilterChips(response); + + setState(() { + getCDPolicies = employees; + originalData = employees; + _hasSearched = true; + _currentPage = 1; + if (chips.isNotEmpty) { + _filterChips = chips; + } + _isSearching = false; + isLoading = false; + }); + _applyLocalSearchFilter(); + } else { + setState(() { + getCDPolicies = []; + originalData = []; + filteredData = []; + _hasSearched = true; + _isSearching = false; + isLoading = false; + }); + } + } catch (e, stackTrace) { + logDebug('❌ Chip load exception: $e'); + logDebug('Stack trace: $stackTrace'); + if (mounted) { + setState(() { + _isSearching = false; + isLoading = false; + _hasSearched = true; + }); + ToastHelper.showErrorToast( + context, 'Failed to load members. Please try again.'); + } + } + } + + /// Filter already-loaded chip data locally (no API). + void _applyLocalSearchFilter() { + if (!mounted) return; + final query = searchController.text.trim().toLowerCase(); + setState(() { + if (query.isEmpty) { + filteredData = List>.from(originalData); + } else { + filteredData = originalData + .where((row) => _matchesSearch(row, query)) + .toList(); + } + _currentPage = 1; + hasAnyEcardLink = filteredData.any( + (item) => item['ecard_download_link'] != null, + ); + _isSearching = false; + isLoading = false; + }); + } + Future getEcardDownload(String? empCode, String? empId, String? clientPolicyId, String? policyNo) async { final eCarDParams = { @@ -612,27 +881,23 @@ class _HrPolicyDetailsState extends State } void _refreshFilteredData() { - Iterable> data = originalData; - - final lowerQuery = searchController.text.toLowerCase().trim(); - if (lowerQuery.isNotEmpty) { - data = data.where((row) => _matchesSearch(row, lowerQuery)); - } - - if (localTokenType == 'pre' && _preStatusFilter != null) { - data = data.where((row) => _matchesPreStatusFilter(row, _preStatusFilter!)); - } - - filteredData = data.toList(); + // Server already filters by card_data; keep local copy for pagination. + filteredData = List>.from(originalData); hasAnyEcardLink = filteredData.any( (item) => item['ecard_download_link'] != null, ); } void search(String query) { - setState(() { - _currentPage = 1; - _refreshFilteredData(); + setState(() {}); // refresh clear / loading suffix + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 450), () { + // Chip selected → local filter only. No chip → API search. + if (_preStatusFilter != null && _hasSearched) { + _applyLocalSearchFilter(); + } else { + _searchEmployees(); + } }); } @@ -640,65 +905,136 @@ class _HrPolicyDetailsState extends State setState(() { _preStatusFilter = filter; _currentPage = 1; - _refreshFilteredData(); }); + _searchDebounce?.cancel(); + _loadEmployeesByChip(); } void _resetPreStatusFilter() { setState(() { _preStatusFilter = null; _currentPage = 1; - _refreshFilteredData(); }); + _searchDebounce?.cancel(); + final query = searchController.text.trim(); + if (query.isEmpty) { + setState(() { + _hasSearched = false; + _isSearching = false; + originalData = []; + filteredData = []; + getCDPolicies = []; + }); + } else { + _searchEmployeesViaApi(query); + } } - void exportToCsv(List> data) { - List> rows = []; - final isPost = localTokenType == 'post'; - final idHeader = isPost ? 'TPA ID' : 'UHID'; + Future downloadEmployeeListExcel() async { + if (_isExportingExcel) return; - // Header - rows.add([ - 'Emp Code', - 'Name', - idHeader, - 'Relationship', - 'Date Of Birth', - 'Gender', - 'Mobile', - 'Email', - 'Status' - ]); + final clientId = (localClientId ?? widget.ClientId).toString().trim(); + final policyId = + (localClientPolicyId ?? widget.ClientPoliyId).toString().trim(); + final branchId = + (localClientBranchId ?? widget.clientBranchId).toString().trim(); - // Data rows - for (var item in data) { - rows.add([ - item['emp_code'] ?? '', - item['name'] ?? '', - isPost ? (item['tpa_id'] ?? '') : (item['uhid'] ?? ''), - item['relationship'] ?? '', - item['formatted_dob'] ?? '', - item['gender'] ?? '', - item['mobile'] ?? '', - item['email_corporate'] ?? '', - _getRowStatusRaw(item) ?? '', - ]); + if (clientId.isEmpty || policyId.isEmpty || branchId.isEmpty) { + if (!mounted) return; + ToastHelper.showErrorToast(context, 'Missing client/policy/branch details'); + return; } + setState(() => _isExportingExcel = true); + try { + final response = await apiService.downloadEmployeeListExcel( + clientId: clientId, + policyId: policyId, + branchId: branchId, + isPost: localTokenType == 'post', + token: localToken, + ); - // Convert to CSV string - String csvData = const ListToCsvConverter().convert(rows); + final apiMessage = _excelDownloadErrorMessage(response); + if (apiMessage != null) { + if (!mounted) return; + ToastHelper.showErrorToast(context, apiMessage); + return; + } - // For Web: Create download - final bytes = utf8.encode(csvData); - final blob = html.Blob([bytes]); - final url = html.Url.createObjectUrlFromBlob(blob); - final String csvFileName = "policies(${localCardPolicyNo}).csv"; - final anchor = html.AnchorElement(href: url) - ..setAttribute("download", csvFileName) - ..click(); - html.Url.revokeObjectUrl(url); - handleExportAction(); + if (response.statusCode != 200 || response.bodyBytes.isEmpty) { + throw Exception('Download failed (${response.statusCode})'); + } + + final apiContentType = response.headers['content-type'] ?? ''; + final contentDisposition = + response.headers['content-disposition'] ?? ''; + final excelContentType = + apiContentType.contains('spreadsheetml') || + apiContentType.contains('ms-excel') + ? apiContentType + : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + final utf8FileNameMatch = RegExp( + "filename\\*=UTF-8''([^;]+)", + caseSensitive: false, + ).firstMatch(contentDisposition); + final plainFileNameMatch = RegExp( + 'filename="?([^";]+)"?', + caseSensitive: false, + ).firstMatch(contentDisposition); + final rawFileName = + utf8FileNameMatch?.group(1) ?? plainFileNameMatch?.group(1); + final fileName = rawFileName != null && rawFileName.trim().isNotEmpty + ? Uri.decodeComponent(rawFileName.trim()) + : 'employee_list_${localCardPolicyNo ?? policyId}.xlsx'; + + final blob = html.Blob([response.bodyBytes], excelContentType); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); + html.Url.revokeObjectUrl(url); + + await handleExportAction(); + if (!mounted) return; + ToastHelper.showSuccessToast(context, 'Employee list Excel downloaded'); + } catch (e) { + logDebug('Employee list Excel download failed: $e'); + if (!mounted) return; + ToastHelper.showErrorToast( + context, + 'Could not download Excel. Please try again.', + ); + } finally { + if (mounted) setState(() => _isExportingExcel = false); + } + } + + /// Returns API error message when Excel endpoint responds with JSON error. + String? _excelDownloadErrorMessage(http.Response response) { + final contentType = response.headers['content-type'] ?? ''; + final body = response.body.trim(); + final looksLikeJson = contentType.contains('application/json') || + body.startsWith('{') || + body.startsWith('['); + if (!looksLikeJson || body.isEmpty) return null; + + try { + final decoded = jsonDecode(body); + if (decoded is! Map) return null; + final status = decoded['status']; + final isFailure = status == false || + status == 0 || + status?.toString().toLowerCase() == 'false' || + status?.toString().toLowerCase() == 'error' || + status?.toString().toLowerCase() == 'failed'; + if (!isFailure && response.statusCode == 200) return null; + final message = decoded['message']?.toString().trim(); + if (message != null && message.isNotEmpty) return message; + return 'Could not download Excel.'; + } catch (_) { + return null; + } } Future handleExportAction() async { @@ -840,30 +1176,164 @@ class _HrPolicyDetailsState extends State } } + Widget _buildHeaderActionButton({ + required String label, + required Color color, + required double width, + required VoidCallback? onPressed, + bool isLoading = false, + }) { + return SizedBox( + width: width, + height: 40, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + elevation: 0, + padding: EdgeInsets.zero, + disabledBackgroundColor: color.withValues(alpha: 0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + label, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ); + } + + Widget _buildImportButton() { + return SizedBox( + width: 116, + height: 40, + child: ElevatedButton( + onPressed: () async { + await tokenService.writeValue( + 'upload_ClientId', widget.ClientId.toString()); + await tokenService.writeValue( + 'upload_policyTypeId', widget.policyTypeId.toString()); + await tokenService.writeValue( + 'upload_ClientPoliyId', widget.ClientPoliyId.toString()); + await tokenService.writeValue( + 'upload_clientBranchId', widget.clientBranchId.toString()); + await tokenService.writeValue( + 'upload_Token', widget.Token.toString()); + await tokenService.writeValue( + 'upload_TokenType', widget.TokenType.toString()); + await tokenService.writeValue( + 'upload_cardType', widget.cardType.toString()); + await tokenService.writeValue( + 'upload_cardPolicyNo', widget.cardPolicyNo.toString()); + await tokenService.writeValue( + 'upload_cardInsurer_name', widget.cardInsurer_name.toString()); + await tokenService.writeValue( + 'upload_cardPolicy_name', widget.cardPolicy_name.toString()); + await tokenService.writeValue( + 'upload_cardPolicy_ExpDate', widget.cardPolicy_ExpDate.toString()); + await tokenService.writeValue( + 'upload_total_premium', widget.total_premium.toString()); + Navigator.push( + context, + MaterialPageRoute( + settings: localTokenType != "post" + ? const RouteSettings(name: 'preFileUpload') + : const RouteSettings(name: 'postFileUpload'), + builder: (context) => localTokenType != "post" + ? preFileUpload( + ClientId: widget.ClientId, + policyTypeId: widget.policyTypeId, + ClientPoliyId: widget.ClientPoliyId, + clientBranchId: widget.clientBranchId, + Token: widget.Token, + TokenType: widget.TokenType, + cardType: widget.cardType, + cardPolicyNo: widget.cardPolicyNo, + cardInsurer_name: widget.cardInsurer_name, + cardPolicy_name: widget.cardPolicy_name, + cardPolicy_ExpDate: widget.cardPolicy_ExpDate, + total_premium: widget.total_premium, + ) + : postFileUpload( + ClientId: widget.ClientId, + policyTypeId: widget.policyTypeId, + ClientPoliyId: widget.ClientPoliyId, + clientBranchId: widget.clientBranchId, + Token: widget.Token, + TokenType: widget.TokenType, + cardType: widget.cardType, + cardPolicyNo: widget.cardPolicyNo, + cardInsurer_name: widget.cardInsurer_name, + cardPolicy_name: widget.cardPolicy_name, + cardPolicy_ExpDate: widget.cardPolicy_ExpDate, + total_premium: widget.total_premium, + allocgType: 'EB', + ), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE26728), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Import', + style: GoogleFonts.poppins( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ), + ); + } + Widget _buildExportButton() { final buttonStyle = ElevatedButton.styleFrom( backgroundColor: const Color(0xFFE26728), elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(12), ), ); final textStyle = GoogleFonts.poppins( - fontSize: 16, + fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white, - letterSpacing: 1, + letterSpacing: 0.5, ); if (localTokenType == 'pre') { return SizedBox( width: 116, - height: 37, + height: 40, child: PopupMenuButton( - offset: const Offset(0, 37), + offset: const Offset(0, 40), onSelected: (value) { if (value == 'csv') { - exportToCsv(filteredData); + downloadEmployeeListExcel(); } else if (value == 'inception') { downloadInceptionExport(); } @@ -872,7 +1342,7 @@ class _HrPolicyDetailsState extends State PopupMenuItem( value: 'csv', child: Text( - 'Export', + 'Export Excel', style: GoogleFonts.poppins(fontSize: 13), ), ), @@ -888,7 +1358,10 @@ class _HrPolicyDetailsState extends State child: ElevatedButton( onPressed: () {}, style: buttonStyle, - child: Text('Export', style: textStyle), + child: Text( + _isExportingExcel ? 'Exporting…' : 'Export', + style: textStyle, + ), ), ), ), @@ -897,11 +1370,14 @@ class _HrPolicyDetailsState extends State return SizedBox( width: 116, - height: 37, + height: 40, child: ElevatedButton( - onPressed: () => exportToCsv(filteredData), + onPressed: _isExportingExcel ? null : downloadEmployeeListExcel, style: buttonStyle, - child: Text('Export', style: textStyle), + child: Text( + _isExportingExcel ? 'Exporting…' : 'Export', + style: textStyle, + ), ), ); } @@ -1262,6 +1738,8 @@ class _HrPolicyDetailsState extends State @override void dispose() { + _searchDebounce?.cancel(); + searchController.dispose(); _tabController.dispose(); super.dispose(); } @@ -1275,6 +1753,7 @@ class _HrPolicyDetailsState extends State Widget _buildContent(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), body: SafeArea( child: Stack( children: [ @@ -1283,428 +1762,217 @@ class _HrPolicyDetailsState extends State children: [ // ---------------- HEADER ---------------- Padding( - padding: const EdgeInsets.all(16), - child: Column(children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - /// 🔙 Back + Title (LEFT) - Row( - children: [ - IconButton( - tooltip: 'Previous Page', - onPressed: () async => { - await clearPolicyStorage(), - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => policies(), - ), - ) - }, - // splashRadius: 20, - icon: const Icon( - Icons.arrow_back_ios, - size: 18, - color: Colors.black, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - const SizedBox(width: 6), - Container( - // color: Colors.redAccent.shade100, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - "${localCardType} - ${localCardPolicyNo} " ?? - '', - style: GoogleFonts.poppins( - color: Colors.black, - fontSize: 14, - fontWeight: FontWeight.w500, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < 900; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: 'Previous Page', + onPressed: () async => { + await clearPolicyStorage(), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => policies(), ), - ), - Text( - localTokenType == 'pre' - ? "${localCardPolicyName} (${localCardPolicyExpDate})" - : "${localCardInsurerName} - ${localCardPolicyName} (${localCardPolicyExpDate})", - style: GoogleFonts.poppins( - color: Colors.grey, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - ], - ), - - /// Push right content to end - const Spacer(), - - if (localIsEcardBulkDownload == 1) ...[ - const SizedBox(width: 12), - SizedBox( - width: 40, - height: 37, - child: ElevatedButton( - onPressed: () { - getEcardBulkDownload(); + ) }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE26728), - elevation: 0, - padding: EdgeInsets.zero, // ✅ IMPORTANT - alignment: Alignment.center, // ✅ FORCE CENTER - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + size: 18, + color: Color(0xFF0F172A), + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${localCardType} - ${localCardPolicyNo} " ?? + '', + style: GoogleFonts.poppins( + color: const Color(0xFF0F172A), + fontSize: isCompact ? 15 : 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + localTokenType == 'pre' + ? "${localCardPolicyName} (${localCardPolicyExpDate})" + : "${localCardInsurerName} - ${localCardPolicyName} (${localCardPolicyExpDate})", + style: GoogleFonts.poppins( + color: const Color(0xFF64748B), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.end, + children: [ + if (localIsEcardBulkDownload == 1) + SizedBox( + width: 40, + height: 40, + child: ElevatedButton( + onPressed: () { + getEcardBulkDownload(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE26728), + elevation: 0, + padding: EdgeInsets.zero, + alignment: Alignment.center, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon( + Icons.credit_card, + size: 18, + color: Colors.white, + ), ), ), - child: Icon( - Icons.credit_card, - size: 18, - color: Colors.white, - )), + if (localTokenType == 'pre') ...[ + _buildHeaderActionButton( + label: 'Reminder Config', + color: const Color(0xFF009195), + width: 150, + onPressed: _openReminderConfigDialog, + ), + _buildHeaderActionButton( + label: 'Reminder Mail Template', + color: const Color(0xFFE26728), + width: 210, + onPressed: _isSendingReminder + ? null + : _openReminderTemplateDialog, + isLoading: _isSendingReminder, + ), + ], + if (localTokenType == 'post') ...[ + _buildHeaderActionButton( + label: 'Policy Terms', + color: const Color(0xFF009195), + width: 130, + onPressed: _isLoadingPolicyTerms + ? null + : _openPolicyTermsDialog, + isLoading: _isLoadingPolicyTerms, + ), + _buildHeaderActionButton( + label: 'Network Hospital List', + color: const Color(0xFF009195), + width: 190, + onPressed: _isLoadingHospitalList + ? null + : _openNetworkHospitalListDialog, + isLoading: _isLoadingHospitalList, + ), + ], + _buildImportButton(), + _buildExportButton(), + ], ), ], + ); + }, + ), + ), - const SizedBox(width: 12), + const SizedBox(height: 8), - if (localTokenType == 'pre') ...[ - SizedBox( - width: 150, - height: 37, - child: ElevatedButton( - onPressed: _openReminderConfigDialog, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF009195), - elevation: 0, - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: Text( - 'Reminder Config', - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.poppins( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 210, - height: 37, - child: ElevatedButton( - onPressed: _isSendingReminder - ? null - : _openReminderTemplateDialog, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE26728), - elevation: 0, - padding: EdgeInsets.zero, - disabledBackgroundColor: - const Color(0xFFE26728).withValues(alpha: 0.6), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: _isSendingReminder - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - 'Reminder Mail Template', - maxLines: 1, - softWrap: false, - style: GoogleFonts.poppins( - fontSize: 15, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - ), - const SizedBox(width: 12), - ], - - if (localTokenType == 'post') ...[ - SizedBox( - width: 130, - height: 37, - child: ElevatedButton( - onPressed: _isLoadingPolicyTerms - ? null - : _openPolicyTermsDialog, - 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: _isLoadingPolicyTerms - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - 'Policy Terms', - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.poppins( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), - ), - 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( - width: 116, - height: 37, - child: ElevatedButton( - onPressed: () async { - await tokenService.writeValue('upload_ClientId', - widget.ClientId.toString()); - await tokenService.writeValue( - 'upload_policyTypeId', - widget.policyTypeId.toString()); - await tokenService.writeValue( - 'upload_ClientPoliyId', - widget.ClientPoliyId.toString()); - await tokenService.writeValue( - 'upload_clientBranchId', - widget.clientBranchId.toString()); - await tokenService.writeValue( - 'upload_Token', widget.Token.toString()); - await tokenService.writeValue('upload_TokenType', - widget.TokenType.toString()); - await tokenService.writeValue('upload_cardType', - widget.cardType.toString()); - await tokenService.writeValue( - 'upload_cardPolicyNo', - widget.cardPolicyNo.toString()); - await tokenService.writeValue( - 'upload_cardInsurer_name', - widget.cardInsurer_name.toString()); - await tokenService.writeValue( - 'upload_cardPolicy_name', - widget.cardPolicy_name.toString()); - await tokenService.writeValue( - 'upload_cardPolicy_ExpDate', - widget.cardPolicy_ExpDate.toString()); - await tokenService.writeValue( - 'upload_total_premium', - widget.total_premium.toString()); - Navigator.push( - context, - MaterialPageRoute( - settings: localTokenType != "post" - ? RouteSettings(name: 'preFileUpload') - : RouteSettings(name: 'postFileUpload'), - builder: (context) => localTokenType != - "post" - ? preFileUpload( - ClientId: - widget.ClientId, // <-- from map - policyTypeId: widget.policyTypeId, - ClientPoliyId: widget.ClientPoliyId, - clientBranchId: - widget.clientBranchId, - Token: widget.Token, - TokenType: widget.TokenType, - cardType: widget.cardType, - cardPolicyNo: widget.cardPolicyNo, - cardInsurer_name: - widget.cardInsurer_name, - cardPolicy_name: - widget.cardPolicy_name, - cardPolicy_ExpDate: - widget.cardPolicy_ExpDate, - total_premium: widget.total_premium, - - // Token: widget.Token, - // ClientId: widget.ClientId, - // ClientPolicyId : widget.ClientPoliyId, - // PolicyName: widget.cardPolicy_name, - // PolicyNo: widget.cardPolicyNo, - // ClientBranchId: widget.HrId, - // PolicyType: widget.cardType, - ) - : postFileUpload( - ClientId: - widget.ClientId, // <-- from map - policyTypeId: widget.policyTypeId, - ClientPoliyId: widget.ClientPoliyId, - clientBranchId: - widget.clientBranchId, - Token: widget.Token, - TokenType: widget.TokenType, - cardType: widget.cardType, - cardPolicyNo: widget.cardPolicyNo, - cardInsurer_name: - widget.cardInsurer_name, - cardPolicy_name: - widget.cardPolicy_name, - cardPolicy_ExpDate: - widget.cardPolicy_ExpDate, - total_premium: widget.total_premium, - allocgType: 'EB', - - // Token: localToken, - // ClientId: widget.ClientId, - // ClientPolicyId : widget.ClientPoliyId, - // PolicyName: widget.cardPolicy_name, - // PolicyNo: widget.cardPolicyNo, - // ClientBranchId: widget.HrId, - // PolicyType: widget.cardType, - )), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFE26728), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: Text( - 'Import', - style: GoogleFonts.poppins( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, - letterSpacing: 1, - ), - ), - ), - ), - const SizedBox(width: 12), - - /// ⬇️ Export Button - _buildExportButton(), - ], - ), - ]), + // ---------------- SEARCH + FILTER CHIPS ---------------- + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: LayoutBuilder( + builder: (context, constraints) { + return _buildSearchAndFiltersSection(constraints); + }, + ), ), const SizedBox(height: 12), - // ---------------- LOADER CONDITION ---------------- - // This shows a linear progress bar if the site is fetching data - if (isLoading) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: LinearProgressIndicator(color: Color(0xFFE26728)), - ), - - // ---------------- PREMIUM / STATUS ---------------- - if (localTokenType == "pre") - Padding( - padding: const EdgeInsets.only(left: 15, right: 16), - child: _buildStatusSummary(), - ), - - if (localTokenType == "post") - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Align( - alignment: Alignment.centerRight, - child: _buildCompactSearchField(), - ), - ), - - const SizedBox(height: 12), - - // ---------------- TABLE (SCROLLABLE) ---------------- + // ---------------- TABLE (after search only) ---------------- Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: isLoading - ? Container( - color: Colors - .transparent, // Semi-transparent background - child: Center( - child: // Your GIF loader widget - Image.asset( - height: 60, - width: 60, - 'assets/nhance-loader.gif'), // Adjust path to your GIF loader + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 320), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: (_isSearching || (isLoading && _hasSearched)) + ? Center( + key: const ValueKey('loader'), + child: Image.asset( + height: 60, + width: 60, + 'assets/nhance-loader.gif', ), ) - : Column( - children: [ - _buildCDDataTable( - context), // ← DO NOT wrap again in Expanded - ], - )), + : !_hasSearched + ? KeyedSubtree( + key: const ValueKey('prompt'), + child: _buildSearchPromptEmptyState(), + ) + : filteredData.isEmpty + ? Center( + key: const ValueKey('empty'), + child: Text( + 'No matching members found', + style: GoogleFonts.poppins( + fontSize: 14, + color: const Color(0xFF64748B), + ), + ), + ) + : DecoratedBox( + key: const ValueKey('table'), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFE2E8F0), + ), + boxShadow: [ + BoxShadow( + color: Colors.black + .withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Column( + children: [ + _buildCDDataTable(context), + ], + ), + ), + ), + ), + ), ), const SizedBox(height: 48), @@ -2002,6 +2270,15 @@ class _HrPolicyDetailsState extends State // } Map getPreStatusCounts() { + // Prefer API chip counts; fall back to client counts from loaded rows. + if (_filterChips.isNotEmpty) { + return { + for (final chip in _filterChips) + chip['key'].toString(): + int.tryParse('${chip['count']}') ?? 0, + }; + } + int empCount = 0; int enrolled = 0; int notEnrolled = 0; @@ -2041,13 +2318,11 @@ class _HrPolicyDetailsState extends State } Color _getPreFilterChipColor(String filter) { - switch (filter) { + switch (filter.toLowerCase()) { case 'emp_count': return const Color(0xFFE2FBCB); - case 'enrolled': + case 'submitted': return const Color(0xFFBDF9D9); - case 'not_enrolled': - return const Color(0xFFFFE8AC); case 'logged_in': return const Color(0xFFC5F2F4); case 'not_logged_in': @@ -2055,39 +2330,87 @@ class _HrPolicyDetailsState extends State case 'draft': return const Color(0xFFF9EBBD); default: - return const Color(0xFFB0BEC5); + return const Color(0xFFE8F5F5); } } - Widget _buildCompactSearchField({double width = 180}) { - return SizedBox( - width: width, - height: 32, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(6), + Widget _buildModernSearchField({double? width}) { + final field = AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + height: 48, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFF009195).withValues(alpha: 0.25), ), - child: TextField( - controller: searchController, - onChanged: search, - textAlign: TextAlign.left, - textAlignVertical: TextAlignVertical.center, - style: GoogleFonts.poppins(fontSize: 12), - decoration: InputDecoration( - hintText: 'Search', - hintStyle: GoogleFonts.poppins(fontSize: 12, color: Colors.black45), - prefixIcon: const Icon(Icons.search, size: 16), - prefixIconConstraints: - const BoxConstraints(minWidth: 28, minHeight: 32), - isDense: true, - border: InputBorder.none, - contentPadding: - const EdgeInsets.only(left: 0, right: 10, top: 0, bottom: 0), + boxShadow: [ + BoxShadow( + color: const Color(0xFF009195).withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, 6), ), + ], + ), + child: TextField( + controller: searchController, + onChanged: search, + onSubmitted: (_) { + _searchDebounce?.cancel(); + _searchEmployees(); + }, + textInputAction: TextInputAction.search, + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w500, + color: const Color(0xFF1E293B), + ), + decoration: InputDecoration( + hintText: 'Search by name, emp code, mobile, email…', + hintStyle: GoogleFonts.poppins( + fontSize: 13, + color: const Color(0xFF94A3B8), + ), + prefixIcon: const Icon( + Icons.search_rounded, + size: 22, + color: Color(0xFF009195), + ), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + tooltip: 'Clear', + onPressed: () { + searchController.clear(); + search(''); + setState(() {}); + }, + icon: const Icon(Icons.close_rounded, size: 18), + ) + : (_isSearching + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF009195), + ), + ), + ) + : null), + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 14), ), ), ); + + if (width != null) { + return SizedBox(width: width, child: field); + } + return field; } Widget _buildPreStatusFilterChip({ @@ -2098,27 +2421,73 @@ class _HrPolicyDetailsState extends State final selected = _preStatusFilter == filterKey; final color = _getPreFilterChipColor(filterKey); - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _onPreStatusFilterTap(filterKey), - borderRadius: BorderRadius.circular(6), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: selected ? const Color(0xFF009195) : Colors.transparent, - width: 2, + return AnimatedScale( + scale: selected ? 1.03 : 1.0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _onPreStatusFilterTap(filterKey), + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected + ? const Color(0xFF009195) + : Colors.white.withValues(alpha: 0.6), + width: selected ? 2 : 1, + ), + boxShadow: selected + ? [ + BoxShadow( + color: const Color(0xFF009195).withValues(alpha: 0.22), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], ), - ), - child: Text( - '$label - $count', - style: GoogleFonts.poppins( - fontSize: 12, - fontWeight: selected ? FontWeight.w700 : FontWeight.w600, - color: Colors.black, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + color: const Color(0xFF0F172A), + ), + ), + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '$count', + style: GoogleFonts.poppins( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFF009195), + ), + ), + ), + ], ), ), ), @@ -2127,89 +2496,180 @@ class _HrPolicyDetailsState extends State } Widget _buildStatusSummary() { - if (originalData.isEmpty) { - return const SizedBox(); - } + if (_filterChips.isEmpty && !_isLoadingCards) { + return const SizedBox.shrink(); + } - final statusCounts = getPreStatusCounts(); - const filters = >[ - {'key': 'emp_count', 'label': 'Emp Count'}, - {'key': 'enrolled', 'label': 'Submitted'}, - {'key': 'logged_in', 'label': 'Logged-In'}, - {'key': 'not_logged_in', 'label': 'Not Logged-In'}, - {'key': 'draft', 'label': 'Draft'}, - ]; - - return SizedBox( - height: 38, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: filters.length + 1, - separatorBuilder: (_, __) => const SizedBox(width: 10), - itemBuilder: (context, index) { - if (index == filters.length) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: - _preStatusFilter == null ? null : _resetPreStatusFilter, - borderRadius: BorderRadius.circular(6), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: _preStatusFilter != null - ? const Color(0xFF009195) - : const Color(0xFFD0D0D0), - ), - ), - child: Text( - 'Reset', - style: GoogleFonts.poppins( - fontSize: 12, - fontWeight: FontWeight.w600, - color: _preStatusFilter != null - ? const Color(0xFF009195) - : Colors.black54, - ), - ), - ), - ), - ); - } - - final filter = filters[index]; - return _buildPreStatusFilterChip( - filterKey: filter['key']!, - label: filter['label']!, - count: statusCounts[filter['key']] ?? 0, - ); - }, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isLoadingCards) + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator( + minHeight: 3, + color: Color(0xFF009195), + backgroundColor: Color(0xFFE0F2F1), ), ), - const SizedBox(width: 12), - _buildCompactSearchField(), + if (_filterChips.isNotEmpty) + Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ..._filterChips.map((filter) { + final key = filter['key'].toString(); + return _buildPreStatusFilterChip( + filterKey: key, + label: (filter['label'] ?? _chipLabelForKey(key)).toString(), + count: int.tryParse('${filter['count']}') ?? 0, + ); + }), + Material( + color: Colors.transparent, + child: InkWell( + onTap: + _preStatusFilter == null ? null : _resetPreStatusFilter, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _preStatusFilter != null + ? const Color(0xFF009195) + : const Color(0xFFE2E8F0), + ), + ), + child: Text( + 'Reset', + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _preStatusFilter != null + ? const Color(0xFF009195) + : const Color(0xFF94A3B8), + ), + ), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildSearchAndFiltersSection(BoxConstraints constraints) { + final isNarrow = constraints.maxWidth < 720; + + return Container( + width: double.infinity, + padding: EdgeInsets.all(isNarrow ? 14 : 18), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFF0FDFA), + Color(0xFFFFFFFF), + Color(0xFFFFF7ED), + ], + ), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Find members', + style: GoogleFonts.poppins( + fontSize: 15, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A), + ), + ), + const SizedBox(height: 4), + Text( + 'Select a filter chip or search to load matching employee records.', + style: GoogleFonts.poppins( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ), + const SizedBox(height: 14), + _buildStatusSummary(), + const SizedBox(height: 14), + _buildModernSearchField(), ], ), ); } - Widget _buildCDDataTable(BuildContext context) { - if (filteredData.isEmpty) { - return const Center(child: Text('No data available')); - } + Widget _buildSearchPromptEmptyState() { + return Center( + child: AnimatedOpacity( + opacity: 1, + duration: const Duration(milliseconds: 300), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: const Color(0xFF009195).withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.manage_search_rounded, + size: 36, + color: Color(0xFF009195), + ), + ), + const SizedBox(height: 18), + Text( + 'Select a filter or search to view members', + textAlign: TextAlign.center, + style: GoogleFonts.poppins( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A), + ), + ), + const SizedBox(height: 8), + Text( + 'Use a filter chip or the search box above to load employee details.', + textAlign: TextAlign.center, + style: GoogleFonts.poppins( + fontSize: 13, + height: 1.45, + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildCDDataTable(BuildContext context) { return Expanded( - // ✅ VERY IMPORTANT child: CustomScrollView( slivers: [ /// 🔒 Sticky Header @@ -2229,7 +2689,7 @@ class _HrPolicyDetailsState extends State delegate: SliverChildBuilderDelegate( (context, index) { final item = _paginatedData[index]; - return _buildCDRow(item); + return _buildCDRow(item, index); }, childCount: _paginatedData.length, ), @@ -2244,13 +2704,14 @@ class _HrPolicyDetailsState extends State ); } - Widget _buildCDRow(Map item) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - margin: const EdgeInsets.only(top: 6), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Color(0xFFA9D9DE)), + Widget _buildCDRow(Map item, [int index = 0]) { + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + decoration: BoxDecoration( + color: index.isEven ? Colors.white : const Color(0xFFF8FFFE), + border: const Border( + bottom: BorderSide(color: Color(0xFFE2E8F0)), ), ), child: Row( @@ -3284,7 +3745,7 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate { Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( - color: const Color(0xFFD7E9EB), + color: const Color(0xFFE6F5F6), padding: const EdgeInsets.symmetric(horizontal: 16), alignment: Alignment.centerLeft, child: Row( diff --git a/lib/service/api_service.dart b/lib/service/api_service.dart index 3a83ca4..cf1ccb4 100755 --- a/lib/service/api_service.dart +++ b/lib/service/api_service.dart @@ -750,6 +750,55 @@ class ApiService { return _makeGetRequest(url, headers); } + /// Excel export for employee list + /// (`downloadEmployeeListExcel?client_id=&policy_id=&branch_id=`). + Future downloadEmployeeListExcel({ + required String clientId, + required String policyId, + required String branchId, + required bool isPost, + String? token, + }) async { + final authToken = token ?? _token; + if (authToken == null || authToken.isEmpty) { + await _initializeToken(); + } + + final baseUrl = isPost ? Environment.apiUrlPost : Environment.apiUrl; + final url = Uri.parse('${baseUrl}downloadEmployeeListExcel').replace( + queryParameters: { + 'client_id': clientId, + 'policy_id': policyId, + 'branch_id': branchId, + }, + ); + final headers = { + 'Authorization': 'Bearer ${token ?? _token ?? ''}', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + }; + return http.get(url, headers: headers); + } + + /// Excel export for claims collection report (`claims-collection-report/download-excel`). + Future downloadClaimsCollectionReportExcel({ + required String clientPolicyId, + }) async { + if (_token == null) await _initializeToken(); + + final url = Uri.parse( + '${Environment.apiUrlPost}claims-collection-report/download-excel', + ).replace( + queryParameters: {'client_policy_id': clientPolicyId}, + ); + final headers = { + 'Authorization': 'Bearer ${_token ?? ''}', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + }; + return http.get(url, headers: headers); + } + Future> postHrTpaDashboard(params, token) async { final url = Uri.parse('${Environment.apiUrlPost}getHrTpaDashboard'); @@ -1031,13 +1080,26 @@ class ApiService { } Future> getEmployeeAndDependenceToApi( - String clintID, getPolicyNo, String empRefId, String token) async { + String clintID, getPolicyNo, String empRefId, String token, + {dynamic cardData, String? searchKey}) async { logDebug(_hrtoken); if (token == null) { await _initializeToken(); } + final params = { + 'client_id': clintID, + 'client_policy_id': '$getPolicyNo', + 'client_branch_id': empRefId, + }; + if (cardData != null) { + params['card_data'] = cardData.toString(); + } + if (searchKey != null && searchKey.trim().isNotEmpty) { + params['search'] = searchKey.trim(); + } final url = Uri.parse( - '${Environment.apiUrlPost}getEmployeeAndDependenceByClientId?client_id=$clintID&client_policy_id=$getPolicyNo&client_branch_id=$empRefId'); + '${Environment.apiUrlPost}getEmployeeAndDependenceByClientId') + .replace(queryParameters: params); final headers = { 'Authorization': 'Bearer $token' ?? '', }; @@ -1272,13 +1334,26 @@ class ApiService { } Future> getEmployeeAndDependenceToApiPre( - String clintID, String getPolicyNo, String empRefId, String token) async { + String clintID, String getPolicyNo, String empRefId, String token, + {dynamic cardData, String? searchKey}) async { logDebug(_hrtoken); if (token == null) { await _initializeToken(); } - final url = Uri.parse( - '${Environment.apiUrl}getEmployeeAndDependenceByClientId?client_id=$clintID&client_policy_id=$getPolicyNo&client_branch_id=$empRefId'); + final params = { + 'client_id': clintID, + 'client_policy_id': getPolicyNo, + 'client_branch_id': empRefId, + }; + if (cardData != null) { + params['card_data'] = cardData.toString(); + } + if (searchKey != null && searchKey.trim().isNotEmpty) { + params['search'] = searchKey.trim(); + } + final url = + Uri.parse('${Environment.apiUrl}getEmployeeAndDependenceByClientId') + .replace(queryParameters: params); final headers = { 'Authorization': 'Bearer $token' ?? '', };