nhance_partner/lib/presentation/screens/payout/payout_report.dart
2026-05-07 16:48:03 +05:30

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;
});
},
),
],
),
);
}
}