1028 lines
31 KiB
Dart
1028 lines
31 KiB
Dart
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<PendingDependentApproval> createState() =>
|
|
_PendingDependentApprovalState();
|
|
}
|
|
|
|
class _PendingDependentApprovalState extends State<PendingDependentApproval> {
|
|
final tokenService = TokenStorageService();
|
|
late ApiService apiService;
|
|
|
|
final TextEditingController searchController = TextEditingController();
|
|
Timer? _searchDebounce;
|
|
int _searchRequestId = 0;
|
|
|
|
List<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> _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<void> _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>()
|
|
.map((e) => Map<String, dynamic>.from(e))
|
|
.toList()
|
|
: <Map<String, dynamic>>[];
|
|
|
|
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<String, dynamic> row, List<String> 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<String, dynamic> row) {
|
|
return row['employee_id'] ??
|
|
row['emp_id'] ??
|
|
row['id'] ??
|
|
row['dependent_id'];
|
|
}
|
|
|
|
dynamic _clientPolicyId(Map<String, dynamic> row) {
|
|
return row['client_policy_id'] ??
|
|
row['policy_id'] ??
|
|
row['clientPolicyId'];
|
|
}
|
|
|
|
Future<void> _confirmAndProcess(
|
|
Map<String, dynamic> 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<bool>(
|
|
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<String>(
|
|
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<void> _processDependent(
|
|
Map<String, dynamic> 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<String, dynamic> 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<Map<String, dynamic>> _sortByPendingFirst(
|
|
List<Map<String, dynamic>> rows,
|
|
) {
|
|
final sorted = List<Map<String, dynamic>>.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<String, dynamic> 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<int> 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<int>(
|
|
value: _rowsPerPage,
|
|
underline: const SizedBox.shrink(),
|
|
items: [5, 10, 15, 20, 50].map((value) {
|
|
return DropdownMenuItem<int>(
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|