fix_payout
This commit is contained in:
parent
e65290cc7b
commit
1988ebeca7
@ -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
|
||||
|
||||
|
||||
@ -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))),
|
||||
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)))]),
|
||||
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(
|
||||
@ -458,259 +492,293 @@ 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,
|
||||
? 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),
|
||||
),
|
||||
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))),
|
||||
]),
|
||||
]));
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user