fix_payout

This commit is contained in:
sanjeev.p 2026-04-04 09:14:15 +05:30
parent e65290cc7b
commit 1988ebeca7
3 changed files with 576 additions and 892 deletions

View File

@ -1360,6 +1360,8 @@ class ApiService {
if (_token == null) await _initializeToken();
final url = Uri.parse('${Env.apiUrl}partner/$id/details');
// final url = Uri.parse('http://localhost/nhance_partner_be/partner/$id/details');
final headers = {
'Authorization': 'Bearer $_token',
'app-signature': Env.App_Signature,
@ -1374,6 +1376,7 @@ class ApiService {
if (_token == null) await _initializeToken();
final url = Uri.parse('${Env.apiUrl}partner/$id/policies');
// final url = Uri.parse('http://localhost/nhance_partner_be/partner/$id/details/policies');
final headers = {
'Authorization': 'Bearer $_token',
@ -1393,6 +1396,7 @@ class ApiService {
if (_token == null) await _initializeToken();
final url = Uri.parse('${Env.apiUrl}partner/$id/renewals?days=$days');
// final url = Uri.parse('http://localhost/nhance_partner_be/partner/$id/renewals?days=$days');
final headers = {
'Authorization': 'Bearer $_token',
'app-signature': Env.App_Signature,
@ -1401,13 +1405,19 @@ class ApiService {
return response;
}
/// GET /partner/{id}/earnings
/// Returns: list of monthly earning records with
/// month_key, month_label, premium, policies, paid/status
Future<Map<String, dynamic>> getPartnerEarnings(dynamic id) async {
/// GET /partner/{id}/earnings?financial_year=2025-2026
/// Monthly rows for that Indian FY: month_key, month_label, premium, policies, payout.
Future<Map<String, dynamic>> getPartnerEarnings(
dynamic id, {
String? financialYear,
}) async {
if (_token == null) await _initializeToken();
final url = Uri.parse('${Env.apiUrl}partner/$id/earnings');
final q = (financialYear != null && financialYear.isNotEmpty)
? '?financial_year=${Uri.encodeQueryComponent(financialYear)}'
: '';
final url = Uri.parse('${Env.apiUrl}partner/$id/earnings$q');
// final url = Uri.parse('http://localhost/nhance_partner_be/partner/$id/earnings$q');
final headers = {
'Authorization': 'Bearer $_token',
'app-signature': Env.App_Signature,
@ -2078,6 +2088,7 @@ class ApiService {
Future<Map<String, dynamic>> getCreateOrUpdate(data) async {
final url = Uri.parse('${Env.apiUrl}invoice/create-or-update');
// final url = Uri.parse('http://localhost/nhance_partner_be/invoice/create-or-update');
// final token = await getToken(); // Fetch token

View File

@ -43,8 +43,7 @@ class _State extends ConsumerState<PartnerPortalDashboard> {
List<Map<String, dynamic>> allPolicies = [];
List<Map<String, dynamic>> allRenewals = [];
List<Map<String, dynamic>> filteredRenewals = [];
int selectedRenewalDays = 10, renewalPage = 0;
static const kPerPage = 4;
int selectedRenewalDays = 10;
List<Map<String, dynamic>> allEarnings = [];
String selectedFY = _currentFinYear();
@ -96,57 +95,48 @@ class _State extends ConsumerState<PartnerPortalDashboard> {
selectedRenewalDays = days;
allRenewals = raw;
filteredRenewals = raw;
renewalPage = 0;
});
}
}
Future<void> _fetchEarnings(dynamic id) async {
final r = await api.getPartnerEarnings(id);
if (r['status'] == 'success') setState(() => allEarnings = List<Map<String, dynamic>>.from((r['data'] ?? []).map((e) => Map<String, dynamic>.from(e))));
Future<void> _fetchEarnings(dynamic id, {String? financialYear}) async {
final fy = financialYear ?? selectedFY;
final r = await api.getPartnerEarnings(id, financialYear: fy);
if (r['status'] == 'success') {
setState(() {
allEarnings = List<Map<String, dynamic>>.from(
(r['data'] ?? []).map((e) => Map<String, dynamic>.from(e)),
);
});
}
}
void _applyRenewalFilter(int days) {
setState(() {
selectedRenewalDays = days;
filteredRenewals = allRenewals.where((r) => (r['days_left'] ?? 999) <= days).toList();
renewalPage = 0;
});
}
List<Map<String, dynamic>> get _fyEarnings {
final s = _fyStart(selectedFY), e = _fyEnd(selectedFY);
return allEarnings.where((row) {
try {
final raw = row['month_key']?.toString() ?? '';
final dt = raw.length == 7 ? DateTime.parse('$raw-01') : DateTime.tryParse(raw);
if (dt == null) return true;
return !dt.isBefore(s) && !dt.isAfter(e);
} catch (_) { return true; }
}).toList();
}
/// Backend returns months for the requested [financial_year] only.
Map<String, dynamic> get _fyAgg {
double pre = 0, com = 0, tds = 0, net = 0; int pol = 0; bool allPaid = true;
for (final e in _fyEarnings) {
pre += _d(e['premium']); com += _d(e['commission']);
tds += _d(e['tds']); net += _d(e['net_payout']);
pol += int.tryParse(e['policies']?.toString() ?? '0') ?? 0;
if ((e['paid']?.toString()) != '1' && e['paid'] != true) allPaid = false;
double premium = 0;
double payout = 0;
int policies = 0;
for (final e in allEarnings) {
premium += _d(e['premium']);
policies += int.tryParse(e['policies']?.toString() ?? '0') ?? 0;
payout += _d(e['payout']);
}
if (com == 0 && pre > 0) { com = pre * 0.15; tds = com * 0.10; net = com - tds; }
return { 'premium': pre, 'commission': com, 'tds': tds, 'netPayout': net, 'policies': pol, 'paid': allPaid && _fyEarnings.isNotEmpty };
return {
'premium': premium,
'policies': policies,
'payout': payout,
};
}
double _d(dynamic v) => double.tryParse(v?.toString() ?? '0') ?? 0;
List<Map<String, dynamic>> get _pageRenewals {
final s = renewalPage * kPerPage;
final e = (s + kPerPage).clamp(0, filteredRenewals.length);
return s >= filteredRenewals.length ? [] : filteredRenewals.sublist(s, e);
}
int get _totalPages => (filteredRenewals.length / kPerPage).ceil();
String _inr(dynamic v) {
final n = _d(v);
if (n >= 10000000) return 'Rs.${(n/10000000).toStringAsFixed(1)}Cr';
@ -180,14 +170,12 @@ class _State extends ConsumerState<PartnerPortalDashboard> {
_PartnerProfileCard(details: partnerDetails, initials: _initials(partnerDetails['agent_name']?.toString() ?? 'PD')),
const SizedBox(height: 14),
_label('Overview'),
_OverviewBar(details: partnerDetails, inr: _inr),
_OverviewBar(details: partnerDetails, inr: _inr, overviewFinYear: partnerDetails['overview_financial_year']?.toString()),
const SizedBox(height: 14),
// _label('Policy List & Renewal Alerts'),
_PolicyRenewalRow(
policies: allPolicies,
currentPageRenewals: _pageRenewals, totalRenewalPages: _totalPages,
renewalPage: renewalPage, selectedRenewalDays: selectedRenewalDays,
filteredRenewalsCount: filteredRenewals.length,
renewals: filteredRenewals,
selectedRenewalDays: selectedRenewalDays,
onRenewalDays: (d) {
final id = _agentId ?? ref.read(userIdProvider);
if (id != null) {
@ -196,15 +184,20 @@ class _State extends ConsumerState<PartnerPortalDashboard> {
_applyRenewalFilter(d);
}
},
onRenewalPageChanged: (p) => setState(() => renewalPage = p),
inr: _inr,
),
const SizedBox(height: 14),
_label('Earning Details'),
_EarningDetailsSection(
allFinYears: _allFinYears(), selectedFinYear: selectedFY,
onFinYearChanged: (fy) => setState(() => selectedFY = fy),
aggregate: _fyAgg, earningsForFY: _fyEarnings, inr: _inr,
allFinYears: _allFinYears(),
selectedFinYear: selectedFY,
onFinYearChanged: (fy) {
final id = _agentId ?? ref.read(userIdProvider);
setState(() => selectedFY = fy);
if (id != null) _fetchEarnings(id, financialYear: fy);
},
aggregate: _fyAgg,
inr: _inr,
),
const SizedBox(height: 20),
]),
@ -272,24 +265,24 @@ class _PartnerProfileCard extends StatelessWidget {
}
//
// OVERVIEW BAR
// mapped_policies / active_policies COUNT from partner_policy WHERE agent_id=$id
// total_premium / commission_earned SUM from partner_policy
// enquiry_* COUNT from partner_enquiry WHERE agent_id=$id, is_active=1
// endorsement_* COUNT from partner_endorsement_request WHERE agent_id=$id, is_active=1
// Clients removed. Mapped Policies shows Active dot only.
// OVERVIEW BAR metrics scoped to current Indian FY (from API overview_financial_year)
//
class _OverviewBar extends StatelessWidget {
final Map<String, dynamic> details; final String Function(dynamic) inr;
const _OverviewBar({required this.details, required this.inr});
final Map<String, dynamic> details;
final String Function(dynamic) inr;
/// Same format as [selectedFY], e.g. "2026-2027"; drives FY label under premium.
final String? overviewFinYear;
const _OverviewBar({required this.details, required this.inr, this.overviewFinYear});
@override
Widget build(BuildContext context) {
final fyTag = _fyShort(overviewFinYear ?? _currentFinYear());
final mp = details['mapped_policies'] ?? '0';
final ip = details['issued_policies'] ?? '0'; // policy_number IS NOT NULL
final pp2 = details['pending_policies'] ?? '0'; // policy_number IS NULL
final tp = details['total_premium'] ?? '0';
final com = details['commission_earned'] ?? '0';
final cr = details['commission_rate'] ?? '15';
final cpd = details['commission_paid'] ?? '0';
final cup = details['commission_unpaid'] ?? '0';
final eq = details['enquiry_total'] ?? '0';
final eqd = details['enquiry_completed'] ?? '0';
final eqp = details['enquiry_pending'] ?? '0';
@ -303,16 +296,22 @@ class _OverviewBar extends StatelessWidget {
child: LayoutBuilder(builder: (ctx, box) {
final wide = box.maxWidth > 700;
if (wide) return IntrinsicHeight(child: Row(children: [
_sc('Mapped Policies', mp.toString(), const Color(0xFF0ABFA3), [_dot(const Color(0xFF10B981), '$ip Issued'), _dot(const Color(0xFFF97316), '$pp2 Pending')], false),
_sc('Total Premium', inr(tp), const Color(0xFF10B981), [_dot(Colors.grey, 'FY ${_currentFinYear()}')]),
_sc('Delegation', inr(com), const Color(0xFFF97316), [_dot(Colors.grey, '$cr% rate')]),
_sc('Policies', mp.toString(), const Color(0xFF0ABFA3), [_dot(const Color(0xFF10B981), '$ip Issued'), _dot(const Color(0xFFF97316), '$pp2 Pending')], false),
_sc('Total Premium', inr(tp), const Color(0xFF10B981), [_dot(Colors.grey, 'FY $fyTag')]),
_sc('Total Payout', inr(com), const Color(0xFFF97316), [
_dot(const Color(0xFF006B5C), 'Paid ${inr(cpd)}'),
_dot(const Color(0xFF64748B), 'Unpaid ${inr(cup)}'),
]),
_fc('Enquiry', eqd.toString(), eq.toString(), const Color(0xFF3B82F6), [_dot(const Color(0xFF10B981), '$eqd Done'), _dot(const Color(0xFFF97316), '$eqp Pending')]),
_fc('Endorsement', end.toString(), en.toString(), const Color(0xFF8B5CF6), [_dot(const Color(0xFF10B981), '$end Done'), _dot(const Color(0xFFF97316), '$enp Pending')]),
]));
return Wrap(children: [
_wc('Mapped Policies', mp.toString(), const Color(0xFF0ABFA3), [_dot(const Color(0xFF10B981), '$ip Issued'), _dot(const Color(0xFFF97316), '$pp2 Pending')]),
_wc('Total Premium', inr(tp), const Color(0xFF10B981), [_dot(Colors.grey, 'FY ${_currentFinYear()}')]),
_wc('Delegation', inr(com), const Color(0xFFF97316), [_dot(Colors.grey, '$cr% rate')]),
_wc('Policies', mp.toString(), const Color(0xFF0ABFA3), [_dot(const Color(0xFF10B981), '$ip Issued'), _dot(const Color(0xFFF97316), '$pp2 Pending')]),
_wc('Total Premium', inr(tp), const Color(0xFF10B981), [_dot(Colors.grey, 'FY $fyTag')]),
_wc('Total Payout', inr(com), const Color(0xFFF97316), [
_dot(const Color(0xFF006B5C), 'Paid ${inr(cpd)}'),
_dot(const Color(0xFF64748B), 'Unpaid ${inr(cup)}'),
]),
_wc('Enquiry', '$eqd / $eq', const Color(0xFF3B82F6), [_dot(const Color(0xFF10B981), '$eqd Done')]),
_wc('Endorsement', '$end / $en', const Color(0xFF8B5CF6), [_dot(const Color(0xFF10B981), '$end Done')]),
]);
@ -340,23 +339,22 @@ class _OverviewBar extends StatelessWidget {
// POLICY LIST + RENEWAL ALERTS ROW
//
class _PolicyRenewalRow extends StatelessWidget {
final List<Map<String, dynamic>> policies, currentPageRenewals;
final int totalRenewalPages, renewalPage, selectedRenewalDays, filteredRenewalsCount;
final ValueChanged<int> onRenewalDays, onRenewalPageChanged;
final List<Map<String, dynamic>> renewals;
final int selectedRenewalDays;
final ValueChanged<int> onRenewalDays;
final String Function(dynamic) inr;
const _PolicyRenewalRow({required this.policies, required this.currentPageRenewals,
required this.totalRenewalPages, required this.renewalPage,
required this.selectedRenewalDays, required this.filteredRenewalsCount,
required this.onRenewalDays, required this.onRenewalPageChanged, required this.inr});
const _PolicyRenewalRow({
required this.renewals,
required this.selectedRenewalDays,
required this.onRenewalDays,
required this.inr,
});
@override
Widget build(BuildContext context) => _RenewalAlertsCard(
currentPageRenewals: currentPageRenewals,
totalPages: totalRenewalPages,
currentPage: renewalPage,
renewals: renewals,
selectedDays: selectedRenewalDays,
totalCount: filteredRenewalsCount,
totalCount: renewals.length,
onDays: onRenewalDays,
onPageChanged: onRenewalPageChanged,
inr: inr,
);
}
@ -406,18 +404,54 @@ class _PolicyListCard extends StatelessWidget {
}
class _RenewalAlertsCard extends StatelessWidget {
final List<Map<String, dynamic>> currentPageRenewals;
final int totalPages, currentPage, selectedDays, totalCount;
final ValueChanged<int> onDays, onPageChanged;
static const int kVisibleRows = 10;
static const double kRowHeight = 44;
final List<Map<String, dynamic>> renewals;
final int selectedDays, totalCount;
final ValueChanged<int> onDays;
final String Function(dynamic) inr;
const _RenewalAlertsCard({required this.currentPageRenewals, required this.totalPages, required this.currentPage, required this.selectedDays, required this.totalCount, required this.onDays, required this.onPageChanged, required this.inr});
const _RenewalAlertsCard({
required this.renewals,
required this.selectedDays,
required this.totalCount,
required this.onDays,
required this.inr,
});
@override
Widget build(BuildContext context) => _DashCard(
header: Row(children: [
Container(width:30,height:30,decoration:BoxDecoration(color:const Color(0xFFFFEDED),borderRadius:BorderRadius.circular(8)),child:const Icon(Icons.notifications_active_outlined,size:16,color:Color(0xFFEF4444))),
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: const Color(0xFFFFEDED),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.notifications_active_outlined, size: 16, color: Color(0xFFEF4444)),
),
const SizedBox(width: 8),
Column(crossAxisAlignment:CrossAxisAlignment.start, children:[Text('Renewal Alerts',style:_chartHeader), Text('At-risk policies',style:GoogleFonts.inter(fontSize:9.5,color:const Color(0xFF8AABB5)))]),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Renewal Alerts', style: _chartHeader),
Text(
'At-risk policies',
style: GoogleFonts.inter(fontSize: 9.5, color: const Color(0xFF8AABB5)),
),
],
),
const Spacer(),
if (totalCount > 0)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
'$totalCount total',
style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF8AABB5)),
),
),
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
@ -459,258 +493,292 @@ class _RenewalAlertsCard extends StatelessWidget {
]),
body: totalCount == 0
? const _EmptyState(message: 'No renewals due within selected period')
: Padding(
padding: const EdgeInsets.all(10),
child: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final crossAxisCount = w >= 1100 ? 4 : w >= 850 ? 3 : w >= 560 ? 2 : 1;
return Column(children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
mainAxisExtent: 90,
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
color: const Color(0xFFE3F9F8),
child: Row(
children: [
SizedBox(width: 88, child: Text('Due', style: _tableHeadStyle)),
Expanded(flex: 2, child: Text('Policy No.', style: _tableHeadStyle)),
Expanded(flex: 2, child: Text('Holder', style: _tableHeadStyle)),
Expanded(flex: 2, child: Text('Expires', style: _tableHeadStyle)),
Expanded(
flex: 2,
child: Text('Premium', textAlign: TextAlign.right, style: _tableHeadStyle),
),
itemCount: currentPageRenewals.length,
itemBuilder: (_, i) => _RenewalAlertCard(renewal: currentPageRenewals[i], inr: inr),
),
if (totalPages > 1) ...[
const SizedBox(height: 10),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
Text('${currentPage+1} / $totalPages', style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF8AABB5))),
const SizedBox(width: 8),
_PA(icon: Icons.chevron_left, enabled: currentPage > 0, onTap: () => onPageChanged(currentPage - 1)),
const SizedBox(width: 4),
_PA(icon: Icons.chevron_right, enabled: currentPage < totalPages - 1, onTap: () => onPageChanged(currentPage + 1)),
]),
],
]);
},
),
),
SizedBox(
height: kVisibleRows * kRowHeight,
child: ListView.separated(
physics: const ClampingScrollPhysics(),
itemCount: renewals.length,
separatorBuilder: (_, __) => const Divider(height: 1, color: Color(0xFFD9F0ED)),
itemBuilder: (_, i) => _RenewalTableRow(renewal: renewals[i], inr: inr),
),
),
],
),
);
}
// renewal card holder_name=insured_name, premium=premium_amount (both aliased in API)
class _RenewalAlertCard extends StatelessWidget {
final Map<String, dynamic> renewal; final String Function(dynamic) inr;
const _RenewalAlertCard({required this.renewal, required this.inr});
class _RenewalTableRow extends StatelessWidget {
final Map<String, dynamic> renewal;
final String Function(dynamic) inr;
const _RenewalTableRow({required this.renewal, required this.inr});
@override
Widget build(BuildContext context) {
final d = int.tryParse(renewal['days_left']?.toString() ?? '0') ?? 0;
Color dc, bb, bf;
if (d<=10){dc=const Color(0xFFEF4444);bb=const Color(0xFFFFEDED);bf=const Color(0xFFEF4444);}
else if(d<=20){dc=const Color(0xFFF97316);bb=const Color(0xFFFEF0E7);bf=const Color(0xFFF97316);}
else{dc=const Color(0xFF94A3B8);bb=const Color(0xFFF1F5F9);bf=const Color(0xFF64748B);}
return Container(
padding:const EdgeInsets.symmetric(horizontal:10,vertical:7),
decoration:BoxDecoration(color:Colors.white,border:Border.all(color:const Color(0xFFE2E8F0)),borderRadius:BorderRadius.circular(10),
boxShadow:const[BoxShadow(color:Color(0x06000000),blurRadius:4,offset:Offset(0,2))]),
child:Column(mainAxisSize: MainAxisSize.min,crossAxisAlignment:CrossAxisAlignment.start,children:[
Row(children:[Container(width:8,height:8,decoration:BoxDecoration(shape:BoxShape.circle,color:dc)),const Spacer(),
Container(padding:const EdgeInsets.symmetric(horizontal:7,vertical:2),decoration:BoxDecoration(color:bb,borderRadius:BorderRadius.circular(4)),
child:Text('DUE IN $d DAYS',style:GoogleFonts.inter(fontSize:9,fontWeight:FontWeight.w700,color:bf,letterSpacing:.3)))]),
const SizedBox(height:4),
Text(renewal['holder_name']?.toString()??'--',style:GoogleFonts.inter(fontSize:11,fontWeight:FontWeight.w600,color:const Color(0xFF0F2D3D)),maxLines:1,overflow:TextOverflow.ellipsis),
const SizedBox(height:1),
Row(mainAxisAlignment:MainAxisAlignment.spaceBetween,children:[
Flexible(child:Text('Policy: ${renewal['policy_no']?.toString()??'--'}',style:GoogleFonts.inter(fontSize:9.5,color:const Color(0xFF8AABB5)),overflow:TextOverflow.ellipsis)),
Text(inr(renewal['premium']??0),style:GoogleFonts.inter(fontSize:12,fontWeight:FontWeight.w700,color:const Color(0xFF0F2D3D))),
]),
]));
}
Color chipBg, chipFg;
if (d <= 10) {
chipBg = const Color(0xFFFFEDED);
chipFg = const Color(0xFFEF4444);
} else if (d <= 20) {
chipBg = const Color(0xFFFEF0E7);
chipFg = const Color(0xFFF97316);
} else {
chipBg = const Color(0xFFF1F5F9);
chipFg = const Color(0xFF64748B);
}
typedef _PA = _PaginationArrow;
class _PaginationArrow extends StatelessWidget {
final IconData icon; final bool enabled; final VoidCallback onTap;
const _PaginationArrow({required this.icon, required this.enabled, required this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(onTap:enabled?onTap:null,child:Container(width:28,height:28,
decoration:BoxDecoration(color:enabled?const Color(0xFF0F2D3D):const Color(0xFFF1F5F9),borderRadius:BorderRadius.circular(6)),
child:Icon(icon,size:18,color:enabled?Colors.white:const Color(0xFFCBD5E1))));
return SizedBox(
height: _RenewalAlertsCard.kRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
SizedBox(
width: 88,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: chipBg,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$d d',
style: GoogleFonts.inter(
fontSize: 9.5,
fontWeight: FontWeight.w700,
color: chipFg,
letterSpacing: 0.2,
),
),
),
),
),
Expanded(
flex: 2,
child: Text(
renewal['policy_no']?.toString() ?? '--',
style: _tableTeal,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 2,
child: Text(
renewal['holder_name']?.toString() ?? '--',
style: _tableData,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 2,
child: Text(
renewal['end_date']?.toString() ?? '--',
style: _tableData,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 2,
child: Text(
inr(renewal['premium'] ?? 0),
style: _tableDataBold,
textAlign: TextAlign.right,
),
),
],
),
),
);
}
}
//
// EARNING DETAILS SECTION
// EARNING DETAILS FY header + stat cards (Premium, Policies, Total commission)
// Data from GET partner/{id}/earnings, aggregated per selected financial year.
//
class _EarningDetailsSection extends StatelessWidget {
final List<String> allFinYears;
final String selectedFinYear;
final ValueChanged<String> onFinYearChanged;
final Map<String, dynamic> aggregate;
final List<Map<String, dynamic>> earningsForFY;
final String Function(dynamic) inr;
const _EarningDetailsSection({required this.allFinYears, required this.selectedFinYear, required this.onFinYearChanged, required this.aggregate, required this.earningsForFY, required this.inr});
Widget _hero() {
final revenue = inr(aggregate['premium'] ?? 0);
final netPayout = inr(aggregate['netPayout'] ?? 0);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: BoxDecoration(color: Colors.white, border: Border.all(color: const Color(0xFFD9F0ED)), borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Color(0x08000000), blurRadius: 4, offset: Offset(0, 2))]),
child: LayoutBuilder(builder: (ctx, box) {
final wide = box.maxWidth > 500;
final left = Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Text('PERFORMANCE DASHBOARD', style: GoogleFonts.inter(fontSize: 9.5, fontWeight: FontWeight.w700, color: const Color(0xFF8AABB5), letterSpacing: 1.2)),
// const SizedBox(height: 6),
Text('Monthly Revenue', style: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w700, color: const Color(0xFF0F2D3D))),
const SizedBox(height: 14),
Row(crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [
Text(revenue, style: GoogleFonts.manrope(fontSize: 28, fontWeight: FontWeight.w700, color: const Color(0xFF000666))),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: const Color(0xFFE6FAF7), borderRadius: BorderRadius.circular(6)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.trending_up, size: 12, color: Color(0xFF0ABFA3)),
const SizedBox(width: 4),
Text('+12.4%', style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w700, color: const Color(0xFF0ABFA3))),
]),
),
]),
const SizedBox(height: 6),
Text('Accumulated earnings for ${_fyFull(selectedFinYear)}.', style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF8AABB5))),
]);
final payout = Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration(color: const Color(0xFFF8FFFE), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFD9F0ED))),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.savings_outlined, size: 28, color: Color(0xFF0ABFA3)),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('TOTAL PAYOUT TO DATE', style: GoogleFonts.inter(fontSize: 8.5, fontWeight: FontWeight.w700, color: const Color(0xFF8AABB5), letterSpacing: .8)),
const SizedBox(height: 4),
Text(netPayout, style: GoogleFonts.manrope(fontSize: 20, fontWeight: FontWeight.w700, color: const Color(0xFF000666))),
]),
]),
);
return wide
? Row(crossAxisAlignment: CrossAxisAlignment.center, children: [Expanded(child: left), const SizedBox(width: 20), payout])
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [left, const SizedBox(height: 16), payout]);
}),
);
}
const _EarningDetailsSection({
required this.allFinYears,
required this.selectedFinYear,
required this.onFinYearChanged,
required this.aggregate,
required this.inr,
});
Widget _fySection() {
@override
Widget build(BuildContext context) {
final agg = aggregate;
final stats = [
_SI('Financial Year', _fyFull(selectedFinYear), Icons.calendar_today_outlined, const Color(0xFF000666)),
_SI('Premium Collected', inr(agg['premium'] ?? 0), Icons.payments_outlined, const Color(0xFF006B5C)),
_SI('Policies', '${agg['policies'] ?? 0}', Icons.policy_outlined, const Color(0xFF1A237E)),
_SI('Delegation (15%)', inr(agg['commission'] ?? 0), Icons.percent_outlined, const Color(0xFF059669)),
_SI('TDS (10%)', inr(agg['tds'] ?? 0), Icons.account_balance_outlined, const Color(0xFFD97706)),
_SI('Net Payout', inr(agg['netPayout'] ?? 0), Icons.savings_outlined, const Color(0xFF059669)),
_EarningStatItem(
'Financial Year',
_fyFull(selectedFinYear),
Icons.calendar_today_outlined,
const Color(0xFF000666),
),
_EarningStatItem(
'Premium Collected',
inr(agg['premium'] ?? 0),
Icons.payments_outlined,
const Color(0xFF006B5C),
),
_EarningStatItem(
'Policies',
'${agg['policies'] ?? 0}',
Icons.policy_outlined,
const Color(0xFF1A237E),
),
_EarningStatItem(
'Total Payout',
inr(agg['payout'] ?? 0),
Icons.account_balance_wallet_outlined,
const Color(0xFFF97316),
),
];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, border: Border.all(color: const Color(0xFFD9F0ED)), borderRadius: BorderRadius.circular(12)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFD9F0ED)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.currency_rupee_rounded, size: 16, color: Color(0xFF006B5C)),
const SizedBox(width: 6),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Earning Details', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF0F2D3D))),
Text('${_fyFull(selectedFinYear)} · ${_fyRange(selectedFinYear)}', style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF8AABB5))),
]),
const Spacer(),
_FYTabBar(allFinYears: allFinYears, selected: selectedFinYear, onChanged: onFinYearChanged),
]),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Earning Details',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFF0F2D3D),
),
),
Text(
'${_fyFull(selectedFinYear)} · ${_fyRange(selectedFinYear)}',
style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF8AABB5)),
),
],
),
),
_FYTabBar(
allFinYears: allFinYears,
selected: selectedFinYear,
onChanged: onFinYearChanged,
),
],
),
const SizedBox(height: 14),
LayoutBuilder(builder: (ctx, box) {
final cols = box.maxWidth > 700 ? 6 : box.maxWidth > 450 ? 3 : 2;
final w = (box.maxWidth - (cols - 1) * 10) / cols;
return Wrap(spacing: 10, runSpacing: 10, children: stats.map((s) => SizedBox(width: w, child: _StatCard(item: s))).toList());
}),
]),
);
}
Widget _txnTable() => Container(
decoration: BoxDecoration(color: Colors.white, border: Border.all(color: const Color(0xFFD9F0ED)), borderRadius: BorderRadius.circular(10),
boxShadow: const [BoxShadow(color: Color(0x08000000), blurRadius: 4, offset: Offset(0, 2))]),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFD9F0ED)))),
child: Row(children: [
Text('Recent Transactions', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF0F2D3D))),
const SizedBox(width: 8), _CountBadge(earningsForFY.length), const Spacer(),
Text('Showing for ${_fyFull(selectedFinYear)}', style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF8AABB5))),
]),
),
Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), color: const Color(0xFFF2F4F6),
child: Row(children: ['Month','Premium','Policies','Delegation','Net Payout']
.map((h) => Expanded(flex: 2, child: Text(h, style: _tableHeadStyle))).toList()),
),
earningsForFY.isEmpty
? const SizedBox(
height: 180,
child: Center(
child: _EmptyState(
message: 'No transactions for this financial year',
),
),
)
: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: ListView.separated(
shrinkWrap: true, physics: const ClampingScrollPhysics(),
itemCount: earningsForFY.length,
separatorBuilder: (_, __) => const Divider(height: 1, color: Color(0xFFD9F0ED)),
itemBuilder: (_, i) {
final e = earningsForFY[i];
final label = e['month_label']?.toString() ?? e['month_key']?.toString() ?? '--';
double pre = double.tryParse(e['premium']?.toString() ?? '0') ?? 0;
double com = double.tryParse(e['commission']?.toString() ?? '0') ?? (pre * 0.15);
double tds = double.tryParse(e['tds']?.toString() ?? '0') ?? (com * 0.10);
double net = double.tryParse(e['net_payout']?.toString() ?? '0') ?? (com - tds);
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
child: Row(children: [
Expanded(flex:2, child: Text(label, style:_tableTeal, overflow:TextOverflow.ellipsis)),
Expanded(flex:2, child: Text(inr(pre), style:_tableDataBold.copyWith(color:const Color(0xFF000666)))),
Expanded(flex:2, child: Text('${e['policies']??0}', style:_tableDataBold)),
Expanded(flex:2, child: Text(inr(com), style:_tableDataBold.copyWith(color:const Color(0xFF059669)))),
Expanded(flex:2, child: Text(inr(net), style:_tableDataBold.copyWith(color:const Color(0xFF059669)))),
]),
LayoutBuilder(
builder: (ctx, box) {
final w = box.maxWidth;
final cols = w > 900 ? 4 : w > 520 ? 2 : 1;
final cardW = (w - (cols - 1) * 10) / cols;
return Wrap(
spacing: 10,
runSpacing: 10,
children: stats
.map((s) => SizedBox(width: cardW, child: _EarningStatCard(item: s)))
.toList(),
);
},
),
],
),
]),
);
@override
Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_hero(), const SizedBox(height: 14), _fySection(), const SizedBox(height: 14), _txnTable(),
]);
}
}
typedef _SI = _StatItem;
class _StatItem { final String label, value; final IconData icon; final Color color; const _StatItem(this.label, this.value, this.icon, this.color); }
class _EarningStatItem {
final String label;
final String value;
final IconData icon;
final Color color;
const _EarningStatItem(this.label, this.value, this.icon, this.color);
}
class _EarningStatCard extends StatelessWidget {
final _EarningStatItem item;
const _EarningStatCard({required this.item});
class _StatCard extends StatelessWidget {
final _StatItem item; const _StatCard({required this.item});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: const [BoxShadow(color: Color(0x07000000), blurRadius: 4, offset: Offset(0, 2))]),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible(child: Text(item.label.toUpperCase(), style: GoogleFonts.inter(fontSize: 8.5, fontWeight: FontWeight.w800, color: const Color(0xFF8AABB5), letterSpacing: .6), overflow: TextOverflow.ellipsis)),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: const [
BoxShadow(color: Color(0x07000000), blurRadius: 4, offset: Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
item.label.toUpperCase(),
style: GoogleFonts.inter(
fontSize: 8.5,
fontWeight: FontWeight.w800,
color: const Color(0xFF8AABB5),
letterSpacing: .6,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(item.icon, size: 16, color: item.color),
]),
],
),
const SizedBox(height: 8),
Text(item.value, style: GoogleFonts.manrope(fontSize: 18, fontWeight: FontWeight.w700, color: item.color)),
]),
Text(
item.value,
style: GoogleFonts.manrope(
fontSize: 18,
fontWeight: FontWeight.w700,
color: item.color,
),
),
],
),
);
}

View File

@ -1,8 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:excel/excel.dart' hide Border;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -70,6 +66,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
'invoiceNo',
'invoiceDate',
'invoiceStatus',
'utrNumber',
];
// --------------------------
@ -95,7 +92,6 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
bool isLoading = false;
bool isLoadingEditData = false;
bool hasFetchedTableData = false;
final Map<String, String> _uploadedUtrByPolicyKey = {};
final Set<int> _inlineSavingIds = <int>{};
late ApiService apiService;
dynamic managerId;
@ -516,14 +512,26 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
Future<void> saveInvoice() async {
try {
if (selectedAgentId == null) {
ToastHelper.showWarningToast(context, "Please select an agent");
ToastHelper.showWarningToast(
context,
"Please select a partner before saving.",
);
return;
}
if (selectedPolicies.isEmpty) {
ToastHelper.showWarningToast(
context,
"Please select at least 1 policy",
"Please select at least one policy before saving.",
);
return;
}
final String utrInput = (controllers['utrNumber']?.text ?? '').trim();
if (utrInput.isEmpty) {
ToastHelper.showWarningToast(
context,
"Please enter the UTR number. It cannot be left empty.",
);
return;
}
@ -536,17 +544,59 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
'yyyy-MM-dd',
).format(policyTillDate!);
// Total Commission
final double totalCommission = filteredPolicies
.where((p) => selectedPolicies.contains(int.parse(p["policy_id"])))
.fold(0.0, (sum, p) => sum + _parseCommissionValue(p["commission_amount"]));
final List<Map<String, dynamic>> selectedRows = filteredPolicies
.where((p) {
final id = int.tryParse(p["policy_id"]?.toString() ?? "0") ?? 0;
return id != 0 && selectedPolicies.contains(id);
})
.toList();
if (selectedRows.isEmpty) {
ToastHelper.showWarningToast(
context,
"No matching policies found for your selection. Refresh the list and try again.",
);
return;
}
for (final p in selectedRows) {
final policyNo = (p['policy_no'] ?? '-').toString();
final rawPayout = (p['commission_amount'] ?? '').toString().trim();
if (rawPayout.isEmpty) {
ToastHelper.showWarningToast(
context,
"Payout amount is missing for policy $policyNo. Enter an amount for every selected policy.",
);
return;
}
final payout = double.tryParse(rawPayout);
if (payout == null) {
ToastHelper.showWarningToast(
context,
"Payout amount for policy $policyNo is not a valid number. Use digits only (e.g. 1500 or 1500.50).",
);
return;
}
final premium = _parsePremiumAmount(p['premium_amount']);
if (premium != null && payout > premium + 0.001) {
ToastHelper.showWarningToast(
context,
"Payout for policy $policyNo (₹${payout.toStringAsFixed(2)}) cannot be greater than the premium (₹${premium.toStringAsFixed(2)}).",
);
return;
}
}
final double totalPayout = selectedRows.fold(
0.0,
(sum, p) => sum + _parseCommissionValue(p["commission_amount"]),
);
// Build items array
final List<Map<String, dynamic>> items = filteredPolicies
.where((p) => selectedPolicies.contains(int.parse(p["policy_id"])))
final List<Map<String, dynamic>> items = selectedRows
.map(
(p) => {
"policy_id": int.parse(p["policy_id"]),
"policy_id": int.parse(p["policy_id"].toString()),
"policy_no": p["policy_no"],
"commission_amount": _parseCommissionValue(p["commission_amount"]),
},
@ -556,14 +606,35 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
defaultUtrDate: formattedInvoiceDate,
);
if (utrs.isEmpty) {
ToastHelper.showWarningToast(
context,
"UTR details could not be built. Check the UTR number and selected policies.",
);
return;
}
final double totalUtrAmount = _sumUtrAmounts(utrs);
if (!_amountsMatch(totalPayout, totalUtrAmount)) {
ToastHelper.showWarningToast(
context,
"Total payout (₹${totalPayout.toStringAsFixed(2)}) must match the total UTR amount (₹${totalUtrAmount.toStringAsFixed(2)}). "
"Check each row's payout and the UTR field.",
);
return;
}
final int payoutStatus =
_amountsMatch(totalPayout, totalUtrAmount) ? 2 : 1;
final jsondata = {
"invoice_no": controllers['invoiceNo']?.text,
"invoice_amount": totalCommission,
"invoice_amount": totalPayout,
"agent_id": selectedAgentId,
"broker_id": selectedBrokerID,
"invoice_date": formattedInvoiceDate,
"till_date": formattedTillDate,
"payout_status": 0,
"payout_status": payoutStatus,
"pos_id": null,
"created_by": userId,
"updated_by": userId,
@ -581,14 +652,16 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
print('PD =>API Data response - $response');
selectedPolicies.clear();
controllers['invoiceNo']?.clear();
controllers['utrNumber']?.clear();
invoiceDate = DateTime.now();
policyTillDate = DateTime.now();
filteredPolicies = [];
_uploadedUtrByPolicyKey.clear();
});
context.go(AppRoutes.invoiceList);
context.go(AppRoutes.payoutList);
} else {
filteredPolicies = [];
final msg = (response['message'] ?? response['data'] ?? 'Save failed')
.toString();
ToastHelper.showErrorToast(context, msg);
}
} catch (e) {
print('PD =>Exception occurred: $e');
@ -599,36 +672,6 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
}
}
String _normalizeHeader(dynamic header) {
return header
.toString()
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]'), '');
}
String _excelCellValue(Data? cell) {
final value = cell?.value;
if (value == null) return '';
return value.toString().trim();
}
String _normalizePolicyNo(dynamic value) {
var text = (value ?? '').toString().trim();
if (text.isEmpty) return '';
// Remove common Excel text-protection wrappers around policy number.
text = text.replaceAll(RegExp(r"^[`']+"), '').replaceAll(RegExp(r"[`']+$"), '');
// Excel often turns large integer-looking values into "12345.0".
final trailingPointZero = RegExp(r'^(\d+)\.0+$').firstMatch(text);
if (trailingPointZero != null) {
text = trailingPointZero.group(1) ?? text;
}
return text.toLowerCase().replaceAll(RegExp(r'\s+'), '');
}
String _displayText(dynamic value) {
if (value == null) return '-';
final text = value.toString().trim();
@ -636,68 +679,19 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
return text;
}
double? _parseExcelCommission(String raw) {
final cleaned = raw
.replaceAll('', '')
.replaceAll(',', '')
.replaceAll(RegExp(r'\s+'), '')
.trim();
if (cleaned.isEmpty) return null;
return double.tryParse(cleaned);
}
PlatformFile? _buildSanitizedCommissionUploadFile(
List<Map<String, String>> rows,
) {
if (rows.isEmpty) return null;
final excelOut = Excel.createExcel();
final defaultSheet = excelOut.getDefaultSheet();
final sheet = excelOut[defaultSheet ?? 'Sheet1'];
sheet.appendRow([
TextCellValue('Agent Code'),
TextCellValue('Policy Number'),
TextCellValue('Payout Amount'),
]);
for (final row in rows) {
sheet.appendRow([
TextCellValue((row['agent_code'] ?? '').trim()),
TextCellValue((row['policy_no'] ?? '').trim()),
TextCellValue((row['payout_amount'] ?? '').trim()),
]);
}
final bytes = excelOut.encode();
if (bytes == null || bytes.isEmpty) return null;
return PlatformFile(
name: 'policy_commission_upload_cleaned.xlsx',
size: bytes.length,
bytes: Uint8List.fromList(bytes),
);
}
List<Map<String, dynamic>> _buildUtrPayloadForSelectedPolicies({
required String defaultUtrDate,
}) {
/*
* Build UTR payload from selected policies.
* 1) UTR source is Excel "UTR Number" column captured per policy.
* 2) If same UTR appears in multiple selected rows, amount is aggregated.
* 3) Amount comes from selected policy commission amount.
*/
final Map<String, double> utrAmountMap = {};
final String enteredUtr = (controllers['utrNumber']?.text ?? '').trim();
for (final policy in filteredPolicies) {
final int policyId = int.tryParse(policy["policy_id"]?.toString() ?? "0") ?? 0;
if (!selectedPolicies.contains(policyId)) continue;
final String policyKey = _normalizePolicyNo(policy['policy_no']);
final String utrNo = ((_uploadedUtrByPolicyKey[policyKey] ??
policy['utr_no'] ??
'')
.toString())
.trim();
final String utrNo = enteredUtr.isNotEmpty
? enteredUtr
: (policy['utr_no'] ?? '').toString().trim();
if (utrNo.isEmpty) continue;
final double amount = _parseCommissionValue(policy["commission_amount"]);
@ -715,422 +709,6 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
.toList();
}
Widget _excelMatchedPolicyTile(Map<String, String> row) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE5E7EB)),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${row['policy_no']} | ${row['current_commission']} -> ${row['excel_commission']}',
style: GoogleFonts.inter(fontSize: 12),
),
],
),
);
}
/// No Excel row matched the table list all Excel policy numbers; OK only.
Future<void> _showAllPoliciesUnmatchedDialog({
required List<String> excelPolicyNumbersNotMatched,
required int invalidExcelRows,
}) async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(
'No policies matched',
style: GoogleFonts.inter(fontWeight: FontWeight.w600),
),
content: SizedBox(
width: 520,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'None of the policy numbers in the Excel file match the policies in the current table.',
style: GoogleFonts.inter(fontSize: 13, height: 1.35),
),
if (excelPolicyNumbersNotMatched.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Policy numbers from Excel (not matched):',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
...excelPolicyNumbersNotMatched.map(
(p) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('$p', style: GoogleFonts.inter(fontSize: 13)),
),
),
],
if (invalidExcelRows > 0) ...[
const SizedBox(height: 12),
Text(
'$invalidExcelRows row(s) in Excel were skipped (invalid policy number or commission).',
style: GoogleFonts.inter(fontSize: 12, color: Colors.grey[700]),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('OK', style: GoogleFonts.inter(fontWeight: FontWeight.w600)),
),
],
),
);
}
/// At least one match show matched one-by-one; if Excel has extra policies, list them; Proceed / Cancel.
Future<bool> _showUploadReviewProceedDialog({
required List<Map<String, String>> matchedRows,
required List<String> excelPolicyNumbersNotOnTable,
required int invalidExcelRows,
}) async {
final hasExcelOnly = excelPolicyNumbersNotOnTable.isNotEmpty;
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(
hasExcelOnly ? 'Review Excel vs table' : 'Confirm commission update',
style: GoogleFonts.inter(fontWeight: FontWeight.w600),
),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Matched policies (${matchedRows.length})',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
...matchedRows.map(_excelMatchedPolicyTile),
if (hasExcelOnly) ...[
const SizedBox(height: 16),
Text(
'Not found on current table (${excelPolicyNumbersNotOnTable.length})',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.orange.shade800,
),
),
const SizedBox(height: 6),
Text(
'These policy numbers are in Excel but not in the loaded table:',
style: GoogleFonts.inter(fontSize: 12, height: 1.35),
),
const SizedBox(height: 8),
...excelPolicyNumbersNotOnTable.map(
(p) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'$p',
style: GoogleFonts.inter(fontSize: 13),
),
),
),
],
if (invalidExcelRows > 0) ...[
const SizedBox(height: 12),
Text(
'Note: $invalidExcelRows Excel row(s) skipped (invalid data).',
style: GoogleFonts.inter(fontSize: 12, color: Colors.grey[700]),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Cancel', style: GoogleFonts.inter()),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('Proceed', style: GoogleFonts.inter()),
),
],
),
) ??
false;
}
Future<bool> _showMismatchProceedDialog(int mismatchCount) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Partner mismatch found'),
content: Text(
'$mismatchCount row(s) are linked to a different partner.\n'
'Proceeding will retroactively update partner mapping in payout records.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Proceed'),
),
],
),
) ??
false;
}
Future<void> _triggerBulkUpload() async {
if (selectedAgentId == null || selectedAgentId!.isEmpty) {
ToastHelper.showWarningToast(context, 'Please select Partner before uploading.');
return;
}
if (selectedAgentId!.length != 1) {
ToastHelper.showWarningToast(context, 'Please select only 1 Partner before uploading.');
return;
}
if (!hasFetchedTableData || filteredPolicies.isEmpty) {
ToastHelper.showWarningToast(
context,
'No policy data loaded. Set dates, Partner, tap Filter, then upload.',
);
return;
}
try {
final picked = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx', 'xls'],
withData: true,
);
if (picked == null || picked.files.isEmpty) {
ToastHelper.showWarningToast(context, 'No Excel file selected. Please choose a file to upload.');
return;
}
final selectedFile = picked.files.single;
if (selectedFile.bytes == null || selectedFile.bytes!.isEmpty) {
ToastHelper.showWarningToast(context, 'Unable to read the selected Excel file. Try another file.');
return;
}
setState(() => isLoading = true);
final excel = Excel.decodeBytes(Uint8List.fromList(selectedFile.bytes!));
if (excel.tables.isEmpty) {
ToastHelper.showWarningToast(context, 'Invalid Excel: no sheet found in the file.');
return;
}
final rows = excel.tables.values.first.rows;
if (rows.isEmpty) {
ToastHelper.showWarningToast(context, 'Uploaded Excel file is empty.');
return;
}
int headerRowIndex = -1;
int policyNoCol = -1;
int commissionCol = -1;
int agentCodeCol = -1;
final int headerScanLimit = rows.length < 5 ? rows.length : 5;
for (int r = 0; r < headerScanLimit; r++) {
int pCol = -1;
int cCol = -1;
int aCol = -1;
final currentRow = rows[r];
for (int i = 0; i < currentRow.length; i++) {
final normalized = _normalizeHeader(_excelCellValue(currentRow[i]));
if (normalized == 'policynumber') pCol = i;
if (normalized == 'payoutamount' || normalized == 'commissionamount') {
cCol = i;
}
if (normalized == 'agentcode') aCol = i;
}
if (pCol != -1 && cCol != -1) {
headerRowIndex = r;
policyNoCol = pCol;
commissionCol = cCol;
agentCodeCol = aCol;
break;
}
}
if (policyNoCol == -1 || commissionCol == -1) {
ToastHelper.showWarningToast(
context,
'Excel header not found. Required columns: Policy Number and Payout Amount.',
);
return;
}
final Map<String, double> uploadMap = {};
final Map<String, String> uploadPolicyDisplay = {};
final Map<String, String> uploadAgentCodeMap = {};
final List<Map<String, String>> sanitizedRowsForUpload = [];
int invalidRows = 0;
for (int rowIndex = headerRowIndex + 1; rowIndex < rows.length; rowIndex++) {
final row = rows[rowIndex];
final policyRaw = row.length > policyNoCol ? _excelCellValue(row[policyNoCol]) : '';
final commissionRaw = row.length > commissionCol ? _excelCellValue(row[commissionCol]) : '';
final agentCodeRaw = agentCodeCol >= 0 && row.length > agentCodeCol
? _excelCellValue(row[agentCodeCol])
: '';
if (policyRaw.trim().isEmpty && commissionRaw.trim().isEmpty && agentCodeRaw.trim().isEmpty) {
continue;
}
final normalizedPolicy = _normalizePolicyNo(policyRaw);
final parsedCommission = _parseExcelCommission(commissionRaw);
if (normalizedPolicy.isEmpty || parsedCommission == null) {
invalidRows++;
continue;
}
uploadMap[normalizedPolicy] = parsedCommission;
uploadPolicyDisplay[normalizedPolicy] =
policyRaw.trim().isEmpty ? normalizedPolicy : policyRaw.trim();
if (agentCodeRaw.trim().isNotEmpty) {
uploadAgentCodeMap[normalizedPolicy] = agentCodeRaw.trim().toUpperCase();
}
sanitizedRowsForUpload.add({
'agent_code': agentCodeRaw.trim().toUpperCase(),
'policy_no': policyRaw.trim(),
'payout_amount': parsedCommission.toStringAsFixed(2),
});
}
if (uploadMap.isEmpty) {
ToastHelper.showWarningToast(
context,
'No valid data rows in Excel (check policy numbers and payout amounts).',
);
return;
}
final Set<String> screenPolicyKeys = {
for (final p in filteredPolicies) _normalizePolicyNo(p['policy_no']),
};
final List<Map<String, dynamic>> matchedPolicyRefs = [];
final List<Map<String, String>> matchedRowsForDialog = [];
final List<String> excelPoliciesNotOnTable = [];
int partnerMismatchCount = 0;
for (final e in uploadMap.entries) {
final key = e.key;
if (screenPolicyKeys.contains(key)) {
final policy = filteredPolicies.firstWhere(
(p) => _normalizePolicyNo(p['policy_no']) == key,
);
final excelAgentCode = (uploadAgentCodeMap[key] ?? '').trim();
final tableAgentCode =
(policy['agent_code'] ?? '').toString().trim().toUpperCase();
if (excelAgentCode.isNotEmpty &&
tableAgentCode.isNotEmpty &&
excelAgentCode != tableAgentCode) {
partnerMismatchCount++;
}
matchedPolicyRefs.add(policy);
matchedRowsForDialog.add({
'policy_no': (policy['policy_no'] ?? '').toString(),
'current_commission': (policy['commission_amount'] ?? '').toString(),
'excel_commission': e.value.toStringAsFixed(2),
});
} else {
excelPoliciesNotOnTable.add(uploadPolicyDisplay[key] ?? key);
}
}
if (matchedRowsForDialog.isEmpty) {
await _showAllPoliciesUnmatchedDialog(
excelPolicyNumbersNotMatched: excelPoliciesNotOnTable.isNotEmpty
? excelPoliciesNotOnTable
: uploadMap.keys.map((k) => uploadPolicyDisplay[k] ?? k).toList(),
invalidExcelRows: invalidRows,
);
return;
}
final bool proceed = await _showUploadReviewProceedDialog(
matchedRows: matchedRowsForDialog,
excelPolicyNumbersNotOnTable: excelPoliciesNotOnTable,
invalidExcelRows: invalidRows,
);
if (!proceed) {
ToastHelper.showWarningToast(context, 'Commission update cancelled');
return;
}
if (partnerMismatchCount > 0) {
final bool proceedMismatch = await _showMismatchProceedDialog(
partnerMismatchCount,
);
if (!proceedMismatch) {
ToastHelper.showWarningToast(context, 'Cancelled due to partner mismatch.');
return;
}
}
final sanitizedUploadFile = _buildSanitizedCommissionUploadFile(
sanitizedRowsForUpload,
);
final response = await apiService.uploadPolicyCommissionExcel(
id: selectedAgentId!.first,
file: sanitizedUploadFile ?? selectedFile,
proceedPartnerMismatch: partnerMismatchCount > 0,
);
if (response['status'] != 'success' && response['status'] != 200) {
ToastHelper.showErrorToast(
context,
(response['message'] ?? 'Upload failed').toString(),
);
return;
}
setState(() {
for (final policy in matchedPolicyRefs) {
final key = _normalizePolicyNo(policy['policy_no']);
if (uploadMap.containsKey(key)) {
policy['commission_amount'] = uploadMap[key]!.toStringAsFixed(2);
}
}
});
_calculateTotalCommission();
final int tableRowsNotInExcel = filteredPolicies
.where((p) => !uploadMap.containsKey(_normalizePolicyNo(p['policy_no'])))
.length;
final message =
'Updated ${matchedRowsForDialog.length} policy commission(s) from Excel'
'${partnerMismatchCount > 0 ? ' | partner mismatch updated: $partnerMismatchCount' : ''}'
'${excelPoliciesNotOnTable.isNotEmpty ? ' | Excel-only (ignored): ${excelPoliciesNotOnTable.length}' : ''}'
'${tableRowsNotInExcel > 0 ? ' | table rows not in Excel: $tableRowsNotInExcel' : ''}'
'${invalidRows > 0 ? ' | invalid Excel rows: $invalidRows' : ''}';
ToastHelper.showSuccessToast(context, message);
} catch (e) {
ToastHelper.showErrorToast(context, e.toString());
} finally {
if (mounted) setState(() => isLoading = false);
}
}
void showMessage(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
@ -1153,7 +731,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
),
),
content: Text(
"Once saved, changes cannot be edited.\nAre you sure you want to continue?",
"Once saved, payout details cannot be edited.\nAre you sure you want to continue?",
style: GoogleFonts.inter(fontSize: 13, color: Colors.grey[700]),
),
actions: [
@ -1305,25 +883,6 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
if (!isEdit) ...[
const Spacer(),
OutlinedButton.icon(
onPressed: isLoading ? null : _triggerBulkUpload,
icon: const Icon(Icons.upload_file, size: 16),
label: Text(
"Upload",
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFF2E7D6E)),
foregroundColor: const Color(0xFF2E7D6E),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
if (hasFetchedTableData) ...[
const SizedBox(width: 10),
Container(
@ -1389,7 +948,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
),
const SizedBox(width: 6),
Text(
"TOTAL COMMISSION (PAYOUT) :",
"TOTAL PAYOUT AMOUNT :",
style: GoogleFonts.poppins(
fontSize: 10,
fontWeight: FontWeight.w500,
@ -1534,7 +1093,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
),
const SizedBox(width: 4),
Text(
"Total Commission (Payout)",
"Total Payout Amount",
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w500,
@ -1557,6 +1116,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
),
Spacer(),
buildUTRNumber(context),
SizedBox(width: 20),
// ---------------- BUTTONS ----------------
Row(
@ -1577,7 +1137,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
),
),
child: Text(
"Raise Invoice",
"Save Payout Details",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 12,
@ -1637,7 +1197,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
),
const SizedBox(width: 4),
Text(
"Total Commission (Payout)",
"Total Payout Amount",
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w500,
@ -1744,7 +1304,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
_headerText("PARTNER", colAgent),
_headerText("CUSTOMER", colCustomer),
_headerText("PREMIUM", colPremium),
_headerText("COMMISSION (PAYOUT)", colCommission),
_headerText("PAYOUT", colCommission),
],
),
);
@ -1893,6 +1453,31 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
return double.tryParse(value.toString().trim()) ?? 0;
}
/// Premium from API / table (may include or commas).
double? _parsePremiumAmount(dynamic value) {
if (value == null) return null;
final cleaned = value
.toString()
.replaceAll('', '')
.replaceAll(',', '')
.replaceAll(RegExp(r'\s+'), '')
.trim();
if (cleaned.isEmpty) return null;
return double.tryParse(cleaned);
}
double _sumUtrAmounts(List<Map<String, dynamic>> utrs) {
double sum = 0;
for (final row in utrs) {
sum += _parseCommissionValue(row['amount']);
}
return sum;
}
bool _amountsMatch(double a, double b, {double epsilon = 0.01}) {
return (a - b).abs() < epsilon;
}
Future<void> _updateInlineCommission(Map<String, dynamic> row, String value) async {
final trimmed = value.trim();
final amount = double.tryParse(trimmed);
@ -1969,6 +1554,26 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
return total.roundToDouble();
}
Widget buildUTRNumber(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('UTR Number', style: _textStyle),
SizedBox(width: 5),
SizedBox(
height: 35,
width: MediaQuery.of(context).size.width * 0.15,
child: TextFormField(
controller: controllers['utrNumber']!,
inputFormatters: _utrInputFormatter,
decoration: commonInputDecoration(hint: 'UTR Number'),
),
),
],
);
}
InputDecoration commonInputDecoration({required String hint}) {
return InputDecoration(
hintText: hint,