This commit is contained in:
sanjeev.p 2026-04-06 17:14:00 +05:30
parent b3884f32c4
commit 650536745d
7 changed files with 455 additions and 31 deletions

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@ class Env {
);
static const String apiUrl = String.fromEnvironment(
'API_URL',
// defaultValue: 'https://partner.nhanceindia.in/partner_api/api/', /* Live build (enable index.html line 18) */
defaultValue: 'https://venbait.in/nhance/partner/dev/api/', /* Test build (enable index.html line 19) */
//defaultValue: 'https://partner.nhanceindia.in/partner_api/api/', /* Live build (enable index.html line 18) */
defaultValue: 'https://venbait.in/nhance/partner/dev/api/', /* Test build (enable index.html line 19) */
// defaultValue: 'http://localhost/nhance_partner_be/', /* localhost build (enable index.html line 19) */
);
// static const String baseUrl = String.fromEnvironment(

View File

@ -918,6 +918,12 @@ class ApiService {
Future<Map<String, dynamic>> loadPayoutGrid({
String? role,
String? fileId,
String? insurer,
String? vehicleType,
String? segment,
String? rto,
String? planType,
String? search,
String? loggedId,
}) async {
if (_token == null) {
@ -926,21 +932,47 @@ class ApiService {
final roleTrimmed = role?.trim();
final fileIdTrimmed = fileId?.trim();
final insurerTrimmed = insurer?.trim();
final vehicleTypeTrimmed = vehicleType?.trim();
final segmentTrimmed = segment?.trim();
final rtoTrimmed = rto?.trim();
final planTypeTrimmed = planType?.trim();
final searchTrimmed = search?.trim();
final loggedIdTrimmed = loggedId?.trim();
final endpoint = Uri.parse('${Env.apiUrl}grid');
final roleLower = roleTrimmed?.toLowerCase();
final queryParameters = <String, String>{};
if (roleTrimmed != null && roleTrimmed.isNotEmpty) {
queryParameters['role'] = roleTrimmed;
}
if (fileIdTrimmed != null && fileIdTrimmed.isNotEmpty) {
if (roleLower != 'agent' &&
fileIdTrimmed != null &&
fileIdTrimmed.isNotEmpty) {
queryParameters['file_id'] = fileIdTrimmed;
}
if (insurerTrimmed != null && insurerTrimmed.isNotEmpty) {
queryParameters['insurer'] = insurerTrimmed;
}
if (vehicleTypeTrimmed != null && vehicleTypeTrimmed.isNotEmpty) {
queryParameters['vehicle_type'] = vehicleTypeTrimmed;
}
if (segmentTrimmed != null && segmentTrimmed.isNotEmpty) {
queryParameters['segment'] = segmentTrimmed;
}
if (rtoTrimmed != null && rtoTrimmed.isNotEmpty) {
queryParameters['rto'] = rtoTrimmed;
}
if (planTypeTrimmed != null && planTypeTrimmed.isNotEmpty) {
queryParameters['plan_type'] = planTypeTrimmed;
}
if (searchTrimmed != null && searchTrimmed.isNotEmpty) {
queryParameters['search'] = searchTrimmed;
}
if (loggedIdTrimmed != null && loggedIdTrimmed.isNotEmpty) {
queryParameters['logged_id'] = loggedIdTrimmed;
}
final url = endpoint.replace(queryParameters: queryParameters);
final headers = {
'Authorization': 'Bearer ${_token ?? ''}',
'app-signature': Env.App_Signature,
@ -1050,6 +1082,96 @@ class ApiService {
return response;
}
Future<void> downloadAgentRetentionRateExcel() async {
if (_token == null) {
await _initializeToken();
}
final url = Uri.parse('${Env.apiUrl}agent/exportRetentionRateExcel');
// final url = Uri.parse('http://localhost/nhance_partner_be/agent/exportRetentionRateExcel');
final headers = {
'Authorization': 'Bearer ${_token ?? ''}',
'app-signature': Env.App_Signature,
};
final response = await _makeGethttpRequest(url, headers);
if (response.statusCode != 200) {
throw Exception('Failed to download retention rate excel');
}
final contentType = response.headers['content-type'] ?? '';
if (contentType.contains('application/json')) {
throw Exception('No export data available');
}
final blob = html.Blob([response.bodyBytes]);
final blobUrl = html.Url.createObjectUrlFromBlob(blob);
final fileName = extractFileName(
response.headers['content-disposition'],
'RentationRate.xlsx',
);
html.AnchorElement(href: blobUrl)
..setAttribute('download', fileName)
..click();
html.Url.revokeObjectUrl(blobUrl);
}
Future<Map<String, dynamic>> importAgentRetentionRateExcel({
required PlatformFile file,
}) async {
if (_token == null) {
await _initializeToken();
}
final url = Uri.parse('${Env.apiUrl}agent/importRetentionRateExcel');
// final url = Uri.parse('http://localhost/nhance_partner_be/agent/importRetentionRateExcel');
try {
final request = http.MultipartRequest('POST', url);
request.headers.addAll({
'Authorization': 'Bearer ${_token ?? ''}',
'app-signature': Env.App_Signature,
});
if (file.bytes == null) {
throw Exception('Could not read selected file');
}
request.files.add(
http.MultipartFile.fromBytes(
'retention_excel',
file.bytes!,
filename: file.name,
),
);
final streamedResponse = await request.send();
final responseBody = await streamedResponse.stream.bytesToString();
if (streamedResponse.statusCode == 401 ||
streamedResponse.statusCode == 403) {
await clearLocalStorageAndRedirect();
return {'status': 'error', 'message': 'Session expired'};
}
if (responseBody.trim().isEmpty) {
return {
'status': 'error',
'message': 'Empty response from server',
'code': streamedResponse.statusCode,
};
}
final decoded = jsonDecode(responseBody);
if (decoded is Map<String, dynamic>) {
return decoded;
}
return {'status': 'error', 'message': 'Unexpected response format'};
} catch (e) {
return {'status': 'error', 'message': e.toString()};
}
}
Future<Map<String, dynamic>> findSingleAgentData(id) async {
// print(_token);
if (_token == null) {

View File

@ -24,13 +24,19 @@ Future<void> _restoreManagerId(WidgetRef ref) async {
'attendanceIndvStaffName',
);
if (savedManagerId != null && savedUserId != null) {
ref.read(managerIdProvider.notifier).state = savedManagerId;
// Partners (agents) often have no manager_id in JWT, so never gate role/userId
// restore on managerId without role, grid `?role=Agent&logged_id=` is omitted and
// the payout grid endpoint misbehaves for agent logins after app restart.
if (savedUserId != null) {
ref.read(userIdProvider.notifier).state = savedUserId;
ref.read(userRoleProvider.notifier).state = savedUserRole;
print("Manager ID restored: $savedManagerId");
print("User ID restored: $savedUserId");
}
if (savedManagerId != null) {
ref.read(managerIdProvider.notifier).state = savedManagerId;
print("Manager ID restored: $savedManagerId");
}
if (savedUserRole != null && savedUserRole.trim().isNotEmpty) {
ref.read(userRoleProvider.notifier).state = savedUserRole;
print("Role restored: $savedUserRole");
}

View File

@ -554,9 +554,9 @@ class _gridViewScreenState extends ConsumerState<gridViewScreen> {
if (_showAddButton) {
return rows.map((row) {
final next = Map<String, dynamic>.from(row);
next['partner_comp'] = next['comp'];
next['partner_tp'] = next['tp'];
next['partner_od'] = next['od'];
next['partner_comp'] = next['oa'] ?? next['comp'];
next['partner_tp'] = next['ob'] ?? next['tp'];
next['partner_od'] = next['oc'] ?? next['od'];
return next;
}).toList();
}
@ -1611,9 +1611,13 @@ class _gridViewScreenState extends ConsumerState<gridViewScreen> {
}
}
final loggedIdForGrid =
(effectiveRole ?? '').toLowerCase() == 'agent' ? _loggedId : null;
final res = await _apiService.loadPayoutGrid(
role: effectiveRole,
fileId: effectiveFileId,
loggedId: loggedIdForGrid,
);
if ((res['status'] ?? '').toString().toLowerCase() != 'success') {
ToastHelper.showErrorToast(
@ -2443,9 +2447,13 @@ class _gridViewScreenState extends ConsumerState<gridViewScreen> {
}
}
final loggedIdForGrid =
(effectiveRole ?? '').toLowerCase() == 'agent' ? _loggedId : null;
final res = await _apiService.loadPayoutGrid(
role: effectiveRole,
fileId: effectiveFileId,
loggedId: loggedIdForGrid,
);
if ((res['status'] ?? '').toString().toLowerCase() != 'success') {

View File

@ -1,12 +1,14 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/routing/routes.dart';
import '../../../../core/services/api_service.dart';
import '../../../../data/utils/Pagination.dart';
import '../../../../data/utils/toastNotification.dart';
import '../../../layouts/main_layout.dart';
import '../../../layouts/responsive_layout.dart';
import '../../../providers/manager_provider.dart';
@ -34,6 +36,7 @@ class AgentListState extends ConsumerState<AgentList> {
List<Map<String, dynamic>> originalData = [];
List<Map<String, dynamic>> filteredData = [];
bool isLoading = false;
bool isRetentionImportLoading = false;
dynamic sortedData;
dynamic managerID;
@override
@ -110,9 +113,6 @@ class AgentListState extends ConsumerState<AgentList> {
(item['sales_executive_name'] ?? '-').toLowerCase().contains(
query.toLowerCase(),
) ||
(item['retention_rate'] ?? '-').toLowerCase().contains(
query.toLowerCase(),
) ||
(isActiveStatus.contains(query.toLowerCase()));
}).toList();
});
@ -155,6 +155,232 @@ class AgentListState extends ConsumerState<AgentList> {
}
}
Future<void> _exportRetentionRateExcel() async {
final proceed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Export Rentation Rate'),
content: const Text(
'The downloaded Excel will include input validation.\n\n'
'Allowed values: only numbers from 0 to 100.\n'
'Not allowed: negative values, values above 100, text/special characters.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Download'),
),
],
);
},
);
if (proceed != true) return;
try {
await apiService.downloadAgentRetentionRateExcel();
if (mounted) {
ToastHelper.showSuccessToast(
context,
'Rentation rate Excel downloaded',
);
}
} catch (e) {
if (mounted) {
ToastHelper.showErrorToast(
context,
'Failed to export rentation rate Excel',
);
}
}
}
Future<void> _importRetentionRateExcel() async {
final picked = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: const ['xls', 'xlsx'],
withData: true,
);
if (picked == null || picked.files.isEmpty) {
return;
}
final file = picked.files.first;
if (file.bytes == null) {
if (mounted) {
ToastHelper.showErrorToast(context, 'Could not read selected file');
}
return;
}
setState(() {
isRetentionImportLoading = true;
});
try {
final response = await apiService.importAgentRetentionRateExcel(file: file);
final status = (response['status'] ?? '').toString().toLowerCase();
if (status == 'success') {
final data = (response['data'] is Map<String, dynamic>)
? response['data'] as Map<String, dynamic>
: <String, dynamic>{};
final report = (response['report'] is Map<String, dynamic>)
? response['report'] as Map<String, dynamic>
: <String, dynamic>{};
final inserted = (data['inserted_cells'] ?? 0).toString();
final updated = (data['updated_cells'] ?? 0).toString();
if (mounted) {
ToastHelper.showSuccessToast(
context,
'Import done',
);
}
await _showRetentionImportReportDialog(data, report);
refresh();
} else {
if (mounted) {
ToastHelper.showErrorToast(context, 'Import failed');
}
}
} catch (e) {
if (mounted) {
ToastHelper.showErrorToast(context, 'Failed to import rentation rate');
}
} finally {
if (mounted) {
setState(() {
isRetentionImportLoading = false;
});
}
}
}
Future<void> _showRetentionImportReportDialog(
Map<String, dynamic> data,
Map<String, dynamic> report,
) async {
if (!mounted) return;
final inserted = data['inserted_cells'] ?? 0;
final updated = data['updated_cells'] ?? 0;
final skippedZero = data['skipped_zero'] ?? 0;
final skippedSame = data['skipped_same'] ?? 0;
final skippedEmptyRow = data['skipped_empty_row'] ?? 0;
final skippedEmptyCell = data['skipped_empty_cell'] ?? 0;
final skippedUnknownAgent = data['skipped_unknown_agent'] ?? 0;
final invalidRange = data['invalid_range'] ?? 0;
final invalidNumber = data['invalid_number'] ?? 0;
final unknownAgents = (report['unknown_agent_codes'] as List?)
?.map((e) => e.toString())
.where((e) => e.trim().isNotEmpty)
.toList() ??
<String>[];
final unknownVehicleTypes = (report['unknown_vehicle_types'] as List?)
?.map((e) => e.toString())
.where((e) => e.trim().isNotEmpty)
.toList() ??
<String>[];
final invalidCells = (report['invalid_cells'] as List?)
?.map((e) => e.toString())
.where((e) => e.trim().isNotEmpty)
.toList() ??
<String>[];
final agentWiseSkippedRaw = (report['agent_wise_skipped_counts'] is Map)
? Map<String, dynamic>.from(report['agent_wise_skipped_counts'] as Map)
: <String, dynamic>{};
final lines = <String>[
'Inserted: $inserted',
'Updated: $updated',
// 'Skipped (value = 0): $skippedZero',
// 'Skipped (Excel value equals DB value): $skippedSame',
// 'Skipped (empty first column / agent code): $skippedEmptyRow',
// 'Skipped (empty cell): $skippedEmptyCell',
// 'Skipped (unknown agent code): $skippedUnknownAgent',
'Skipped (out of range 0-100): $invalidRange',
'Skipped (non-numeric value): $invalidNumber',
];
if (unknownAgents.isNotEmpty) {
lines.add('');
lines.add('Unknown Agent Codes:');
lines.addAll(unknownAgents.take(10).map((e) => '- $e'));
if (unknownAgents.length > 10) {
lines.add('- ...and ${unknownAgents.length - 10} more');
}
}
if (unknownVehicleTypes.isNotEmpty) {
lines.add('');
lines.add('Unknown Vehicle Types (header):');
lines.addAll(unknownVehicleTypes.take(10).map((e) => '- $e'));
if (unknownVehicleTypes.length > 10) {
lines.add('- ...and ${unknownVehicleTypes.length - 10} more');
}
}
if (invalidCells.isNotEmpty) {
lines.add('');
lines.add('Invalid Cell Samples:');
lines.addAll(invalidCells.take(10).map((e) => '- $e'));
if (invalidCells.length > 10) {
lines.add('- ...and ${invalidCells.length - 10} more');
}
}
if (agentWiseSkippedRaw.isNotEmpty) {
final sorted = agentWiseSkippedRaw.entries.toList()
..sort((a, b) {
final av = int.tryParse(a.value.toString()) ?? 0;
final bv = int.tryParse(b.value.toString()) ?? 0;
return bv.compareTo(av);
});
final nonZero = sorted
.where((e) => (int.tryParse(e.value.toString()) ?? 0) > 0)
.toList();
if (nonZero.isNotEmpty) {
lines.add('');
lines.add('Skipped Count (Agent Wise):');
lines.addAll(
nonZero
.take(20)
.map((e) => '- ${e.key}: ${int.tryParse(e.value.toString()) ?? 0}'),
);
if (nonZero.length > 20) {
lines.add('- ...and ${nonZero.length - 20} more');
}
}
}
await showDialog<void>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Rentation Rate Import Report'),
content: SizedBox(
width: 520,
child: SingleChildScrollView(
child: SelectableText(lines.join('\n')),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
);
},
);
}
List<Widget> _buildPopupMenuActions(BuildContext context, dynamic data) {
return [
GestureDetector(
@ -266,6 +492,81 @@ class AgentListState extends ConsumerState<AgentList> {
txtwidth: MediaQuery.of(context).size.width * 0.15,
),
SizedBox(width: 10),
InkWell(
onTap: _exportRetentionRateExcel,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFF2E7D6E),
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
children: [
Icon(
Icons.file_download_outlined,
color: Colors.white,
size: 16,
),
SizedBox(width: 6),
Text(
'Export Rentation Rate',
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
SizedBox(width: 10),
InkWell(
onTap: isRetentionImportLoading
? null
: _importRetentionRateExcel,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFF2E7D6E),
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
children: [
isRetentionImportLoading
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
Icons.file_upload_outlined,
color: Colors.white,
size: 16,
),
SizedBox(width: 6),
Text(
'Import Rentation Rate',
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
SizedBox(width: 10),
InkWell(
onTap: () {
@ -317,7 +618,6 @@ class AgentListState extends ConsumerState<AgentList> {
'Email',
'Sales Executive Name',
'Phone Number',
'Retention Rate',
'Address',
'Status',
],
@ -328,7 +628,6 @@ class AgentListState extends ConsumerState<AgentList> {
"email",
"sales_executive_name",
"mobile",
"retention_rate",
"address",
"is_active",
],
@ -414,13 +713,6 @@ class AgentListState extends ConsumerState<AgentList> {
flex: 2,
child: Text('Sales Executive Name', style: _headerStyle),
),
Expanded(
flex: 1,
child: Text(
'Retention Rate ',
style: _headerStyle,
),
),
Expanded(
flex: 2,
child: Text('Address', style: _headerStyle),
@ -542,10 +834,6 @@ class AgentListState extends ConsumerState<AgentList> {
flex: 2,
child: Text(item['sales_executive_name'] ?? '-', style: _dataBold),
),
Expanded(
flex: 1,
child: Text(item['retention_rate'] ?? '-', style: _dataBold),
),
Expanded(
flex: 2,
child: Text(

View File

@ -15,8 +15,8 @@
the `--base-href` argument provided to `flutter build`.
-->
<!-- <base href="$FLUTTER_BASE_HREF"> -->
<!-- <base href="/partner/"> --> <!-- Live build: also check env.dart (line 8) -->
<base href="/nhance/partner/app/">
<!-- <base href="/partner/"> --> <!-- Live build: also check env.dart (line 8) -->
<base href="/nhance/partner/app/">
<!-- <base href="{Env.baseHref}">-->
<meta charset="UTF-8">