From 1988ebeca7025b6020182017449d181ab2584ff8 Mon Sep 17 00:00:00 2001 From: "sanjeev.p" Date: Sat, 4 Apr 2026 09:14:15 +0530 Subject: [PATCH] fix_payout --- lib/core/services/api_service.dart | 21 +- .../dashboard/partner_portal_dashboard.dart | 756 ++++++++++-------- .../screens/payout/payout_details.dart | 691 ++++------------ 3 files changed, 576 insertions(+), 892 deletions(-) diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index d6cc8e1..e770900 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -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> 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> 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> 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 diff --git a/lib/presentation/screens/dashboard/partner_portal_dashboard.dart b/lib/presentation/screens/dashboard/partner_portal_dashboard.dart index a2c63b3..9382d2e 100644 --- a/lib/presentation/screens/dashboard/partner_portal_dashboard.dart +++ b/lib/presentation/screens/dashboard/partner_portal_dashboard.dart @@ -43,8 +43,7 @@ class _State extends ConsumerState { List> allPolicies = []; List> allRenewals = []; List> filteredRenewals = []; - int selectedRenewalDays = 10, renewalPage = 0; - static const kPerPage = 4; + int selectedRenewalDays = 10; List> allEarnings = []; String selectedFY = _currentFinYear(); @@ -96,57 +95,48 @@ class _State extends ConsumerState { selectedRenewalDays = days; allRenewals = raw; filteredRenewals = raw; - renewalPage = 0; }); } } - Future _fetchEarnings(dynamic id) async { - final r = await api.getPartnerEarnings(id); - if (r['status'] == 'success') setState(() => allEarnings = List>.from((r['data'] ?? []).map((e) => Map.from(e)))); + Future _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>.from( + (r['data'] ?? []).map((e) => Map.from(e)), + ); + }); + } } void _applyRenewalFilter(int days) { setState(() { selectedRenewalDays = days; filteredRenewals = allRenewals.where((r) => (r['days_left'] ?? 999) <= days).toList(); - renewalPage = 0; }); } - List> 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 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> 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 { _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 { _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 details; final String Function(dynamic) inr; - const _OverviewBar({required this.details, required this.inr}); + final Map 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,25 +339,24 @@ class _OverviewBar extends StatelessWidget { // POLICY LIST + RENEWAL ALERTS ROW // ───────────────────────────────────────────────────────────────────────────── class _PolicyRenewalRow extends StatelessWidget { - final List> policies, currentPageRenewals; - final int totalRenewalPages, renewalPage, selectedRenewalDays, filteredRenewalsCount; - final ValueChanged onRenewalDays, onRenewalPageChanged; + final List> renewals; + final int selectedRenewalDays; + final ValueChanged 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, - selectedDays: selectedRenewalDays, - totalCount: filteredRenewalsCount, - onDays: onRenewalDays, - onPageChanged: onRenewalPageChanged, - inr: inr, - ); + renewals: renewals, + selectedDays: selectedRenewalDays, + totalCount: renewals.length, + onDays: onRenewalDays, + inr: inr, + ); } // ── Policy list card @@ -406,312 +404,382 @@ class _PolicyListCard extends StatelessWidget { } class _RenewalAlertsCard extends StatelessWidget { - final List> currentPageRenewals; - final int totalPages, currentPage, selectedDays, totalCount; - final ValueChanged onDays, onPageChanged; + static const int kVisibleRows = 10; + static const double kRowHeight = 44; + + final List> renewals; + final int selectedDays, totalCount; + final ValueChanged 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))), - 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)))]), - const Spacer(), - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: const Color(0xFFF2F4F6), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE2E8F0)), - ), - child: Wrap( - spacing: 2, - runSpacing: 2, - children: [10, 20, 30, 40, 50].map((d) { - final act = selectedDays == d; - return GestureDetector( - onTap: () => onDays(d), - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: act ? const Color(0xFF000666) : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: act ? const Color(0xFF000666) : Colors.transparent, + 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)), + ), + 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)), + ), + ], + ), + 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( + color: const Color(0xFFF2F4F6), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Wrap( + spacing: 2, + runSpacing: 2, + children: [10, 20, 30, 40, 50].map((d) { + final act = selectedDays == d; + return GestureDetector( + onTap: () => onDays(d), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: act ? const Color(0xFF000666) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: act ? const Color(0xFF000666) : Colors.transparent, + ), + ), + child: Text( + '$d Days', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: act ? Colors.white : const Color(0xFF454652), + ), + ), ), - ), - child: Text( - '$d Days', - style: GoogleFonts.inter( - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: .4, - color: act ? Colors.white : const Color(0xFF454652), + ); + }).toList(), + ), + ), + ]), + body: totalCount == 0 + ? const _EmptyState(message: 'No renewals due within selected period') + : 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), + ), + ], + ), + ), + 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), + ), + ), + ], + ), + ); +} + +class _RenewalTableRow extends StatelessWidget { + final Map 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 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); + } + + 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, + ), ), ), ), - ); - }).toList(), + ), + 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, + ), + ), + ], ), ), - ]), - 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, - ), - 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)), - ]), - ], - ]); - }, - ), - ), - ); -} - -// renewal card — holder_name=insured_name, premium=premium_amount (both aliased in API) -class _RenewalAlertCard extends StatelessWidget { - final Map renewal; final String Function(dynamic) inr; - const _RenewalAlertCard({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))), - ]), - ])); + ); } } -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)))); -} - // ───────────────────────────────────────────────────────────────────────────── -// 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 allFinYears; final String selectedFinYear; final ValueChanged onFinYearChanged; final Map aggregate; - final List> 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: [ - 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), - ]), - 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))), - ]), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFD9F0ED)), + borderRadius: BorderRadius.circular(12), ), - 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', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.currency_rupee_rounded, size: 16, color: Color(0xFF006B5C)), + const SizedBox(width: 6), + 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)), + ), + ], ), ), - ) - : 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)))), - ]), - ); - }, + _FYTabBar( + allFinYears: allFinYears, + selected: selectedFinYear, + onChanged: onFinYearChanged, ), - ), - ]), - ); - - @override - Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - _hero(), const SizedBox(height: 14), _fySection(), const SizedBox(height: 14), _txnTable(), - ]); + ], + ), + const SizedBox(height: 14), + 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(), + ); + }, + ), + ], + ), + ); + } } -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)), - 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)), - ]), - ); + 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, + ), + ), + 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, + ), + ), + ], + ), + ); } class _FYTabBar extends StatelessWidget { diff --git a/lib/presentation/screens/payout/payout_details.dart b/lib/presentation/screens/payout/payout_details.dart index 619b126..b2276f0 100644 --- a/lib/presentation/screens/payout/payout_details.dart +++ b/lib/presentation/screens/payout/payout_details.dart @@ -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 { 'invoiceNo', 'invoiceDate', 'invoiceStatus', + 'utrNumber', ]; // -------------------------- @@ -95,7 +92,6 @@ class _PayOutDetailsState extends ConsumerState { bool isLoading = false; bool isLoadingEditData = false; bool hasFetchedTableData = false; - final Map _uploadedUtrByPolicyKey = {}; final Set _inlineSavingIds = {}; late ApiService apiService; dynamic managerId; @@ -516,14 +512,26 @@ class _PayOutDetailsState extends ConsumerState { Future 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 { '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> 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> items = filteredPolicies - .where((p) => selectedPolicies.contains(int.parse(p["policy_id"]))) + final List> 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 { 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 { 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 { } } - 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 { 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> 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> _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 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 { .toList(); } - Widget _excelMatchedPolicyTile(Map 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 _showAllPoliciesUnmatchedDialog({ - required List excelPolicyNumbersNotMatched, - required int invalidExcelRows, - }) async { - await showDialog( - 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 _showUploadReviewProceedDialog({ - required List> matchedRows, - required List excelPolicyNumbersNotOnTable, - required int invalidExcelRows, - }) async { - final hasExcelOnly = excelPolicyNumbersNotOnTable.isNotEmpty; - return await showDialog( - 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 _showMismatchProceedDialog(int mismatchCount) async { - return await showDialog( - 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 _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 uploadMap = {}; - final Map uploadPolicyDisplay = {}; - final Map uploadAgentCodeMap = {}; - final List> 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 screenPolicyKeys = { - for (final p in filteredPolicies) _normalizePolicyNo(p['policy_no']), - }; - final List> matchedPolicyRefs = []; - final List> matchedRowsForDialog = []; - final List 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 { ), ), 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 { 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 { ), 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 { ), 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 { ), Spacer(), + buildUTRNumber(context), SizedBox(width: 20), // ---------------- BUTTONS ---------------- Row( @@ -1577,7 +1137,7 @@ class _PayOutDetailsState extends ConsumerState { ), ), child: Text( - "Raise Invoice", + "Save Payout Details", style: GoogleFonts.poppins( color: Colors.white, fontSize: 12, @@ -1637,7 +1197,7 @@ class _PayOutDetailsState extends ConsumerState { ), 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 { _headerText("PARTNER", colAgent), _headerText("CUSTOMER", colCustomer), _headerText("PREMIUM", colPremium), - _headerText("COMMISSION (PAYOUT)", colCommission), + _headerText("PAYOUT", colCommission), ], ), ); @@ -1893,6 +1453,31 @@ class _PayOutDetailsState extends ConsumerState { 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> 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 _updateInlineCommission(Map row, String value) async { final trimmed = value.trim(); final amount = double.tryParse(trimmed); @@ -1969,6 +1554,26 @@ class _PayOutDetailsState extends ConsumerState { 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,