diff --git a/lib/config/environment.dart b/lib/config/environment.dart index 66c58e0..e2dc4bf 100755 --- a/lib/config/environment.dart +++ b/lib/config/environment.dart @@ -54,6 +54,8 @@ class Environment { return "$base - Dashboard"; case 'policies': return "$base - Policies"; + case 'pendingDependentApproval': + return "$base - Approvals"; case 'CdPoliciesList': return "$base - CD Details"; case 'ClaimsPolicies': diff --git a/lib/customAppBar/side_bar.dart b/lib/customAppBar/side_bar.dart index d582e33..85d55a8 100644 --- a/lib/customAppBar/side_bar.dart +++ b/lib/customAppBar/side_bar.dart @@ -89,6 +89,12 @@ class _NhanceSideBarState extends State { 'label': 'Policies', 'icon': 'policies', }); + items.add({ + 'route': 'pendingDependentApproval', + 'label': 'Approvals', + 'icon': 'pending_approve', + 'useMaterialIcon': true, + }); } // CD @@ -363,18 +369,25 @@ class _NhanceSideBarState extends State { final showClaimsSubMenu = isClaims && _hasMultipleClaimsSubMenus; final claimsDirectRoute = isClaims ? (_singleClaimsRoute ?? 'ClaimsPolicies') : null; + final useMaterialIcon = item['useMaterialIcon'] == true; return _SideItem( key: itemKey, - icon: SvgPicture.string( - SvgService.getSvg(item['icon']), - width: 35, - height: 35, - colorFilter: const ColorFilter.mode( - Colors.white, - BlendMode.srcIn, - ), - ), + icon: useMaterialIcon + ? const Icon( + Icons.how_to_reg_outlined, + color: Colors.white, + size: 30, + ) + : SvgPicture.string( + SvgService.getSvg(item['icon']), + width: 35, + height: 35, + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + ), label: item['label'], isActive: isClaims ? isClaimsActive : activeRoute == item['route'], onTap: showClaimsSubMenu @@ -730,10 +743,14 @@ class _SideItem extends StatelessWidget { const SizedBox(height: 6), Text( label, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, - fontSize: 11, + fontSize: 10, fontWeight: FontWeight.w500, + height: 1.15, ), ), ], diff --git a/lib/main.dart b/lib/main.dart index 1cc85a2..4912aa3 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:nhancepolicy/presentation/postFileUpload.dart'; import 'package:nhancepolicy/presentation/cdList.dart'; import 'package:nhancepolicy/presentation/claims.dart'; import 'package:nhancepolicy/presentation/nonEBClaimsList.dart'; +import 'package:nhancepolicy/presentation/pendingDependentApproval.dart'; import 'package:nhancepolicy/presentation/policies.dart'; import 'package:nhancepolicy/service/session/session_service.dart'; import 'package:nhancepolicy/service/token_storage_service.dart'; @@ -187,6 +188,7 @@ const Set _authProtectedRoutes = { 'hrHome', 'hrDashboard', 'policies', + 'pendingDependentApproval', 'claimsOverviewDashboard', 'ClaimsPolicies', 'nonEBClaimsList', @@ -378,6 +380,9 @@ final Map appRoutes = { 'policies': (context) => AuthWrapper( builder: (_) => const policies(), ), + 'pendingDependentApproval': (context) => AuthWrapper( + builder: (_) => const PendingDependentApproval(), + ), 'CdPoliciesList': (context) => AuthWrapper( builder: (_) => CdPoliciesList(), ), diff --git a/lib/presentation/hrPolicyDetails.dart b/lib/presentation/hrPolicyDetails.dart index c78e027..904071a 100755 --- a/lib/presentation/hrPolicyDetails.dart +++ b/lib/presentation/hrPolicyDetails.dart @@ -1947,13 +1947,13 @@ class _HrPolicyDetailsState extends State ], ( label: 'Import', - icon: Icons.upload_file_rounded, + icon: Icons.file_download_outlined, color: const Color(0xFFE26728), onTap: _openImportFlow, ), ( label: 'Export', - icon: Icons.download_rounded, + icon: Icons.file_upload_outlined, color: const Color(0xFFE26728), onTap: localTokenType == 'pre' ? null diff --git a/lib/presentation/pendingDependentApproval.dart b/lib/presentation/pendingDependentApproval.dart new file mode 100644 index 0000000..b9475ce --- /dev/null +++ b/lib/presentation/pendingDependentApproval.dart @@ -0,0 +1,1027 @@ +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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/service/api_service.dart b/lib/service/api_service.dart index cf1ccb4..b242e4e 100755 --- a/lib/service/api_service.dart +++ b/lib/service/api_service.dart @@ -750,6 +750,120 @@ class ApiService { return _makeGetRequest(url, headers); } + /// Pending approval dependents list + /// (`GET getPendingApprovalDependents`) — post API only. + Future> getPendingApprovalDependents({ + String? clientId, + String? branchId, + String? search, + String? token, + }) async { + final authToken = token ?? _token; + if (authToken == null || authToken.isEmpty) { + await _initializeToken(); + } + + final query = {}; + if (clientId != null && clientId.toString().trim().isNotEmpty) { + query['client_id'] = clientId.toString(); + } + if (branchId != null && branchId.toString().trim().isNotEmpty) { + query['client_branch_id'] = branchId.toString(); + query['branch_id'] = branchId.toString(); + } + if (search != null && search.trim().isNotEmpty) { + query['search'] = search.trim(); + } + + final url = Uri.parse( + '${Environment.apiUrlPost}getPendingApprovalDependents', + ).replace( + queryParameters: query.isEmpty ? null : query, + ); + final headers = { + 'Authorization': 'Bearer ${token ?? _token ?? ''}', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + }; + + final response = await http.get(url, headers: headers); + if (response.statusCode == 200) { + return _parseJsonBody(response); + } + // API returns 404 when there are no pending dependents. + if (response.statusCode == 404) { + final body = _parseJsonBody(response); + return { + 'status': 'success', + 'code': 404, + 'message': body['message'] ?? 'No pending approval dependents found', + 'data': [], + }; + } + return _handleResponse(response); + } + + /// Approve or reject a pending dependent + /// (`POST processDependentAdd`). + Future> processDependentAdd({ + required bool isPost, + required dynamic employeeId, + required dynamic clientPolicyId, + required String status, + dynamic hrId, + String? rejectReason, + 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}processDependentAdd'); + final headers = { + 'Authorization': 'Bearer ${token ?? _token ?? ''}', + 'Content-Type': 'application/json', + 'APP-SIGNATURE': + 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y', + }; + final body = { + 'employee_id': employeeId, + 'client_policy_id': clientPolicyId, + 'status': status, + }; + if (hrId != null && hrId.toString().trim().isNotEmpty) { + body['hr_id'] = hrId is num ? hrId : int.tryParse(hrId.toString()) ?? hrId; + } + if (status == 'rejected' && + rejectReason != null && + rejectReason.trim().isNotEmpty) { + body['reject_reason'] = rejectReason.trim(); + } + + final response = await http.post( + url, + headers: headers, + body: jsonEncode(body), + ); + if (response.statusCode == 200) { + return _parseJsonBody(response); + } + // Surface API validation messages (400/404) without session redirect. + if (response.statusCode == 400 || response.statusCode == 404) { + final parsed = _parseJsonBody(response); + return { + 'status': parsed['status'] ?? 'error', + 'code': response.statusCode, + 'message': parsed['message'] ?? + parsed['data']?.toString() ?? + 'Request failed', + 'data': parsed['data'], + }; + } + return _handleResponse(response); + } + /// Excel export for employee list /// (`downloadEmployeeListExcel?client_id=&policy_id=&branch_id=`). Future downloadEmployeeListExcel({