import 'dart:async'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:nhancepolicy/customAppBar/base_layout.dart'; import 'package:nhancepolicy/customAppBar/toastHelper.dart'; import 'package:nhancepolicy/logger.dart'; import 'package:nhancepolicy/service/api_service.dart'; import 'package:nhancepolicy/service/secure_pop_scope.dart'; import 'package:nhancepolicy/service/token_storage_service.dart'; class PendingDependentApproval extends StatefulWidget { const PendingDependentApproval({super.key}); @override State createState() => _PendingDependentApprovalState(); } class _PendingDependentApprovalState extends State { final tokenService = TokenStorageService(); late ApiService apiService; final TextEditingController searchController = TextEditingController(); Timer? _searchDebounce; int _searchRequestId = 0; List> listData = []; bool isLoading = false; bool _sessionReady = false; bool _isActing = false; int? _actingEmployeeId; bool _isPost = true; String? _token; String? _clientId; String? _branchId; String? _hrId; int _currentPage = 1; int _rowsPerPage = 10; List> get _paginatedData { if (listData.isEmpty) return []; final startIndex = (_currentPage - 1) * _rowsPerPage; if (startIndex >= listData.length) return []; final endIndex = (_currentPage * _rowsPerPage).clamp(0, listData.length); return listData.sublist(startIndex, endIndex); } @override void initState() { super.initState(); apiService = ApiService(context); _loadData(); } @override void dispose() { _searchDebounce?.cancel(); searchController.dispose(); super.dispose(); } Future _ensureSessionContext() async { if (_sessionReady && _token != null && _token!.isNotEmpty) return; _token = tokenService.getCurrentToken(); // Always use post client / branch / hr for pending approval APIs. _isPost = true; _clientId = await tokenService.readValue('empClientId'); _branchId = await tokenService.readValue('empClientBranchId'); _hrId = await tokenService.readValue('empHrId'); _sessionReady = true; } Future _loadData({String? search}) async { final requestId = ++_searchRequestId; final searchQuery = (search ?? searchController.text).trim(); setState(() => isLoading = true); try { await _ensureSessionContext(); final response = await apiService.getPendingApprovalDependents( clientId: _clientId, branchId: _branchId, search: searchQuery.isEmpty ? null : searchQuery, token: _token, ); if (!mounted || requestId != _searchRequestId) return; if (response['status'] == 'success') { final raw = response['data']; final list = raw is List ? raw .whereType() .map((e) => Map.from(e)) .toList() : >[]; setState(() { listData = _sortByPendingFirst(list); _currentPage = 1; isLoading = false; }); } else { setState(() { listData = []; isLoading = false; }); final message = response['message']?.toString() ?? 'Failed to load dependents'; ToastHelper.showWarningToast(context, message); } } catch (e) { logDebug('getPendingApprovalDependents error: $e'); if (!mounted || requestId != _searchRequestId) return; setState(() { listData = []; isLoading = false; }); ToastHelper.showErrorToast(context, 'Failed to load pending dependents'); } } void _onSearchChanged(String query) { _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 450), () { _loadData(search: query); }); } void _onRefresh() { _searchDebounce?.cancel(); searchController.clear(); _loadData(search: ''); } String _field(Map row, List keys) { for (final key in keys) { final value = row[key]; if (value != null && value.toString().trim().isNotEmpty) { return value.toString().trim(); } } // Compose first + last when present. if (keys.contains('first_name') || keys.contains('emp_name')) { final first = row['first_name']?.toString().trim() ?? ''; final last = row['last_name']?.toString().trim() ?? ''; final combined = '$first $last'.trim(); if (combined.isNotEmpty) return combined; } return '-'; } /// Normalize API dates to `dd-MM-yyyy`. String _formatDisplayDate(dynamic raw) { if (raw == null) return '-'; final value = raw.toString().trim(); if (value.isEmpty || value == '-') return '-'; final cleaned = value .replaceAll('/', '-') .replaceAll('.', '-') .split(' ') .first .split('T') .first; // Already dd-MM-yyyy final dmy = RegExp(r'^(\d{2})-(\d{2})-(\d{4})$').firstMatch(cleaned); if (dmy != null) return cleaned; // yyyy-MM-dd → dd-MM-yyyy final ymd = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(cleaned); if (ymd != null) { return '${ymd.group(3)}-${ymd.group(2)}-${ymd.group(1)}'; } // dd-MM-yy → leave as-is if not parseable further return cleaned; } dynamic _employeeId(Map row) { return row['employee_id'] ?? row['emp_id'] ?? row['id'] ?? row['dependent_id']; } dynamic _clientPolicyId(Map row) { return row['client_policy_id'] ?? row['policy_id'] ?? row['clientPolicyId']; } Future _confirmAndProcess( Map row, String status, ) async { final employeeId = _employeeId(row); final clientPolicyId = _clientPolicyId(row); if (employeeId == null || clientPolicyId == null) { ToastHelper.showWarningToast( context, 'Missing employee or policy id for this record', ); return; } final isApprove = status == 'approved'; final name = _field(row, const [ 'emp_name', 'name', 'dependent_name', 'first_name', 'last_name', ]); if (isApprove) { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), title: Text( 'Confirm Approval', style: GoogleFonts.poppins(fontWeight: FontWeight.w600), ), content: Text( 'Are you sure you want to approve the dependent "$name"?\n\n' 'This will activate their policy coverage.', style: GoogleFonts.poppins(fontSize: 14, height: 1.4), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: Text( 'Cancel', style: GoogleFonts.poppins(color: const Color(0xFF009195)), ), ), ElevatedButton( onPressed: () => Navigator.of(ctx).pop(true), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF009195), elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: Text( 'Approve', style: GoogleFonts.poppins(color: Colors.white), ), ), ], ), ) ?? false; if (!confirmed || !mounted) return; await _processDependent(row, status); return; } // Reject flow — require reason. final reasonController = TextEditingController(); final rejectResult = await showDialog( context: context, builder: (ctx) { String? errorText; return StatefulBuilder( builder: (ctx, setDialogState) { return AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), title: Text( 'Confirm Rejection', style: GoogleFonts.poppins(fontWeight: FontWeight.w600), ), content: SizedBox( width: 420, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Are you sure you want to reject the dependent "$name"?\n' 'Please provide a reason for rejection.', style: GoogleFonts.poppins(fontSize: 14, height: 1.4), ), const SizedBox(height: 14), Text( 'Reason', style: GoogleFonts.poppins( fontSize: 13, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 6), TextField( controller: reasonController, maxLines: 4, autofocus: true, style: GoogleFonts.poppins(fontSize: 13), decoration: InputDecoration( hintText: 'Enter rejection reason...', hintStyle: GoogleFonts.poppins( fontSize: 13, color: const Color(0xFF94A3B8), ), errorText: errorText, filled: true, fillColor: const Color(0xFFF8FAFC), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFFE2E8F0)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFFE2E8F0)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF009195)), ), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text( 'Cancel', style: GoogleFonts.poppins(color: const Color(0xFF009195)), ), ), ElevatedButton( onPressed: () { final reason = reasonController.text.trim(); if (reason.isEmpty) { setDialogState(() { errorText = 'Rejection reason is required'; }); return; } Navigator.of(ctx).pop(reason); }, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFE26728), elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: Text( 'Reject', style: GoogleFonts.poppins(color: Colors.white), ), ), ], ); }, ); }, ); reasonController.dispose(); if (rejectResult == null || !mounted) return; await _processDependent(row, status, rejectReason: rejectResult); } Future _processDependent( Map row, String status, { String? rejectReason, }) async { final employeeId = _employeeId(row); final clientPolicyId = _clientPolicyId(row); setState(() { _isActing = true; _actingEmployeeId = int.tryParse(employeeId.toString()); }); try { final response = await apiService.processDependentAdd( isPost: _isPost, employeeId: employeeId is num ? employeeId : int.tryParse(employeeId.toString()) ?? employeeId, clientPolicyId: clientPolicyId is num ? clientPolicyId : int.tryParse(clientPolicyId.toString()) ?? clientPolicyId, status: status, hrId: _hrId, rejectReason: rejectReason, token: _token, ); if (!mounted) return; if (response['status'] == 'success') { ToastHelper.showSuccessToast( context, status == 'approved' ? 'Dependent approved successfully' : 'Dependent rejected successfully', ); setState(() { final idx = listData.indexWhere( (r) => _employeeId(r)?.toString() == employeeId.toString(), ); if (idx != -1) { listData[idx] = { ...listData[idx], 'status': status, if (rejectReason != null) 'reject_reason': rejectReason, }; listData = _sortByPendingFirst(listData); } }); } else { final message = response['message']?.toString() ?? response['data']?.toString() ?? 'Action failed'; ToastHelper.showWarningToast(context, message); } } catch (e) { logDebug('processDependentAdd error: $e'); if (mounted) { ToastHelper.showErrorToast(context, 'Failed to process dependent'); } } finally { if (mounted) { setState(() { _isActing = false; _actingEmployeeId = null; }); } } } @override Widget build(BuildContext context) { return BaseLayout( child: SecurePopScope( child: _buildContent(context), ), ); } Widget _buildContent(BuildContext context) { return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), ), padding: const EdgeInsets.all(16), child: Column( children: [ Row( children: [ Text( 'Approvals', style: GoogleFonts.poppins( fontSize: 22, fontWeight: FontWeight.w500, color: Colors.black, ), ), const Spacer(), Container( width: 360, height: 40, decoration: BoxDecoration( color: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(8), ), child: TextField( controller: searchController, onChanged: _onSearchChanged, style: GoogleFonts.poppins(fontSize: 14), decoration: InputDecoration( hintText: 'Search by name, emp code, mobile, email...', hintStyle: GoogleFonts.poppins( fontSize: 13, color: const Color(0xFF94A3B8), ), prefixIcon: const Icon(Icons.search, size: 18), border: InputBorder.none, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), ), ), const SizedBox(width: 10), IconButton( tooltip: 'Refresh', onPressed: isLoading ? null : _onRefresh, icon: const Icon(Icons.refresh_rounded, color: Color(0xFF009195)), ), ], ), const SizedBox(height: 16), Expanded( child: isLoading ? Center( child: Image.asset( 'assets/nhance-loader.gif', height: 60, width: 60, ), ) : _buildTable(context), ), ], ), ); } Widget _buildTable(BuildContext context) { if (listData.isEmpty) { return Center( child: Text( searchController.text.trim().isEmpty ? 'No pending approval dependents found' : 'No matching dependents', style: GoogleFonts.poppins( fontSize: 14, color: const Color(0xFF64748B), ), ), ); } return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE2E8F0)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 10, offset: const Offset(0, 2), ), ], ), clipBehavior: Clip.antiAlias, child: Column( children: [ _buildHeaderRow(), Expanded( child: ListView.builder( itemCount: _paginatedData.length, itemBuilder: (context, index) { return _buildDataRow(_paginatedData[index], index); }, ), ), const Divider(height: 1, thickness: 1, color: Color(0xFFE2E8F0)), _buildPagination(context), ], ), ); } Widget _buildHeaderRow() { return Container( height: 55, color: const Color(0xFFE6F5F6), padding: const EdgeInsets.symmetric(horizontal: 16), alignment: Alignment.centerLeft, child: Row( children: [ _headerCell('Employee', flex: 3), _headerCell('Dependent', flex: 3), _headerCell('Relationship', flex: 2), _headerCell('Date Of Birth', flex: 2), _headerCell('Dependent Effective Date', flex: 3), _headerCell('Policy', flex: 3), _headerCell('Status', flex: 2), _headerCell('Action', flex: 2, align: TextAlign.center), ], ), ); } Widget _headerCell( String label, { required int flex, TextAlign align = TextAlign.left, }) { return Expanded( flex: flex, child: Text( label, textAlign: align, style: GoogleFonts.poppins( fontSize: 13, fontWeight: FontWeight.w600, color: const Color(0xFF0F172A), ), ), ); } String _rowStatus(Map item) { return _field(item, const ['status', 'emp_status', 'approval_status']) .toLowerCase() .trim(); } int _statusSortOrder(String status) { switch (status) { case 'pending_approval': return 0; case 'rejected': return 1; default: return 2; } } /// `pending_approval` first, then `rejected`, then other statuses. List> _sortByPendingFirst( List> rows, ) { final sorted = List>.from(rows); sorted.sort((a, b) { return _statusSortOrder(_rowStatus(a)) .compareTo(_statusSortOrder(_rowStatus(b))); }); return sorted; } String _statusLabel(String status) { if (status.isEmpty || status == '-') return 'Pending'; if (status == 'approved' || status == 'active') return 'Approved'; if (status == 'rejected') return 'Rejected'; if (status == 'pending' || status == 'pending_approval') return 'Pending'; return status[0].toUpperCase() + status.substring(1); } Color _statusChipColor(String status) { final normalized = status.toLowerCase(); if (normalized == 'approved' || normalized == 'active') { return const Color(0xFFDCFCE7); } if (normalized == 'rejected') { return const Color(0xFFFEE2E2); } return const Color(0xFFFFF7ED); } Color _statusTextColor(String status) { final normalized = status.toLowerCase(); if (normalized == 'approved' || normalized == 'active') { return const Color(0xFF166534); } if (normalized == 'rejected') { return const Color(0xFFB91C1C); } return const Color(0xFFC2410C); } Widget _buildDataRow(Map item, int index) { final employeeId = _employeeId(item); final isRowActing = _isActing && _actingEmployeeId?.toString() == employeeId?.toString(); final employeeName = _field(item, const ['self_name']); final empCode = _field(item, const ['emp_code', 'employee_code', 'code']); final dependentName = _field(item, const [ 'emp_name', 'name', 'dependent_name', 'first_name', 'last_name', ]); final relation = _field(item, const [ 'relationship', 'relation', 'relation_name', 'relationship_name', ]); final dob = _formatDisplayDate(_field(item, const [ 'dob', 'date_of_birth', 'dateOfBirth', 'birth_date', 'formatted_dob', ])); final effectiveDate = _formatDisplayDate(_field(item, const [ 'date_coverage', 'coverage_date', 'dependent_effective_date', ])); final policy = _field(item, const [ 'policy_no', 'policy_name', 'policy_type_name', 'policy_type', ]); final statusRaw = _rowStatus(item); final statusLabel = _statusLabel(statusRaw); final isApproved = statusRaw == 'approved' || statusRaw == 'active'; final isRejected = statusRaw == 'rejected'; final rejectReason = _field(item, const [ 'reject_reason', 'rejection_reason', 'reason', ]); final showRejectReasonTooltip = isRejected && rejectReason != '-' && rejectReason.isNotEmpty; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: index.isEven ? Colors.white : const Color(0xFFF8FFFE), border: const Border( bottom: BorderSide(color: Color(0xFFE2E8F0)), ), ), child: Row( children: [ Expanded( flex: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(employeeName, style: _dataBold), Text(empCode, style: _dataSub), ], ), ), _dataCell(dependentName, flex: 3), _dataCell(relation, flex: 2), _dataCell(dob, flex: 2), _dataCell(effectiveDate, flex: 3), _dataCell(policy, flex: 3), Expanded( flex: 2, child: Align( alignment: Alignment.centerLeft, child: Builder( builder: (context) { final chip = Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: _statusChipColor(statusRaw), borderRadius: BorderRadius.circular(10), ), child: Text( statusLabel, style: GoogleFonts.poppins( fontSize: 11, fontWeight: FontWeight.w600, color: _statusTextColor(statusRaw), ), ), ); if (!showRejectReasonTooltip) return chip; return Tooltip( message: 'Reason: $rejectReason', waitDuration: const Duration(milliseconds: 250), child: chip, ); }, ), ), ), Expanded( flex: 2, child: isRowActing ? const Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2), ), ) : isApproved ? Center(child: Text('-', style: _dataBold)) : Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildActionIconSlot( tooltip: 'Approve', icon: const Icon( Icons.check_circle_rounded, color: Color(0xFF16A34A), size: 26, ), onPressed: _isActing ? null : () => _confirmAndProcess(item, 'approved'), ), _buildActionIconSlot( tooltip: 'Reject', icon: const Icon( Icons.cancel_rounded, color: Color(0xFFE26728), size: 26, ), onPressed: _isActing || isRejected ? null : () => _confirmAndProcess(item, 'rejected'), visible: !isRejected, ), ], ), ), ), ], ), ); } static final _dataBold = GoogleFonts.poppins( fontSize: 12, fontWeight: FontWeight.w400, color: Color(0xFF000000), ); static final _dataSub = GoogleFonts.poppins( fontSize: 10, fontWeight: FontWeight.w300, color: Color(0xFF585757), ); Widget _dataCell(String text, {required int flex}) { return Expanded( flex: flex, child: Text( text, maxLines: 2, overflow: TextOverflow.ellipsis, style: _dataBold, ), ); } Widget _buildActionIconSlot({ required String tooltip, required Widget icon, required VoidCallback? onPressed, bool visible = true, }) { return SizedBox( width: 40, height: 40, child: visible ? IconButton( tooltip: tooltip, onPressed: onPressed, padding: EdgeInsets.zero, constraints: const BoxConstraints(), icon: icon, ) : null, ); } Widget _buildPagination(BuildContext context) { final totalItems = listData.length; final startEntry = totalItems == 0 ? 0 : ((_currentPage - 1) * _rowsPerPage) + 1; var endEntry = _currentPage * _rowsPerPage; if (endEntry > totalItems) endEntry = totalItems; final totalPages = totalItems == 0 ? 1 : (totalItems / _rowsPerPage).ceil(); List getVisiblePages() { const visiblePageCount = 5; if (totalPages <= visiblePageCount) { return List.generate(totalPages, (i) => i + 1); } if (_currentPage <= 3) return [1, 2, 3, 4, 5]; if (_currentPage >= totalPages - 2) { return [ totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages, ]; } return [ _currentPage - 2, _currentPage - 1, _currentPage, _currentPage + 1, _currentPage + 2, ]; } final visiblePages = getVisiblePages(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Showing $startEntry to $endEntry of $totalItems entries', style: GoogleFonts.poppins( fontSize: 13, color: const Color(0xFF585757), ), ), Row( children: [ Text( 'Rows', style: GoogleFonts.poppins( fontSize: 13, color: const Color(0xFF585757), ), ), const SizedBox(width: 8), DropdownButton( value: _rowsPerPage, underline: const SizedBox.shrink(), items: [5, 10, 15, 20, 50].map((value) { return DropdownMenuItem( value: value, child: Text( '$value', style: GoogleFonts.poppins(fontSize: 14), ), ); }).toList(), onChanged: (value) { if (value == null) return; setState(() { _rowsPerPage = value; _currentPage = 1; }); }, ), IconButton( onPressed: _currentPage > 1 ? () => setState(() => _currentPage--) : null, icon: const Icon(Icons.chevron_left), ), if (!visiblePages.contains(1) && totalPages > 0) ...[ _buildPageButton(1), const Padding( padding: EdgeInsets.symmetric(horizontal: 4), child: Text('...'), ), ], for (final page in visiblePages) _buildPageButton(page), if (!visiblePages.contains(totalPages) && totalPages > 0) ...[ const Padding( padding: EdgeInsets.symmetric(horizontal: 4), child: Text('...'), ), _buildPageButton(totalPages), ], IconButton( onPressed: _currentPage < totalPages ? () => setState(() => _currentPage++) : null, icon: const Icon(Icons.chevron_right), ), ], ), ], ), ); } Widget _buildPageButton(int page) { final isActive = _currentPage == page; return Padding( padding: const EdgeInsets.symmetric(horizontal: 3), child: SizedBox( width: 36, height: 36, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: isActive ? const Color(0xFF00A6A6) : Colors.grey[300], foregroundColor: isActive ? Colors.white : Colors.black, elevation: 0, padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(18), ), ), onPressed: () => setState(() => _currentPage = page), child: Text( '$page', style: GoogleFonts.poppins( fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ), ); } }