payout reports and payout upload
This commit is contained in:
parent
9a6685382a
commit
c121c127df
File diff suppressed because one or more lines are too long
@ -37,6 +37,8 @@ import '../../presentation/screens/login/login_screen.dart';
|
||||
import '../../presentation/screens/payout/payout_screen.dart';
|
||||
import '../../presentation/screens/payout/payout_details.dart';
|
||||
import '../../presentation/screens/payout/payout_list.dart';
|
||||
import '../../presentation/screens/payout/payout_upload.dart';
|
||||
import '../../presentation/screens/payout/payout_report.dart';
|
||||
import '../../presentation/screens/splash/splash_screen.dart';
|
||||
import '../../presentation/screens/staff/Enquiry/enquiry_inline_Edit_17Nov.dart';
|
||||
import '../../presentation/screens/staff/Enquiry/enquiry_inline_list.dart';
|
||||
@ -102,6 +104,14 @@ final GoRouter appRouter = GoRouter(
|
||||
return PayOutDetails(editItem: editItem);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.payoutUpload,
|
||||
builder: (context, state) => const PayoutUploadScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.payoutReport,
|
||||
builder: (context, state) => const PayoutReportScreen(),
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: AppRoutes.profile,
|
||||
|
||||
@ -52,6 +52,9 @@ class AppRoutes {
|
||||
static const String vehicleTypeLst = '/vehicleTypeLst';
|
||||
static const String payoutList = '/payoutList';
|
||||
static const String payoutDetails = '/payoutDetails';
|
||||
static const String payoutUpload = '/payoutUpload';
|
||||
/// Accounts + agent: commission payout report (list API TBD).
|
||||
static const String payoutReport = '/payoutReport';
|
||||
static const String payoutGrid = '/payoutGrid';
|
||||
static const String gridList = '/gridList';
|
||||
static const String gridUpload = '/gridUpload';
|
||||
|
||||
@ -2050,7 +2050,7 @@ class ApiService {
|
||||
// --------------------------------- PayOut Module----------------------------------------------
|
||||
Future<Map<String, dynamic>> getCommissionRateList(data) async {
|
||||
print("getCommissionRateList------- $data}");
|
||||
// final url = Uri.parse('${Env.apiUrl}/invoice/commission-rate-list');
|
||||
final url = Uri.parse('${Env.apiUrl}/invoice/commission-rate-list');
|
||||
// final url = Uri.parse('http://localhost/nhance_partner_be/invoice/commission-rate-list');
|
||||
print("getCommissionRateList 1");
|
||||
// final token = await getToken(); // Fetch token
|
||||
@ -2350,7 +2350,6 @@ class ApiService {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> uploadPolicyCommissionExcel({
|
||||
required dynamic id,
|
||||
required PlatformFile file,
|
||||
bool proceedPartnerMismatch = false,
|
||||
}) async {
|
||||
@ -2370,12 +2369,11 @@ class ApiService {
|
||||
|
||||
request.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
'commission_excel',
|
||||
file.bytes!,
|
||||
filename: file.name,
|
||||
),
|
||||
);
|
||||
request.fields['id'] = id.toString();
|
||||
if (proceedPartnerMismatch) {
|
||||
request.fields['proceed_partner_mismatch'] = '1';
|
||||
}
|
||||
@ -2390,9 +2388,21 @@ class ApiService {
|
||||
}
|
||||
|
||||
if (streamedResponse.statusCode != 200) {
|
||||
try {
|
||||
final decoded = jsonDecode(responseBody);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return {
|
||||
...decoded,
|
||||
if (!decoded.containsKey('code')) 'code': streamedResponse.statusCode,
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Server Error: ${streamedResponse.statusCode}',
|
||||
'status': 'failed',
|
||||
'code': streamedResponse.statusCode,
|
||||
'message': responseBody.isNotEmpty
|
||||
? responseBody
|
||||
: 'Server Error: ${streamedResponse.statusCode}',
|
||||
};
|
||||
}
|
||||
|
||||
@ -2613,6 +2623,52 @@ class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Static sample Excel for commission upload (template).
|
||||
Future<void> downloadCommissionUploadSampleExcel() async {
|
||||
await getPdfDownload(
|
||||
'policy/downloadCommissionUploadSampleExcel',
|
||||
'commission_upload_sample',
|
||||
);
|
||||
}
|
||||
|
||||
/// GET `policy/commissionPayoutReport` — optional query: `from_date`, `to_date` (yyyy-MM-dd), `agent_id`, `payout_raised` (`all` / `yes` / `no`).
|
||||
Future<Map<String, dynamic>> fetchPayoutReportList({
|
||||
String? fromDate,
|
||||
String? toDate,
|
||||
String? agentId,
|
||||
String? payoutRaised,
|
||||
}) async {
|
||||
if (_token == null) {
|
||||
await _initializeToken();
|
||||
}
|
||||
|
||||
final query = <String, String>{};
|
||||
if (fromDate != null && fromDate.isNotEmpty) {
|
||||
query['from_date'] = fromDate;
|
||||
}
|
||||
if (toDate != null && toDate.isNotEmpty) {
|
||||
query['to_date'] = toDate;
|
||||
}
|
||||
if (agentId != null && agentId.trim().isNotEmpty) {
|
||||
query['agent_id'] = agentId.trim();
|
||||
}
|
||||
if (payoutRaised != null && payoutRaised.isNotEmpty) {
|
||||
query['payout_raised'] = payoutRaised;
|
||||
}
|
||||
|
||||
final url = Uri.parse('${Env.apiUrl}policy/commissionPayoutReport').replace(
|
||||
queryParameters: query.isEmpty ? null : query,
|
||||
);
|
||||
|
||||
final headers = {
|
||||
'Authorization': 'Bearer $_token',
|
||||
'app-signature': Env.App_Signature,
|
||||
};
|
||||
|
||||
final response = await _makeGetRequest(url, headers);
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<void> generatePolicyExcel(managerId, fromDate, toDate, searchValue,flag,insurer) async {
|
||||
final url = Uri.parse(
|
||||
'${Env.apiUrl}reports/policy-excel?manager_id=$managerId&from_date=${fromDate ?? ''}&to_date=${toDate ?? ''}&search=${searchValue ?? ''}&show_policy_report=${flag ?? ''}&insurer_id=${insurer ?? ''}',
|
||||
|
||||
@ -105,6 +105,12 @@ class _AppHeaderState extends ConsumerState<AppHeader> {
|
||||
if (route.contains('payoutgrid')) {
|
||||
return 'PayoutGrid';
|
||||
}
|
||||
if (route.contains('payoutupload')) {
|
||||
return 'PayoutUpload';
|
||||
}
|
||||
if (route.contains('payoutreport')) {
|
||||
return 'PayoutReport';
|
||||
}
|
||||
if (route.contains('invoice') ||
|
||||
(route.contains('payout') && !route.contains('payoutgrid'))) {
|
||||
return 'Payout';
|
||||
@ -509,6 +515,43 @@ class _AppHeaderState extends ConsumerState<AppHeader> {
|
||||
context.go(AppRoutes.payoutList);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildMenuItem(
|
||||
icon: Icons.upload_file_outlined,
|
||||
label: "Payout Upload",
|
||||
isActive: activeMenu == 'PayoutUpload',
|
||||
onTap: () {
|
||||
_hidePopup();
|
||||
setState(() => _activeMenu = 'PayoutUpload');
|
||||
context.go(AppRoutes.payoutUpload);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildMenuItem(
|
||||
icon: Icons.table_chart_outlined,
|
||||
label: "Payout Report",
|
||||
isActive: activeMenu == 'PayoutReport',
|
||||
onTap: () {
|
||||
_hidePopup();
|
||||
setState(() => _activeMenu = 'PayoutReport');
|
||||
context.go(AppRoutes.payoutReport);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Partner (agent): Payout Report — same route as Accounts
|
||||
if (role == 'agent') ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildMenuItem(
|
||||
icon: Icons.table_chart_outlined,
|
||||
label: "Payout Report",
|
||||
isActive: activeMenu == 'PayoutReport',
|
||||
onTap: () {
|
||||
_hidePopup();
|
||||
setState(() => _activeMenu = 'PayoutReport');
|
||||
context.go(AppRoutes.payoutReport);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(),
|
||||
|
||||
@ -1028,7 +1028,7 @@ class _PayOutDetailsState extends ConsumerState<PayOutDetails> {
|
||||
}
|
||||
|
||||
final response = await apiService.uploadPolicyCommissionExcel(
|
||||
id: selectedAgentId!.first,
|
||||
// id: selectedAgentId!.first,
|
||||
file: selectedFile,
|
||||
proceedPartnerMismatch: partnerMismatchCount > 0,
|
||||
);
|
||||
|
||||
1214
lib/presentation/screens/payout/payout_report.dart
Normal file
1214
lib/presentation/screens/payout/payout_report.dart
Normal file
File diff suppressed because it is too large
Load Diff
720
lib/presentation/screens/payout/payout_upload.dart
Normal file
720
lib/presentation/screens/payout/payout_upload.dart
Normal file
@ -0,0 +1,720 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:nhance_partner/data/utils/toastNotification.dart';
|
||||
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../layouts/main_layout.dart';
|
||||
|
||||
class PayoutUploadScreen extends ConsumerStatefulWidget {
|
||||
const PayoutUploadScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PayoutUploadScreen> createState() => _PayoutUploadScreenState();
|
||||
}
|
||||
|
||||
class _PayoutUploadScreenState extends ConsumerState<PayoutUploadScreen> {
|
||||
late ApiService apiService;
|
||||
bool isLoading = false;
|
||||
String? uploadedFileName;
|
||||
List<Map<String, dynamic>> failedRows = [];
|
||||
/// Present when upload succeeded but `data.errors` was non-empty (partial success).
|
||||
Map<String, dynamic>? partialSuccessData;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
apiService = ApiService();
|
||||
}
|
||||
|
||||
void _resetAll() {
|
||||
setState(() {
|
||||
uploadedFileName = null;
|
||||
failedRows = [];
|
||||
partialSuccessData = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Parses `data.errors` (success-with-errors) or top-level error arrays.
|
||||
List<Map<String, dynamic>> _extractFailedRows(Map<String, dynamic> response) {
|
||||
final dynamic data = response['data'];
|
||||
if (data is Map) {
|
||||
final err = data['errors'];
|
||||
if (err is List) {
|
||||
return err.map((e) {
|
||||
if (e is Map<String, dynamic>) return Map<String, dynamic>.from(e);
|
||||
if (e is Map) return Map<String, dynamic>.from(e);
|
||||
return <String, dynamic>{'message': e.toString()};
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
final top = response['errors'] ?? response['failed_rows'];
|
||||
if (top is List) {
|
||||
return top.map((e) {
|
||||
if (e is Map<String, dynamic>) return Map<String, dynamic>.from(e);
|
||||
if (e is Map) return Map<String, dynamic>.from(e);
|
||||
return <String, dynamic>{'message': e.toString()};
|
||||
}).toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
String _failureMessage(Map<String, dynamic> response) {
|
||||
final m = response['message']?.toString().trim();
|
||||
if (m != null && m.isNotEmpty) return m;
|
||||
final data = response['data'];
|
||||
if (data is String && data.trim().isNotEmpty) return data.trim();
|
||||
return 'Upload failed';
|
||||
}
|
||||
|
||||
String _successSummary(Map<String, dynamic> data) {
|
||||
final updated = data['updated_rows'];
|
||||
final skipped = data['skipped_empty_payout'];
|
||||
final parts = <String>[];
|
||||
if (updated != null) parts.add('Updated $updated row(s)');
|
||||
if (skipped != null) parts.add('Skipped empty payout: $skipped');
|
||||
return parts.isEmpty ? 'Upload successful' : parts.join(' · ');
|
||||
}
|
||||
|
||||
String _partialSummary(Map<String, dynamic> data) {
|
||||
final base = _successSummary(data);
|
||||
final ec = data['error_count'];
|
||||
if (ec != null) return '$base · $ec row(s) with errors';
|
||||
return base;
|
||||
}
|
||||
|
||||
static const _errorIcon = Icon(Icons.error_outline, color: Colors.red, size: 16);
|
||||
|
||||
Widget _messageWithLeadingIcon(String text) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: _errorIcon,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: GoogleFonts.inter(fontSize: 12, height: 1.35),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleUploadResponse(Map<String, dynamic> response) {
|
||||
final status = response['status']?.toString();
|
||||
final code = response['code'];
|
||||
|
||||
if (status == 'error' &&
|
||||
(response['message']?.toString().toLowerCase().contains('session') ?? false)) {
|
||||
ToastHelper.showErrorToast(context, response['message']?.toString() ?? 'Session expired');
|
||||
return;
|
||||
}
|
||||
|
||||
final isHttpSuccess =
|
||||
status == 'success' ||
|
||||
status == '200' ||
|
||||
code == 200 ||
|
||||
code == '200';
|
||||
|
||||
if (isHttpSuccess) {
|
||||
final data = response['data'];
|
||||
if (data is Map) {
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
final errors = map['errors'];
|
||||
if (errors is List && errors.isNotEmpty) {
|
||||
setState(() {
|
||||
partialSuccessData = map;
|
||||
failedRows = errors.map((e) {
|
||||
if (e is Map<String, dynamic>) return Map<String, dynamic>.from(e);
|
||||
if (e is Map) return Map<String, dynamic>.from(e);
|
||||
return <String, dynamic>{'message': e.toString()};
|
||||
}).toList();
|
||||
});
|
||||
ToastHelper.showWarningToast(context, _partialSummary(map));
|
||||
return;
|
||||
}
|
||||
ToastHelper.showSuccessToast(context, _successSummary(map));
|
||||
_resetAll();
|
||||
return;
|
||||
}
|
||||
ToastHelper.showSuccessToast(
|
||||
context,
|
||||
response['message']?.toString() ?? 'Upload successful',
|
||||
);
|
||||
_resetAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == 'failed' || status == 'error' || !isHttpSuccess) {
|
||||
final rows = _extractFailedRows(response);
|
||||
ToastHelper.showErrorToast(context, _failureMessage(response));
|
||||
if (rows.isNotEmpty) {
|
||||
setState(() {
|
||||
failedRows = rows;
|
||||
partialSuccessData = null;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ToastHelper.showErrorToast(context, 'Unexpected response');
|
||||
}
|
||||
|
||||
Future<void> _uploadFile() async {
|
||||
final picked = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xlsx', 'xls'],
|
||||
withData: true,
|
||||
);
|
||||
if (picked == null || picked.files.isEmpty) return;
|
||||
|
||||
final file = picked.files.single;
|
||||
if (file.bytes == null || file.bytes!.isEmpty) {
|
||||
ToastHelper.showWarningToast(context, 'Unable to read selected file');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
uploadedFileName = file.name;
|
||||
});
|
||||
try {
|
||||
final response = await apiService.uploadPolicyCommissionExcel(
|
||||
file: file,
|
||||
);
|
||||
_handleUploadResponse(response);
|
||||
} catch (e) {
|
||||
ToastHelper.showErrorToast(context, e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isStandardErrorShape {
|
||||
if (failedRows.isEmpty) return false;
|
||||
for (final r in failedRows) {
|
||||
if (!r.containsKey('row') && !r.containsKey('policy_number')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
List<String> _visibleColumns() {
|
||||
if (failedRows.isEmpty) return [];
|
||||
const preferred = ['row', 'policy_number', 'agent_code', 'expected_agent_code', 'message'];
|
||||
const hidden = <String>{};
|
||||
final keySet = <String>{};
|
||||
for (final row in failedRows) {
|
||||
keySet.addAll(row.keys);
|
||||
}
|
||||
final ordered = <String>[];
|
||||
for (final p in preferred) {
|
||||
if (keySet.contains(p) && !hidden.contains(p)) ordered.add(p);
|
||||
}
|
||||
for (final k in keySet) {
|
||||
if (!ordered.contains(k) && !hidden.contains(k)) ordered.add(k);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
Widget _buildPartialSummaryBanner() {
|
||||
final d = partialSuccessData;
|
||||
if (d == null) return const SizedBox.shrink();
|
||||
final updated = d['updated_rows'];
|
||||
final skipped = d['skipped_empty_payout'];
|
||||
final errCount = d['error_count'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (updated != null)
|
||||
_summaryChip('Updated rows', updated.toString(), const Color(0xFF059669)),
|
||||
if (skipped != null)
|
||||
_summaryChip('Skipped empty payout', skipped.toString(), const Color(0xFF64748B)),
|
||||
if (errCount != null)
|
||||
_summaryChip('Errors', errCount.toString(), const Color(0xFFDC2626)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryChip(String label, String value, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.35)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$label: ',
|
||||
style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF475569)),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w700, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorsTable() {
|
||||
if (_isStandardErrorShape) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTableHeaderRow(isStandard: true),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: failedRows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = failedRows[index];
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFE5E7EB)),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(
|
||||
'${row['row'] ?? '-'}',
|
||||
style: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Text(
|
||||
(row['policy_number'] ?? '-').toString(),
|
||||
style: GoogleFonts.inter(fontSize: 12),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
(row['agent_code'] ?? '-').toString().isEmpty
|
||||
? '—'
|
||||
: (row['agent_code'] ?? '').toString(),
|
||||
style: GoogleFonts.inter(fontSize: 12),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
row['expected_agent_code'] != null &&
|
||||
row['expected_agent_code'].toString().trim().isNotEmpty
|
||||
? row['expected_agent_code'].toString()
|
||||
: '—',
|
||||
style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _messageWithLeadingIcon((row['message'] ?? '-').toString()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final columns = _visibleColumns();
|
||||
return Column(
|
||||
children: [
|
||||
_buildTableHeaderRow(isStandard: false, columns: columns),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: failedRows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = failedRows[index];
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFE5E7EB)),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final c in columns)
|
||||
if (c == 'message')
|
||||
Expanded(
|
||||
child: _messageWithLeadingIcon((row[c] ?? '-').toString()),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
(row[c] ?? '-').toString(),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.inter(fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTableHeaderRow({required bool isStandard, List<String>? columns}) {
|
||||
TextStyle h() => GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF334155),
|
||||
letterSpacing: 0.3,
|
||||
);
|
||||
if (isStandard) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF1F5F9),
|
||||
border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 52, child: Text('ROW', style: h())),
|
||||
SizedBox(width: 160, child: Text('POLICY NUMBER', style: h())),
|
||||
SizedBox(width: 100, child: Text('AGENT CODE', style: h())),
|
||||
SizedBox(width: 120, child: Text('EXPECTED', style: h())),
|
||||
Expanded(child: Text('MESSAGE', style: h())),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final cols = columns ?? [];
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF1F5F9),
|
||||
border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final c in cols)
|
||||
if (c == 'message')
|
||||
Expanded(
|
||||
child: Text(
|
||||
c.toUpperCase().replaceAll('_', ' '),
|
||||
style: h(),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
c.toUpperCase().replaceAll('_', ' '),
|
||||
style: h(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onTemplateFileTap() async {
|
||||
try {
|
||||
await apiService.downloadCommissionUploadSampleExcel();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ToastHelper.showErrorToast(
|
||||
context,
|
||||
'Could not download template. Try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTemplateDownloadHint() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Please download the sample file to review the format.',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: () => _onTemplateFileTap(),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
'Template File',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF2E7D6E),
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: const Color(0xFF2E7D6E),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadCard() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: isLoading ? null : _uploadFile,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 1050),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 26),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF3AA69A), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE6F6F4),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFF7BCBC3)),
|
||||
),
|
||||
child: isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF2E7D6E)),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.upload_file_rounded,
|
||||
size: 16,
|
||||
color: Color(0xFF2E7D6E),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
isLoading ? 'Uploading file...' : 'Upload Your Payout Documents',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'(Supported Format: XLSX, XLS)',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Click here to choose an Excel file and upload.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: const Color(0xFF4B5563),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1050),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildTemplateDownloadHint(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFailureGrid() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
uploadedFileName == null
|
||||
? 'Validation errors'
|
||||
: 'File: $uploadedFileName',
|
||||
style: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: isLoading ? null : _resetAll,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Re-upload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildPartialSummaryBanner(),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Error rows (${failedRows.length})',
|
||||
style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 720 && _isStandardErrorShape) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: failedRows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final row = failedRows[index];
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: const Border(
|
||||
bottom: BorderSide(color: Color(0xFFE5E7EB)),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Row ${row['row'] ?? '-'}',
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_mobileLine('Policy', row['policy_number']),
|
||||
_mobileLine('Agent code', row['agent_code']),
|
||||
if (row['expected_agent_code'] != null &&
|
||||
row['expected_agent_code'].toString().trim().isNotEmpty)
|
||||
_mobileLine('Expected', row['expected_agent_code']),
|
||||
const SizedBox(height: 6),
|
||||
_messageWithLeadingIcon((row['message'] ?? '').toString()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildErrorsTable();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mobileLine(String label, dynamic value) {
|
||||
final v = value?.toString().trim() ?? '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF64748B)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
v.isEmpty ? '—' : v,
|
||||
style: GoogleFonts.inter(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MainLayout(
|
||||
title: 'Payout Upload',
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Payout Upload',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: failedRows.isEmpty
|
||||
? Center(child: _buildUploadCard())
|
||||
: _buildFailureGrid(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -205,6 +205,39 @@ class DrawerMenuState extends ConsumerState<DrawerMenu> {
|
||||
context.go(AppRoutes.allStaffAttendance);
|
||||
},
|
||||
),
|
||||
|
||||
if (roleId == 'Accounts') ...[
|
||||
_buildMenuItem(
|
||||
context: context,
|
||||
icon: const Icon(Icons.payments_outlined, size: 30, color: Colors.black),
|
||||
label: "Payout",
|
||||
isActive: currentPath == AppRoutes.payoutList,
|
||||
onTap: () => context.go(AppRoutes.payoutList),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context: context,
|
||||
icon: const Icon(Icons.upload_file_outlined, size: 30, color: Colors.black),
|
||||
label: "Payout Upload",
|
||||
isActive: currentPath == AppRoutes.payoutUpload,
|
||||
onTap: () => context.go(AppRoutes.payoutUpload),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context: context,
|
||||
icon: const Icon(Icons.table_chart_outlined, size: 30, color: Colors.black),
|
||||
label: "Payout Report",
|
||||
isActive: currentPath == AppRoutes.payoutReport,
|
||||
onTap: () => context.go(AppRoutes.payoutReport),
|
||||
),
|
||||
],
|
||||
|
||||
if (roleId == 'agent')
|
||||
_buildMenuItem(
|
||||
context: context,
|
||||
icon: const Icon(Icons.table_chart_outlined, size: 30, color: Colors.black),
|
||||
label: "Payout Report",
|
||||
isActive: currentPath == AppRoutes.payoutReport,
|
||||
onTap: () => context.go(AppRoutes.payoutReport),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user