chitfund/luckychit/lib/interfaces/manager/transaction_sync_dialog.dart

752 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../core/services/api_service.dart';
/// Transaction Sync Dialog
/// Shows PhonePe transactions and suggests payment matches
class TransactionSyncDialog extends StatefulWidget {
const TransactionSyncDialog({super.key});
@override
State<TransactionSyncDialog> createState() => _TransactionSyncDialogState();
}
class _TransactionSyncDialogState extends State<TransactionSyncDialog> {
final _apiService = ApiService();
bool _isSyncing = false;
bool _isLoadingReview = false;
Map<String, dynamic>? _syncResults;
List<dynamic> _reviewQueue = [];
int _daysBack = 7;
@override
void initState() {
super.initState();
_loadReviewQueue();
}
Future<void> _loadReviewQueue() async {
setState(() {
_isLoadingReview = true;
});
try {
final response = await _apiService.get('/transaction-sync/review-queue');
if (response['success'] == true) {
setState(() {
_reviewQueue = response['data']['review_queue'] ?? [];
_isLoadingReview = false;
});
} else {
setState(() {
_isLoadingReview = false;
});
}
} catch (e) {
setState(() {
_isLoadingReview = false;
});
Get.snackbar(
'Error',
'Failed to load review queue: $e',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
);
}
}
Future<void> _syncTransactions() async {
setState(() {
_isSyncing = true;
_syncResults = null;
});
try {
final response = await _apiService.post(
'/transaction-sync/sync',
{'days_back': _daysBack},
);
if (response['success'] == true) {
setState(() {
_syncResults = response['data'];
_isSyncing = false;
});
Get.snackbar(
'Sync Complete!',
'${response['data']['autoRecorded']} payments auto-recorded, '
'${response['data']['needsReview']} need review',
backgroundColor: Colors.green.shade100,
colorText: Colors.green.shade800,
icon: Icon(Icons.check_circle, color: Colors.green.shade600),
duration: Duration(seconds: 4),
);
// Reload review queue
await _loadReviewQueue();
} else {
setState(() {
_isSyncing = false;
});
Get.snackbar(
'Sync Failed',
response['message'] ?? 'Failed to sync transactions',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
);
}
} catch (e) {
setState(() {
_isSyncing = false;
});
Get.snackbar(
'Error',
'Failed to sync: $e',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
);
}
}
Future<void> _approveMatch(dynamic item) async {
final transaction = item['transaction'];
final match = item['suggestedMatch'];
try {
final txnDate = DateTime.parse(transaction['transactionDate']);
final response = await _apiService.post(
'/transaction-sync/approve',
{
'transaction_id': transaction['id'],
'group_id': match['group']['id'],
'user_id': match['member']['User']['id'],
'month': txnDate.month,
'year': txnDate.year,
'amount': transaction['amount'] / 100,
},
);
if (response['success'] == true) {
Get.snackbar(
'Approved!',
'Payment recorded successfully',
backgroundColor: Colors.green.shade100,
colorText: Colors.green.shade800,
icon: Icon(Icons.check_circle, color: Colors.green.shade600),
);
// Remove from review queue
setState(() {
_reviewQueue.remove(item);
});
} else {
Get.snackbar(
'Failed',
response['message'] ?? 'Could not approve match',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
);
}
} catch (e) {
Get.snackbar(
'Error',
'Failed to approve: $e',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
);
}
}
Future<void> _rejectMatch(dynamic item) async {
final transaction = item['transaction'];
try {
await _apiService.post(
'/transaction-sync/reject',
{
'transaction_id': transaction['id'],
'reason': 'Manager rejected',
},
);
// Remove from review queue
setState(() {
_reviewQueue.remove(item);
});
Get.snackbar(
'Rejected',
'Match rejected',
backgroundColor: Colors.grey.shade100,
colorText: Colors.grey.shade800,
);
} catch (e) {
Get.snackbar(
'Error',
'Failed to reject: $e',
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade800,
);
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
child: Container(
width: double.infinity,
constraints: BoxConstraints(
maxWidth: 600.w,
maxHeight: 0.85.sh,
),
child: Column(
children: [
// Header
Container(
padding: EdgeInsets.all(20.w),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.blue.shade600,
Colors.blue.shade700,
],
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.r),
topRight: Radius.circular(16.r),
),
),
child: Row(
children: [
Container(
padding: EdgeInsets.all(8.w),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
Icons.sync,
color: Colors.white,
size: 24.w,
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Auto-Sync Payments',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
Text(
'Import PhonePe transactions',
style: TextStyle(
fontSize: 14.sp,
color: Colors.white.withOpacity(0.9),
),
),
],
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.close, color: Colors.white, size: 24.w),
),
],
),
),
// Content
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.all(20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sync Controls
_buildSyncControls(),
if (_syncResults != null) ...[
SizedBox(height: 20.h),
_buildSyncResults(),
],
if (_reviewQueue.isNotEmpty) ...[
SizedBox(height: 24.h),
_buildReviewQueueSection(),
],
if (_reviewQueue.isEmpty && !_isLoadingReview && _syncResults == null) ...[
SizedBox(height: 20.h),
_buildEmptyState(),
],
],
),
),
),
],
),
),
);
}
Widget _buildSyncControls() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.cloud_download, color: Colors.blue.shade700, size: 20.w),
SizedBox(width: 8.w),
Text(
'Pull Transactions from PhonePe',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.blue.shade900,
),
),
],
),
SizedBox(height: 12.h),
// Days selection
Row(
children: [
Text(
'Last',
style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade700),
),
SizedBox(width: 8.w),
DropdownButton<int>(
value: _daysBack,
items: [7, 14, 30, 60, 90].map((days) {
return DropdownMenuItem<int>(
value: days,
child: Text('$days days', style: TextStyle(fontSize: 14.sp)),
);
}).toList(),
onChanged: (value) {
setState(() {
_daysBack = value!;
});
},
),
],
),
SizedBox(height: 16.h),
// Sync button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isSyncing ? null : _syncTransactions,
icon: _isSyncing
? SizedBox(
width: 20.w,
height: 20.h,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Icon(Icons.sync, size: 20.w),
label: Text(
_isSyncing ? 'Syncing...' : 'Sync Transactions',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade600,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 14.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
),
),
SizedBox(height: 8.h),
Text(
'• Auto-matches by phone number, name, and amount\n'
'• High-confidence matches auto-recorded\n'
'• Low-confidence matches need your review',
style: TextStyle(
fontSize: 12.sp,
color: Colors.blue.shade700,
height: 1.5,
),
),
],
),
);
}
Widget _buildSyncResults() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: Colors.green.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Colors.green.shade700, size: 20.w),
SizedBox(width: 8.w),
Text(
'Sync Results',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.green.shade900,
),
),
],
),
SizedBox(height: 12.h),
_buildResultRow('Total Transactions', _syncResults!['total'].toString()),
_buildResultRow('Auto-Recorded', _syncResults!['autoRecorded'].toString(),
color: Colors.green.shade700),
_buildResultRow('Need Review', _syncResults!['needsReview'].toString(),
color: Colors.orange.shade700),
_buildResultRow('Could Not Match', _syncResults!['failed'].toString(),
color: Colors.red.shade700),
],
),
);
}
Widget _buildResultRow(String label, String value, {Color? color}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade700),
),
Text(
value,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
color: color ?? Colors.grey.shade900,
),
),
],
),
);
}
Widget _buildReviewQueueSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Review Queue',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'${_reviewQueue.length}',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
color: Colors.orange.shade800,
),
),
),
],
),
SizedBox(height: 12.h),
Text(
'These transactions need your confirmation:',
style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade600),
),
SizedBox(height: 12.h),
..._reviewQueue.map((item) => _buildReviewCard(item)),
],
);
}
Widget _buildReviewCard(dynamic item) {
final transaction = item['transaction'];
final match = item['suggestedMatch'];
final amount = (transaction['amount'] / 100).toStringAsFixed(2);
final payerName = transaction['payerName'] ?? 'Unknown';
final payerPhone = transaction['payerPhone'] ?? '';
final date = DateTime.parse(transaction['transactionDate']);
final confidence = match['confidence'];
final matchedBy = match['matchedBy'];
final groupName = match['group']['name'];
final memberName = match['member']?['User']?['full_name'] ?? 'Unknown';
final memberPhone = match['member']?['User']?['mobile_number'] ?? '';
return Container(
margin: EdgeInsets.only(bottom: 12.h),
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: Colors.orange.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4.r,
offset: Offset(0, 2.h),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Transaction Details
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.account_balance_wallet,
size: 18.w, color: Colors.blue.shade600),
SizedBox(width: 6.w),
Text(
'$amount',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w800,
color: Colors.blue.shade700,
),
),
],
),
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h),
decoration: BoxDecoration(
color: _getConfidenceColor(confidence).withOpacity(0.1),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: _getConfidenceColor(confidence)),
),
child: Text(
confidence.toUpperCase().replaceAll('_', ' '),
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w600,
color: _getConfidenceColor(confidence),
),
),
),
],
),
SizedBox(height: 8.h),
// Payer info
Row(
children: [
Icon(Icons.person, size: 14.w, color: Colors.grey.shade600),
SizedBox(width: 6.w),
Expanded(
child: Text(
'$payerName ${payerPhone.isNotEmpty ? "$payerPhone" : ""}',
style: TextStyle(fontSize: 13.sp, color: Colors.grey.shade700),
overflow: TextOverflow.ellipsis,
),
),
],
),
SizedBox(height: 4.h),
Row(
children: [
Icon(Icons.calendar_today, size: 14.w, color: Colors.grey.shade600),
SizedBox(width: 6.w),
Text(
'${date.day}/${date.month}/${date.year}',
style: TextStyle(fontSize: 13.sp, color: Colors.grey.shade700),
),
],
),
SizedBox(height: 12.h),
Divider(height: 1, color: Colors.grey.shade300),
SizedBox(height: 12.h),
// Suggested Match
Row(
children: [
Icon(Icons.auto_fix_high, size: 16.w, color: Colors.orange.shade600),
SizedBox(width: 6.w),
Text(
'Suggested Match:',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
],
),
SizedBox(height: 8.h),
Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
groupName,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
color: Colors.grey.shade900,
),
),
SizedBox(height: 6.h),
Text(
'$memberName ${memberPhone.isNotEmpty ? "$memberPhone" : ""}',
style: TextStyle(fontSize: 13.sp, color: Colors.grey.shade700),
),
SizedBox(height: 6.h),
Text(
'Matched by: $matchedBy',
style: TextStyle(
fontSize: 11.sp,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
),
SizedBox(height: 16.h),
// Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _rejectMatch(item),
icon: Icon(Icons.close, size: 18.w),
label: Text('Reject', style: TextStyle(fontSize: 14.sp)),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade600,
side: BorderSide(color: Colors.red.shade600),
padding: EdgeInsets.symmetric(vertical: 10.h),
),
),
),
SizedBox(width: 12.w),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: () => _approveMatch(item),
icon: Icon(Icons.check, size: 18.w),
label: Text('Approve & Record', style: TextStyle(fontSize: 14.sp)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 10.h),
),
),
),
],
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: EdgeInsets.all(32.w),
child: Column(
children: [
Icon(
Icons.check_circle_outline,
size: 64.w,
color: Colors.green.shade400,
),
SizedBox(height: 16.h),
Text(
'All Caught Up!',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
color: Colors.green.shade700,
),
),
SizedBox(height: 8.h),
Text(
'No transactions need review.\nTap "Sync Transactions" to check for new payments.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey.shade600,
),
),
],
),
),
);
}
Color _getConfidenceColor(String confidence) {
switch (confidence) {
case 'very_high':
return Colors.green.shade600;
case 'high':
return Colors.lightGreen.shade600;
case 'medium':
return Colors.orange.shade600;
case 'low':
return Colors.red.shade600;
default:
return Colors.grey.shade600;
}
}
}