1283 lines
40 KiB
Dart
1283 lines
40 KiB
Dart
import 'package:dropdown_search/dropdown_search.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../../../core/services/api_service.dart';
|
|
import '../../../data/utils/Pagination.dart';
|
|
import '../../../data/utils/toastNotification.dart';
|
|
import '../../layouts/main_layout.dart';
|
|
import '../../layouts/responsive_layout.dart';
|
|
import '../../providers/manager_provider.dart';
|
|
import '../../providers/userRoleProvider.dart';
|
|
import '../../themes/indicators/date_range_picker_field.dart';
|
|
import '../../themes/indicators/search_field_theme.dart';
|
|
|
|
/// Payout report — layout and styling aligned with [policylist] policy list screen.
|
|
class PayoutReportScreen extends ConsumerStatefulWidget {
|
|
const PayoutReportScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<PayoutReportScreen> createState() => _PayoutReportScreenState();
|
|
}
|
|
|
|
class _PayoutReportScreenState extends ConsumerState<PayoutReportScreen> {
|
|
late ApiService apiService;
|
|
int currentPage = 1;
|
|
int itemsPerPage = 10;
|
|
bool isLoading = false;
|
|
|
|
final TextEditingController _startController = TextEditingController();
|
|
final TextEditingController _endController = TextEditingController();
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
List<Map<String, dynamic>> _rows = [];
|
|
List<Map<String, dynamic>> _filtered = [];
|
|
List<Map<String, dynamic>> _agentOptions = [];
|
|
String? _selectedAgentId;
|
|
/// `null` = "Select Payout Raised" (not chosen). `'all'` | `'yes'` | `'no'` sent to API.
|
|
String? _payoutRaised;
|
|
|
|
bool _hasSearched = false;
|
|
|
|
/// From API root `count` and `summary.{total,raised,pending}`.
|
|
int? _reportCount;
|
|
num? _summaryTotal;
|
|
num? _summaryRaised;
|
|
num? _summaryPending;
|
|
|
|
int? _managerId;
|
|
|
|
bool _isAgentRole() {
|
|
final r = (ref.read(userRoleProvider) ?? '').toString().trim().toLowerCase();
|
|
return r == 'agent' || r == 'a';
|
|
}
|
|
|
|
/// `received_or_not` equals `no` (case-insensitive).
|
|
bool _receivedIsNo(Map<String, dynamic> item) {
|
|
final v = (item['received_or_not'] ?? '').toString().trim().toLowerCase();
|
|
return v == 'no';
|
|
}
|
|
|
|
/// UI / export: capitalize first letter, lowercase the rest (e.g. `yes` → `Yes`).
|
|
String _receivedLabel(dynamic v) {
|
|
final s = v?.toString().trim() ?? '';
|
|
if (s.isEmpty) return '-';
|
|
return s[0].toUpperCase() +
|
|
(s.length > 1 ? s.substring(1).toLowerCase() : '');
|
|
}
|
|
|
|
bool _isAccountsReceivedRole() {
|
|
final role = (ref.read(userRoleProvider) ?? '')
|
|
.toString()
|
|
.trim()
|
|
.toLowerCase()
|
|
.replaceAll(RegExp(r'[\s_\-]+'), '');
|
|
return role == 'accountsreceived' || role == 'accounts';
|
|
}
|
|
|
|
String _receivedDisplayLabel(dynamic v) {
|
|
final raw = v?.toString().trim().toLowerCase() ?? '';
|
|
if (_isAccountsReceivedRole()) {
|
|
if (raw == 'yes' || raw == 'received' || raw == 'paid') {
|
|
return 'Paid';
|
|
}
|
|
}
|
|
return _receivedLabel(v);
|
|
}
|
|
|
|
String? _agentIdForApi() {
|
|
if (_isAgentRole()) {
|
|
final uid = ref.read(userIdProvider);
|
|
return uid?.toString();
|
|
}
|
|
return _selectedAgentId;
|
|
}
|
|
|
|
static const _tableHeaderBg = Color(0xFFF1F5F9);
|
|
static const _rowBorder = Color(0xFFEAEAEA);
|
|
/// Pending summary chip + `received_or_not == no` row highlight (same palette).
|
|
static const _pendingChipBg = Color(0xFFFFEBEE);
|
|
static const _pendingChipBorder = Color(0xFFEF9A9A);
|
|
static const _pendingChipValue = Color(0xFFC62828);
|
|
static const _filterStripMobileBg = Color(0xffD9EBE8);
|
|
/// Closed filter fields (light outline, white fill) — first design.
|
|
static const _filterFieldBorder = Color(0xFFE2E8F0);
|
|
static const _filterFocusTeal = Color(0xFF50A398);
|
|
|
|
static final _dataBold = GoogleFonts.inter(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w400,
|
|
color: const Color(0xFF000000),
|
|
);
|
|
|
|
/// Table column titles (policy list: bold header row).
|
|
static final _headerColumn = GoogleFonts.poppins(
|
|
fontSize: 11.2,
|
|
fontWeight: FontWeight.w600,
|
|
color: const Color(0xFF1E293B),
|
|
);
|
|
|
|
InputDecoration _filterDropdownDecoration({required String hintText}) {
|
|
return InputDecoration(
|
|
isDense: true,
|
|
hintText: hintText,
|
|
hintStyle: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF94A3B8)),
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: _filterFieldBorder, width: 1),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: _filterFieldBorder, width: 1),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: _filterFocusTeal, width: 1.5),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// `agent/agentList` exposes display name as `name`; older payloads may use `agent_name`.
|
|
String _partnerDisplayName(Map<String, dynamic> m) {
|
|
final v = m['name'] ?? m['agent_name'] ?? m['partner_name'] ?? '';
|
|
return v.toString().trim();
|
|
}
|
|
|
|
Map<String, dynamic> _allAgentItem() => {
|
|
'id': '',
|
|
'name': 'Select Agent',
|
|
'agent_name': 'Select Agent',
|
|
'agent_code': '',
|
|
};
|
|
|
|
Map<String, dynamic> _selectedAgentMap() {
|
|
if (_selectedAgentId == null || _selectedAgentId!.isEmpty) {
|
|
return _allAgentItem();
|
|
}
|
|
try {
|
|
final m = _agentOptions.firstWhere(
|
|
(a) => a['id'].toString() == _selectedAgentId,
|
|
);
|
|
return Map<String, dynamic>.from(m);
|
|
} catch (_) {
|
|
return _allAgentItem();
|
|
}
|
|
}
|
|
|
|
List<Map<String, dynamic>> _payoutRaisedChoices() => [
|
|
{'v': null, 'label': 'Select Payout Raised'},
|
|
{'v': 'all', 'label': 'All'},
|
|
{'v': 'yes', 'label': 'Yes'},
|
|
{'v': 'no', 'label': 'No'},
|
|
];
|
|
|
|
Map<String, dynamic> _selectedPayoutRaisedMap() {
|
|
for (final e in _payoutRaisedChoices()) {
|
|
if (e['v'] == _payoutRaised) return e;
|
|
}
|
|
return _payoutRaisedChoices().first;
|
|
}
|
|
|
|
Widget _buildAgentDropdownSearch(double width) {
|
|
final agents = _agentOptions
|
|
.where((a) => (a['id']?.toString() ?? '').isNotEmpty)
|
|
.map((a) => Map<String, dynamic>.from(a))
|
|
.toList();
|
|
|
|
return SizedBox(
|
|
height: 35,
|
|
width: width,
|
|
child: DropdownSearch<Map<String, dynamic>>(
|
|
selectedItem: _selectedAgentMap(),
|
|
items: (filter, infiniteScrollProps) {
|
|
return [_allAgentItem(), ...agents];
|
|
},
|
|
itemAsString: (m) {
|
|
if ((m['id']?.toString() ?? '').isEmpty) {
|
|
return 'Select Agent';
|
|
}
|
|
final name = _partnerDisplayName(m);
|
|
final code = (m['agent_code'] ?? '').toString();
|
|
return '$name $code'.trim();
|
|
},
|
|
compareFn: (a, b) =>
|
|
(a['id']?.toString() ?? '') == (b['id']?.toString() ?? ''),
|
|
decoratorProps: DropDownDecoratorProps(
|
|
decoration: _filterDropdownDecoration(hintText: 'Select Agent'),
|
|
),
|
|
dropdownBuilder: (context, selectedItem) {
|
|
final m = selectedItem ?? _allAgentItem();
|
|
final isAll = (m['id']?.toString() ?? '').isEmpty;
|
|
final name = _partnerDisplayName(m);
|
|
final code = (m['agent_code'] ?? '').toString();
|
|
final text = isAll
|
|
? 'Select Agent'
|
|
: name.isEmpty
|
|
? code
|
|
: (code.isNotEmpty ? '$name — $code' : name);
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
text,
|
|
style: GoogleFonts.inter(
|
|
fontSize: 11,
|
|
color: isAll
|
|
? const Color(0xFF94A3B8)
|
|
: const Color(0xFF0F172A),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
);
|
|
},
|
|
popupProps: PopupProps.menu(
|
|
showSearchBox: true,
|
|
fit: FlexFit.loose,
|
|
constraints: const BoxConstraints(maxHeight: 280),
|
|
menuProps: MenuProps(
|
|
backgroundColor: Colors.white,
|
|
elevation: 8,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
searchFieldProps: TextFieldProps(
|
|
decoration: InputDecoration(
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
hintText: 'Select Agent',
|
|
hintStyle:
|
|
GoogleFonts.inter(fontSize: 12, color: const Color(0xFF94A3B8)),
|
|
isDense: true,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: _filterFieldBorder),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: _filterFieldBorder),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide:
|
|
const BorderSide(color: _filterFocusTeal, width: 1.5),
|
|
),
|
|
),
|
|
),
|
|
itemBuilder: (context, item, isDisabled, isSelected) {
|
|
final isAll = (item['id']?.toString() ?? '').isEmpty;
|
|
return Container(
|
|
color: isSelected ? const Color(0xFFF1F5F9) : null,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: isAll
|
|
? Text(
|
|
'Select Agent',
|
|
style: GoogleFonts.inter(
|
|
fontSize: 12,
|
|
color: const Color(0xFF64748B),
|
|
),
|
|
)
|
|
: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
() {
|
|
final n = _partnerDisplayName(item);
|
|
final c = (item['agent_code'] ?? '').toString();
|
|
return (n.isEmpty ? c : n).toUpperCase();
|
|
}(),
|
|
style: GoogleFonts.inter(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: const Color(0xFF0F172A),
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
(item['agent_code'] ?? '').toString(),
|
|
style: GoogleFonts.inter(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w400,
|
|
color: const Color(0xFF94A3B8),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
onChanged: (val) {
|
|
setState(() {
|
|
final id = val?['id']?.toString() ?? '';
|
|
_selectedAgentId = id.isEmpty ? null : id;
|
|
});
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPayoutRaisedDropdownSearch(double width) {
|
|
final choices = _payoutRaisedChoices();
|
|
return SizedBox(
|
|
height: 35,
|
|
width: width,
|
|
child: DropdownSearch<Map<String, dynamic>>(
|
|
selectedItem: _selectedPayoutRaisedMap(),
|
|
items: (filter, infiniteScrollProps) => choices,
|
|
itemAsString: (m) => (m['label'] ?? '').toString(),
|
|
compareFn: (a, b) => a['v'] == b['v'],
|
|
decoratorProps: DropDownDecoratorProps(
|
|
decoration: _filterDropdownDecoration(hintText: 'Select Payout Raised'),
|
|
),
|
|
dropdownBuilder: (context, selectedItem) {
|
|
final m = selectedItem ?? _payoutRaisedChoices().first;
|
|
final label = (m['label'] ?? '').toString();
|
|
final isUnset = m['v'] == null;
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
label,
|
|
style: GoogleFonts.inter(
|
|
fontSize: 11,
|
|
color: isUnset
|
|
? const Color(0xFF94A3B8)
|
|
: const Color(0xFF0F172A),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
);
|
|
},
|
|
popupProps: PopupProps.menu(
|
|
showSearchBox: true,
|
|
fit: FlexFit.loose,
|
|
constraints: const BoxConstraints(maxHeight: 220),
|
|
menuProps: MenuProps(
|
|
backgroundColor: Colors.white,
|
|
elevation: 8,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
searchFieldProps: TextFieldProps(
|
|
decoration: InputDecoration(
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
hintText: 'Select Payout Raised',
|
|
hintStyle:
|
|
GoogleFonts.inter(fontSize: 12, color: const Color(0xFF94A3B8)),
|
|
isDense: true,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: _filterFieldBorder),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: _filterFieldBorder),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide:
|
|
const BorderSide(color: _filterFocusTeal, width: 1.5),
|
|
),
|
|
),
|
|
),
|
|
itemBuilder: (context, item, isDisabled, isSelected) {
|
|
final isUnset = item['v'] == null;
|
|
return Container(
|
|
color: isSelected ? const Color(0xFFF1F5F9) : null,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: Text(
|
|
(item['label'] ?? '').toString(),
|
|
style: GoogleFonts.inter(
|
|
fontSize: 12,
|
|
fontWeight: isUnset ? FontWeight.w400 : FontWeight.w500,
|
|
color: isUnset
|
|
? const Color(0xFF64748B)
|
|
: const Color(0xFF0F172A),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
onChanged: (val) {
|
|
setState(() {
|
|
if (val == null) {
|
|
_payoutRaised = null;
|
|
} else {
|
|
_payoutRaised = val['v'] as String?;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
apiService = ApiService();
|
|
Future.microtask(() async {
|
|
_managerId = ref.read(managerIdProvider);
|
|
if (_managerId != null && !_isAgentRole()) {
|
|
await _loadAgents();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_startController.dispose();
|
|
_endController.dispose();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadAgents() async {
|
|
final mid = _managerId;
|
|
if (mid == null) return;
|
|
try {
|
|
final response = await apiService.fetchAgentUserList(mid);
|
|
if (response['status'] == 'success' && response['data'] is List) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_agentOptions = List<Map<String, dynamic>>.from(response['data']);
|
|
});
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
String? _dateToIso(String ddMmYyyy) {
|
|
final t = ddMmYyyy.trim();
|
|
if (t.isEmpty) return null;
|
|
try {
|
|
final d = DateFormat('dd-MM-yyyy').parseStrict(t);
|
|
return DateFormat('yyyy-MM-dd').format(d);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
bool _validateFiltersForSearch() {
|
|
final from = _dateToIso(_startController.text);
|
|
final to = _dateToIso(_endController.text);
|
|
final startText = _startController.text.trim();
|
|
final endText = _endController.text.trim();
|
|
if (startText.isNotEmpty || endText.isNotEmpty) {
|
|
if (from == null || to == null) {
|
|
ToastHelper.showWarningToast(
|
|
context,
|
|
'Enter both From date and To date, or clear both.',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
final hasDates = from != null && to != null;
|
|
final hasAgent = _isAgentRole()
|
|
? ref.read(userIdProvider) != null
|
|
: (_selectedAgentId != null && _selectedAgentId!.trim().isNotEmpty);
|
|
final hasPayoutRaised = _payoutRaised != null;
|
|
if (!hasDates && !hasAgent && !hasPayoutRaised) {
|
|
ToastHelper.showWarningToast(
|
|
context,
|
|
_isAgentRole()
|
|
? 'Select at least one filter: date range or payout raised.'
|
|
: 'Select at least one filter: date range, agent, or payout raised.',
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<void> _loadReport() async {
|
|
if (!_validateFiltersForSearch()) return;
|
|
|
|
setState(() {
|
|
isLoading = true;
|
|
_hasSearched = true;
|
|
});
|
|
try {
|
|
final response = await apiService.fetchPayoutReportList(
|
|
fromDate: _dateToIso(_startController.text),
|
|
toDate: _dateToIso(_endController.text),
|
|
agentId: _agentIdForApi(),
|
|
payoutRaised: _payoutRaised,
|
|
);
|
|
if (!mounted) return;
|
|
|
|
final data = response['data'];
|
|
final ok = response['status']?.toString().toLowerCase() == 'success' ||
|
|
response['code'] == 200 ||
|
|
data is List;
|
|
if (ok) {
|
|
List<Map<String, dynamic>> next = [];
|
|
if (data is List) {
|
|
next = data.map((e) {
|
|
if (e is Map<String, dynamic>) return e;
|
|
if (e is Map) return Map<String, dynamic>.from(e);
|
|
return <String, dynamic>{};
|
|
}).toList();
|
|
} else if (data is Map) {
|
|
next = [Map<String, dynamic>.from(data)];
|
|
}
|
|
|
|
int? count;
|
|
final cr = response['count'];
|
|
if (cr is int) {
|
|
count = cr;
|
|
} else if (cr != null) {
|
|
count = int.tryParse(cr.toString());
|
|
}
|
|
num? sumTotal;
|
|
num? sumRaised;
|
|
num? sumPending;
|
|
final sm = response['summary'];
|
|
if (sm is Map) {
|
|
final m = Map<String, dynamic>.from(sm);
|
|
sumTotal = _parseNum(m['total']);
|
|
sumRaised = _parseNum(m['raised']);
|
|
sumPending = _parseNum(m['pending']);
|
|
}
|
|
|
|
setState(() {
|
|
_rows = next;
|
|
_reportCount = count;
|
|
_summaryTotal = sumTotal;
|
|
_summaryRaised = sumRaised;
|
|
_summaryPending = sumPending;
|
|
_applySearch(_searchController.text);
|
|
currentPage = 1;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_rows = [];
|
|
_filtered = [];
|
|
_reportCount = null;
|
|
_summaryTotal = null;
|
|
_summaryRaised = null;
|
|
_summaryPending = null;
|
|
});
|
|
final msg = response['message']?.toString();
|
|
if (msg != null && msg.isNotEmpty) {
|
|
ToastHelper.showWarningToast(context, msg);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ToastHelper.showErrorToast(context, e.toString());
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _filterData(String query) {
|
|
setState(() {
|
|
_applySearch(query);
|
|
currentPage = 1;
|
|
});
|
|
}
|
|
|
|
void _applySearch(String query) {
|
|
final q = query.trim().toLowerCase();
|
|
if (q.isEmpty) {
|
|
_filtered = List.from(_rows);
|
|
return;
|
|
}
|
|
_filtered = _rows.where((row) {
|
|
return row.values.any(
|
|
(v) => v != null && v.toString().toLowerCase().contains(q),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
List<Map<String, dynamic>> get _paginated {
|
|
if (_filtered.isEmpty) return [];
|
|
final maxPage = (_filtered.length / itemsPerPage).ceil().clamp(1, 999999);
|
|
final safe = currentPage.clamp(1, maxPage);
|
|
final start = (safe - 1) * itemsPerPage;
|
|
final end = (start + itemsPerPage).clamp(0, _filtered.length);
|
|
return _filtered.sublist(start, end);
|
|
}
|
|
|
|
String _cell(Map<String, dynamic> row, List<String> keys) {
|
|
for (final k in keys) {
|
|
final v = row[k];
|
|
if (v != null && v.toString().trim().isNotEmpty) {
|
|
return v.toString();
|
|
}
|
|
}
|
|
return '-';
|
|
}
|
|
|
|
String? _firstNonEmptyRaw(Map<String, dynamic> row, List<String> keys) {
|
|
for (final k in keys) {
|
|
final v = row[k];
|
|
if (v != null && v.toString().trim().isNotEmpty) {
|
|
return v.toString();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// API sometimes returns payout + UTR in one string (e.g. `5000.00UTR20260401164749`).
|
|
/// Normalizes into separate display values for PAYOUT and UTR NO columns.
|
|
({String payout, String utr}) _payoutAndUtr(Map<String, dynamic> row) {
|
|
const utrKeys = ['utr_no', 'utr', 'utr_number'];
|
|
const payoutKeys = ['commission_amount', 'payout', 'commission'];
|
|
|
|
String utrStr = (_firstNonEmptyRaw(row, utrKeys) ?? '').trim();
|
|
String payoutRaw = (_firstNonEmptyRaw(row, payoutKeys) ?? '').trim();
|
|
|
|
String stripTrailingPlaceholder(String s) =>
|
|
s.replaceAll(RegExp(r'[\s\-–—]+$'), '').trim();
|
|
|
|
if (payoutRaw.isNotEmpty) {
|
|
final upper = payoutRaw.toUpperCase();
|
|
final idx = upper.indexOf('UTR');
|
|
if (idx > 0) {
|
|
final gluedUtr = payoutRaw.substring(idx).trim();
|
|
payoutRaw = stripTrailingPlaceholder(payoutRaw.substring(0, idx));
|
|
if (utrStr.isEmpty || utrStr == '-') {
|
|
utrStr = gluedUtr;
|
|
}
|
|
} else {
|
|
payoutRaw = stripTrailingPlaceholder(payoutRaw);
|
|
}
|
|
}
|
|
|
|
if (payoutRaw.isEmpty) payoutRaw = '-';
|
|
if (utrStr.isEmpty) utrStr = '-';
|
|
|
|
return (payout: payoutRaw, utr: utrStr);
|
|
}
|
|
|
|
static num? _parseNum(dynamic v) {
|
|
if (v == null) return null;
|
|
if (v is num) return v;
|
|
return num.tryParse(v.toString());
|
|
}
|
|
|
|
String _fmtSummaryAmount(num? v) {
|
|
if (v == null) return '-';
|
|
return NumberFormat('#,##0.00').format(v);
|
|
}
|
|
|
|
Widget _buildSummaryChips() {
|
|
if (!_hasSearched || isLoading) return const SizedBox.shrink();
|
|
// API `data: []` — no rows; hide summary even if count/summary are zeros.
|
|
if (_rows.isEmpty) return const SizedBox.shrink();
|
|
final hasAny = _reportCount != null ||
|
|
_summaryTotal != null ||
|
|
_summaryRaised != null ||
|
|
_summaryPending != null;
|
|
if (!hasAny) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Wrap(
|
|
spacing: 10,
|
|
runSpacing: 8,
|
|
children: [
|
|
_summaryChip(
|
|
label: 'Count',
|
|
value: _reportCount != null ? '$_reportCount' : '-',
|
|
bg: const Color(0xFFE8F5E9),
|
|
border: const Color(0xFFBBDCB5),
|
|
labelColor: const Color(0xFF64748B),
|
|
valueColor: const Color(0xFF2E7D32),
|
|
),
|
|
_summaryChip(
|
|
label: 'Total',
|
|
value: _fmtSummaryAmount(_summaryTotal),
|
|
bg: const Color(0xFFECEFF1),
|
|
border: const Color(0xFFB0BEC5),
|
|
labelColor: const Color(0xFF64748B),
|
|
valueColor: const Color(0xFF37474F),
|
|
),
|
|
_summaryChip(
|
|
label: 'Paid',
|
|
value: _fmtSummaryAmount(_summaryRaised),
|
|
bg: const Color(0xFFE0F2F1),
|
|
border: const Color(0xFF80CBC4),
|
|
labelColor: const Color(0xFF64748B),
|
|
valueColor: const Color(0xFF00695C),
|
|
),
|
|
_summaryChip(
|
|
label: 'Pending',
|
|
value: _fmtSummaryAmount(_summaryPending),
|
|
bg: _pendingChipBg,
|
|
border: _pendingChipBorder,
|
|
labelColor: const Color(0xFF64748B),
|
|
valueColor: _pendingChipValue,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _summaryChip({
|
|
required String label,
|
|
required String value,
|
|
required Color bg,
|
|
required Color border,
|
|
required Color labelColor,
|
|
required Color valueColor,
|
|
}) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: border, width: 1),
|
|
),
|
|
child: RichText(
|
|
text: TextSpan(
|
|
style: GoogleFonts.inter(fontSize: 12, color: labelColor),
|
|
children: [
|
|
TextSpan(text: '$label: '),
|
|
TextSpan(
|
|
text: value,
|
|
style: GoogleFonts.inter(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: valueColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onExport() async {
|
|
if (!_validateFiltersForSearch()) return;
|
|
try {
|
|
await apiService.downloadCommissionPayoutReportExport(
|
|
fromDate: _dateToIso(_startController.text),
|
|
toDate: _dateToIso(_endController.text),
|
|
agentId: _agentIdForApi(),
|
|
payoutRaised: _payoutRaised,
|
|
);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ToastHelper.showErrorToast(context, 'Export failed: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _filterControlsRow(BuildContext context) {
|
|
final spacing = 5.0;
|
|
final isMobile = ResponsiveLayout.isMobile(context);
|
|
final role = (ref.watch(userRoleProvider) ?? '').toString().trim().toLowerCase();
|
|
final showAgentFilter = role != 'agent' && role != 'a';
|
|
|
|
final fields = Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DateRangePickerField(
|
|
startController: _startController,
|
|
endController: _endController,
|
|
hintText: 'Select date range',
|
|
showLabelAboveField: false,
|
|
txtwidth: isMobile
|
|
? null
|
|
: MediaQuery.of(context).size.width * 0.15,
|
|
lastDate: DateTime.now(),
|
|
useFormField: false,
|
|
),
|
|
if (showAgentFilter) ...[
|
|
SizedBox(width: spacing),
|
|
_buildAgentDropdownSearch(
|
|
isMobile ? 168.0 : MediaQuery.of(context).size.width * 0.13,
|
|
),
|
|
],
|
|
SizedBox(width: spacing),
|
|
_buildPayoutRaisedDropdownSearch(
|
|
isMobile ? 168.0 : MediaQuery.of(context).size.width * 0.13,
|
|
),
|
|
SizedBox(width: spacing),
|
|
Tooltip(
|
|
message: 'Search',
|
|
child: IconButton(
|
|
icon: const Icon(Icons.search_rounded, size: 18, color: Color(0xFF94A3B8)),
|
|
onPressed: _loadReport,
|
|
),
|
|
),
|
|
Tooltip(
|
|
message: 'Refresh',
|
|
child: IconButton(
|
|
icon: const Icon(Icons.refresh, size: 18, color: Color(0xFF94A3B8)),
|
|
onPressed: () {
|
|
_startController.clear();
|
|
_endController.clear();
|
|
_searchController.clear();
|
|
setState(() {
|
|
_selectedAgentId = null;
|
|
_payoutRaised = null;
|
|
_rows = [];
|
|
_filtered = [];
|
|
_hasSearched = false;
|
|
_reportCount = null;
|
|
_summaryTotal = null;
|
|
_summaryRaised = null;
|
|
_summaryPending = null;
|
|
currentPage = 1;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(8.0),
|
|
color: isMobile ? _filterStripMobileBg : null,
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: fields,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchAndActions(BuildContext context) {
|
|
final isMobile = ResponsiveLayout.isMobile(context);
|
|
final searchField = ThemedSearchField(
|
|
hintText: 'Search',
|
|
onChanged: _filterData,
|
|
controller: _searchController,
|
|
backgroundColor: const Color(0xFFFFFFFF),
|
|
txtHeight: 30,
|
|
txtwidth: isMobile
|
|
? MediaQuery.of(context).size.width * 0.7
|
|
: MediaQuery.of(context).size.width * 0.15,
|
|
);
|
|
final actions = [
|
|
Tooltip(
|
|
message: 'Export Excel',
|
|
child: InkWell(
|
|
onTap: _onExport,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(7.0),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2E7D6E),
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
child: Image.asset(
|
|
'assets/miscellaneous/export.png',
|
|
height: 13,
|
|
width: 13,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
];
|
|
// Desktop: no Spacer — this Row sits after Expanded(filter); Row+Spacer has
|
|
// unbounded intrinsic width and triggers RenderFlex layout failure.
|
|
if (!isMobile) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
searchField,
|
|
const SizedBox(width: 10),
|
|
...actions,
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
searchField,
|
|
const Spacer(),
|
|
...actions,
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterBar(BuildContext context) {
|
|
final isMobile = ResponsiveLayout.isMobile(context);
|
|
if (isMobile) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_filterControlsRow(context),
|
|
const SizedBox(height: 8),
|
|
_buildSearchAndActions(context),
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _filterControlsRow(context)),
|
|
_buildSearchAndActions(context),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tableHeader() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: _tableHeaderBg,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(flex: 1, child: Text('S NO', style: _headerColumn)),
|
|
Expanded(flex: 2, child: Text('POLICY NO', style: _headerColumn)),
|
|
Expanded(flex: 2, child: Text('REG NO', style: _headerColumn)),
|
|
Expanded(flex: 1, child: Text('WEIGHT', style: _headerColumn)),
|
|
Expanded(flex: 2, child: Text('FUEL TYPE', style: _headerColumn)),
|
|
Expanded(flex: 2, child: Text('VEHICLE TYPE', style: _headerColumn)),
|
|
Expanded(flex: 2, child: Text('UTR NO', style: _headerColumn)),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text('PREMIUM', style: _headerColumn, textAlign: TextAlign.right),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text('PAYOUT', style: _headerColumn, textAlign: TextAlign.right),
|
|
),
|
|
const SizedBox(width: 15),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
_isAccountsReceivedRole() ? 'PAID' : 'RECEIVED',
|
|
style: _headerColumn,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _dataRow(Map<String, dynamic> item, int sno) {
|
|
final highlightNo = _receivedIsNo(item);
|
|
// Fresh style (not copyWith on _dataBold): GoogleFonts may set `foreground`,
|
|
// which overrides `color` and can make text invisible on web.
|
|
final rowStyle = highlightNo
|
|
? GoogleFonts.inter(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w400,
|
|
color: const Color(0xFF0F172A),
|
|
)
|
|
: _dataBold;
|
|
|
|
final pu = _payoutAndUtr(item);
|
|
|
|
final row = Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(flex: 1, child: Text('$sno', style: rowStyle)),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
_cell(item, ['policy_no']),
|
|
style: rowStyle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
_cell(item, ['reg_no']),
|
|
style: rowStyle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: Text(
|
|
_cell(item, ['weight']),
|
|
style: rowStyle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
_cell(item, ['fuel_type']),
|
|
style: rowStyle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
_cell(item, ['vehicle_type']),
|
|
style: rowStyle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
pu.utr,
|
|
style: rowStyle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
_cell(item, ['premium']),
|
|
style: rowStyle,
|
|
textAlign: TextAlign.right,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
pu.payout,
|
|
style: rowStyle,
|
|
textAlign: TextAlign.right,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 15),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
_receivedDisplayLabel(item['received_or_not']),
|
|
style: rowStyle,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
final padded = Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
|
child: row,
|
|
);
|
|
|
|
if (highlightNo) {
|
|
// Background in a separate layer so ListView/Container decoration never clips text (web).
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned.fill(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: _pendingChipBg,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border(
|
|
bottom: const BorderSide(color: _rowBorder, width: 1),
|
|
left: const BorderSide(color: _pendingChipBorder, width: 3),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
padded,
|
|
],
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
clipBehavior: Clip.none,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: const Border(
|
|
bottom: BorderSide(color: _rowBorder, width: 1),
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: padded,
|
|
);
|
|
}
|
|
|
|
Widget _buildDataTable(BuildContext context) {
|
|
if (!_hasSearched) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
() {
|
|
final r =
|
|
(ref.watch(userRoleProvider) ?? '').toString().trim().toLowerCase();
|
|
final hideAgent = r == 'agent' || r == 'a';
|
|
return hideAgent
|
|
? 'Choose filters (date range and/or payout raised) and tap Search to load the report.'
|
|
: 'Choose filters (date range, agent, and/or payout raised) and tap Search to load the report.';
|
|
}(),
|
|
textAlign: TextAlign.center,
|
|
style: GoogleFonts.inter(
|
|
fontSize: 13,
|
|
color: const Color(0xFF64748B),
|
|
height: 1.4,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
final pageItems = _paginated;
|
|
final startIndex = _filtered.isEmpty ? 0 : (currentPage - 1) * itemsPerPage;
|
|
|
|
if (pageItems.isEmpty) {
|
|
return const SizedBox(
|
|
height: 50,
|
|
child: Center(child: Text('No available data')),
|
|
);
|
|
}
|
|
|
|
final isMobile = ResponsiveLayout.isMobile(context);
|
|
final tableWidth =
|
|
MediaQuery.of(context).size.width < 1500 ? 1500.0 : MediaQuery.of(context).size.width;
|
|
|
|
if (isMobile) {
|
|
return Scrollbar(
|
|
thumbVisibility: true,
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: SizedBox(
|
|
width: tableWidth,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_tableHeader(),
|
|
ListView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: pageItems.length,
|
|
itemBuilder: (context, i) {
|
|
return _dataRow(pageItems[i], startIndex + i + 1);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// One ListView inside a fixed-height box (no Column+Expanded) avoids relayout
|
|
// conflicts with horizontal SingleChildScrollView on web.
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final w = constraints.maxWidth < 1500 ? 1500.0 : constraints.maxWidth;
|
|
var h = constraints.maxHeight;
|
|
if (!h.isFinite || h <= 0) {
|
|
h = MediaQuery.sizeOf(context).height * 0.45;
|
|
}
|
|
return Scrollbar(
|
|
thumbVisibility: true,
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: SizedBox(
|
|
width: w,
|
|
height: h,
|
|
child: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
_tableHeader(),
|
|
...pageItems.asMap().entries.map((e) {
|
|
final i = e.key;
|
|
return _dataRow(e.value, startIndex + i + 1);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Filters + table only (title row lives in [build] like [policylist]).
|
|
Widget _buildContent(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildFilterBar(context),
|
|
_buildSummaryChips(),
|
|
const SizedBox(height: 5),
|
|
ResponsiveLayout.isMobile(context)
|
|
? _buildDataTable(context)
|
|
: Expanded(child: _buildDataTable(context)),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isMobile = ResponsiveLayout.isMobile(context);
|
|
return MainLayout(
|
|
title: 'Payout Report',
|
|
// Single Column under MainLayout's Expanded — do not nest LayoutBuilder here;
|
|
// nested LayoutBuilder + Expanded caused _debugRelayoutBoundaryAlreadyMarkedNeedsLayout on web.
|
|
body: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
height: 30,
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
'Payout Report',
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: isMobile
|
|
? SingleChildScrollView(
|
|
child: _buildContent(context),
|
|
)
|
|
: _buildContent(context),
|
|
),
|
|
),
|
|
PaginationControls(
|
|
currentPage: currentPage,
|
|
itemsPerPage: itemsPerPage,
|
|
totalItems: _filtered.length,
|
|
onPageChanged: (page) {
|
|
setState(() => currentPage = page);
|
|
},
|
|
onItemsPerPageChanged: (items) {
|
|
setState(() {
|
|
itemsPerPage = items;
|
|
currentPage = 1;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|