nhance_partner/lib/presentation/screens/payout/payout_upload.dart

721 lines
24 KiB
Dart

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(),
),
],
),
),
);
}
}