create order -> upload bill

This commit is contained in:
Phuoc Nguyen
2025-11-24 14:53:48 +07:00
parent 42d91a5a99
commit 354df3ad01
8 changed files with 545 additions and 71 deletions

View File

@@ -116,3 +116,24 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
} }
} }
} }
#upload bill
curl --location 'https://land.dbiz.com//api/method/upload_file' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--form 'file=@"/C:/Users/tiennld/Downloads/logo_crm.png"' \
--form 'is_private="1"' \
--form 'folder="Home/Attachments"' \
--form 'doctype="Sales Order"' \
--form 'docname="SAL-ORD-2025-00058-1"' \
--form 'optimize="true"'
#order detail
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.sales_order.get_detail' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"name" : "SAL-ORD-2025-00058-1"
}'

View File

@@ -12,9 +12,11 @@
<div class="page-wrapper"> <div class="page-wrapper">
<!-- Header --> <!-- Header -->
<div class="header"> <div class="header">
<a href="checkout.html" class="back-button"> <!--<a href="checkout.html" class="back-button">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</a> </a>-->
<div style="width: 32px;"></div>
<h1 class="header-title">Thanh toán</h1> <h1 class="header-title">Thanh toán</h1>
<button class="back-button" onclick="openInfoModal()"> <button class="back-button" onclick="openInfoModal()">
<i class="fas fa-info-circle"></i> <i class="fas fa-info-circle"></i>

View File

@@ -227,6 +227,12 @@ class ApiConstants {
/// Returns: { "message": { "qr_code": "...", "amount": null, "transaction_id": "...", "bank_info": {...} } } /// Returns: { "message": { "qr_code": "...", "amount": null, "transaction_id": "...", "bank_info": {...} } }
static const String generateQrCode = '/building_material.building_material.api.v1.qrcode.generate'; static const String generateQrCode = '/building_material.building_material.api.v1.qrcode.generate';
/// Upload file (bill/invoice/attachment) (requires sid and csrf_token)
/// POST /api/method/upload_file
/// Form-data: { "file": File, "is_private": "1", "folder": "Home/Attachments", "doctype": "Sales Order", "docname": "SAL-ORD-2025-00058-1", "optimize": "true" }
/// Returns: { "message": { "file_url": "...", "file_name": "...", ... } }
static const String uploadFile = '/upload_file';
/// Get user's orders /// Get user's orders
/// GET /orders?status={status}&page={page}&limit={limit} /// GET /orders?status={status}&page={page}&limit={limit}
static const String getOrders = '/orders'; static const String getOrders = '/orders';

View File

@@ -59,6 +59,8 @@ class CheckoutPage extends HookConsumerWidget {
// Price negotiation // Price negotiation
final needsNegotiation = useState<bool>(false); final needsNegotiation = useState<bool>(false);
final needsContract = useState(false);
// Watch API provider for payment terms // Watch API provider for payment terms
final paymentTermsListAsync = ref.watch(paymentTermsListProvider); final paymentTermsListAsync = ref.watch(paymentTermsListProvider);
@@ -240,6 +242,40 @@ class CheckoutPage extends HookConsumerWidget {
// Price Negotiation Section // Price Negotiation Section
PriceNegotiationSection(needsNegotiation: needsNegotiation), PriceNegotiationSection(needsNegotiation: needsNegotiation),
const SizedBox(height: AppSpacing.md),
Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: const Color(0xFFFFD54F)),
),
child: Row(
children: [
Checkbox(
value: needsContract.value,
onChanged: (value) {
needsContract.value = value ?? false;
},
activeColor: AppColors.warning,
),
const Expanded(
child: Text(
'Yêu cầu hợp đồng',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
),
),
),
],
),
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),

View File

@@ -3,6 +3,7 @@
/// Handles API calls for order-related data. /// Handles API calls for order-related data.
library; library;
import 'package:dio/dio.dart';
import 'package:worker/core/constants/api_constants.dart'; import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/network/dio_client.dart'; import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/orders/data/models/order_status_model.dart'; import 'package:worker/features/orders/data/models/order_status_model.dart';
@@ -194,4 +195,56 @@ class OrderRemoteDataSource {
throw Exception('Failed to generate QR code: $e'); throw Exception('Failed to generate QR code: $e');
} }
} }
/// Upload bill/invoice file
///
/// Calls: POST /api/method/upload_file
/// Form-data: {
/// "file": File,
/// "is_private": "1",
/// "folder": "Home/Attachments",
/// "doctype": "Sales Order",
/// "docname": "SAL-ORD-2025-00058-1",
/// "optimize": "true"
/// }
/// Returns: { "message": { "file_url": "...", "file_name": "...", ... } }
Future<Map<String, dynamic>> uploadBill({
required String filePath,
required String orderId,
}) async {
try {
// Create multipart form data
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
filePath,
filename: filePath.split('/').last,
),
'is_private': '1',
'folder': 'Home/Attachments',
'doctype': 'Sales Order',
'docname': orderId,
'optimize': 'true',
});
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.uploadFile}',
data: formData,
);
final data = response.data;
if (data == null) {
throw Exception('No data received from uploadBill API');
}
// Extract file info from Frappe response
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message field in uploadBill response');
}
return message;
} catch (e) {
throw Exception('Failed to upload bill: $e');
}
}
} }

View File

@@ -65,4 +65,19 @@ class OrderRepositoryImpl implements OrderRepository {
throw Exception('Failed to generate QR code: $e'); throw Exception('Failed to generate QR code: $e');
} }
} }
@override
Future<Map<String, dynamic>> uploadBill({
required String filePath,
required String orderId,
}) async {
try {
return await _remoteDataSource.uploadBill(
filePath: filePath,
orderId: orderId,
);
} catch (e) {
throw Exception('Failed to upload bill: $e');
}
}
} }

View File

@@ -26,4 +26,10 @@ abstract class OrderRepository {
/// Generate QR code for payment /// Generate QR code for payment
Future<Map<String, dynamic>> generateQrCode(String orderId); Future<Map<String, dynamic>> generateQrCode(String orderId);
/// Upload bill/invoice file
Future<Map<String, dynamic>> uploadBill({
required String filePath,
required String orderId,
});
} }

View File

@@ -11,15 +11,18 @@
library; library;
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/orders/presentation/providers/order_repository_provider.dart'; import 'package:worker/features/orders/presentation/providers/order_repository_provider.dart';
@@ -47,6 +50,10 @@ class PaymentQrPage extends HookConsumerWidget {
final remainingSeconds = useState<int>(900); final remainingSeconds = useState<int>(900);
final timer = useRef<Timer?>(null); final timer = useRef<Timer?>(null);
// Upload state
final isUploadingBill = useState<bool>(false);
final selectedImagePath = useState<String?>(null);
// Fetch QR code data // Fetch QR code data
useEffect(() { useEffect(() {
Future<void> fetchQrCode() async { Future<void> fetchQrCode() async {
@@ -146,8 +153,27 @@ class PaymentQrPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
// Image Preview Section
_buildImagePreviewSection(
context,
selectedImagePath.value,
() async {
await _selectImage(context, selectedImagePath);
},
),
const SizedBox(height: AppSpacing.md),
// Action Buttons // Action Buttons
_buildActionButtons(context), _buildActionButtons(
context,
ref,
isUploadingBill.value,
selectedImagePath.value != null,
() async {
await _uploadBill(context, ref, selectedImagePath, isUploadingBill);
},
),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
@@ -452,44 +478,174 @@ class PaymentQrPage extends HookConsumerWidget {
); );
} }
/// Build image preview section
Widget _buildImagePreviewSection(
BuildContext context,
String? imagePath,
VoidCallback onSelectImage,
) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ảnh hóa đơn',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
),
const SizedBox(height: AppSpacing.md),
// Image preview or placeholder
InkWell(
onTap: onSelectImage,
borderRadius: BorderRadius.circular(AppRadius.card),
child: Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF4F6F8),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: const Color(0xFFE2E8F0),
width: 2,
style: BorderStyle.solid,
),
),
child: imagePath != null
? ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.card - 2),
child: Stack(
children: [
Image.file(
File(imagePath),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Positioned(
top: 8,
right: 8,
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
FaIcon(
FontAwesomeIcons.pen,
color: Colors.white,
size: 12,
),
SizedBox(width: 6),
Text(
'Đổi ảnh',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppColors.primaryBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
FontAwesomeIcons.image,
color: AppColors.primaryBlue,
size: 24,
),
),
const SizedBox(height: 12),
const Text(
'Chạm để chọn ảnh hóa đơn',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
const Text(
'Hỗ trợ: JPG, PNG',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
),
),
],
),
);
}
/// Build action buttons /// Build action buttons
Widget _buildActionButtons(BuildContext context) { Widget _buildActionButtons(
BuildContext context,
WidgetRef ref,
bool isUploading,
bool hasImage,
VoidCallback onUpload,
) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row( child: Column(
children: [ children: [
// Confirmed Payment Button // Upload Bill Button
Expanded( SizedBox(
child: OutlinedButton.icon( width: double.infinity,
onPressed: () => _confirmPayment(context),
icon: const FaIcon(FontAwesomeIcons.check, size: 18),
label: const Text(
'Đã thanh toán',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(
color: AppColors.primaryBlue,
width: 1.5,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
),
),
const SizedBox(width: AppSpacing.sm),
// Upload Proof Button
Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => _uploadProof(context), onPressed: (isUploading || !hasImage) ? null : onUpload,
icon: const FaIcon(FontAwesomeIcons.camera, size: 18), icon: isUploading
label: const Text( ? const SizedBox(
'Upload bill', width: 18,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const FaIcon(FontAwesomeIcons.camera, size: 18),
label: Text(
isUploading ? 'Đang upload...' : 'Upload bill chuyển khoản',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
@@ -499,6 +655,33 @@ class PaymentQrPage extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button), borderRadius: BorderRadius.circular(AppRadius.button),
), ),
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
),
),
),
const SizedBox(height: AppSpacing.sm),
// Back to Home Button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: isUploading ? null : () => context.goNamed(RouteNames.home),
icon: const FaIcon(FontAwesomeIcons.house, size: 18),
label: const Text(
'Quay về trang chủ',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.grey900,
side: const BorderSide(
color: AppColors.grey100,
width: 1.5,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
), ),
), ),
), ),
@@ -598,57 +781,209 @@ class PaymentQrPage extends HookConsumerWidget {
); );
} }
/// Confirm payment /// Select image for bill
void _confirmPayment(BuildContext context) { Future<void> _selectImage(
showDialog<void>( BuildContext context,
ValueNotifier<String?> selectedImagePath,
) async {
// Show bottom sheet to select camera or gallery
final ImageSource? source = await showModalBottomSheet<ImageSource>(
context: context, context: context,
builder: (context) => AlertDialog( shape: const RoundedRectangleBorder(
title: const Text('Xác nhận thanh toán'), borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.card)),
content: const Text(
'Bạn đã hoàn tất thanh toán cho đơn hàng này?',
style: TextStyle(fontSize: 14),
), ),
actions: [ builder: (context) => Container(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Chọn ảnh hóa đơn',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
),
const SizedBox(height: AppSpacing.md),
ListTile(
leading: const FaIcon(
FontAwesomeIcons.camera,
color: AppColors.primaryBlue,
),
title: const Text('Chụp ảnh'),
onTap: () => Navigator.of(context).pop(ImageSource.camera),
),
ListTile(
leading: const FaIcon(
FontAwesomeIcons.image,
color: AppColors.primaryBlue,
),
title: const Text('Chọn từ thư viện'),
onTap: () => Navigator.of(context).pop(ImageSource.gallery),
),
const SizedBox(height: AppSpacing.sm),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Chưa'), child: const Text('Hủy'),
), ),
ElevatedButton( ],
onPressed: () { ),
Navigator.of(context).pop(); ),
);
if (source == null || !context.mounted) return;
try {
// Pick image
final picker = ImagePicker();
final pickedFile = await picker.pickImage(
source: source,
maxWidth: 1920,
maxHeight: 1080,
imageQuality: 85,
);
if (pickedFile == null || !context.mounted) return;
selectedImagePath.value = pickedFile.path;
// Show success feedback
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Đã xác nhận thanh toán!'), content: Row(
children: [
FaIcon(FontAwesomeIcons.circleCheck, color: Colors.white, size: 20),
SizedBox(width: 12),
Text('Đã chọn ảnh. Nhấn "Upload bill chuyển khoản" để gửi.'),
],
),
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
// Navigate back after delay } catch (e) {
Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return;
if (context.mounted) {
context.pop(); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi khi chọn ảnh: ${e.toString()}'),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 2),
),
);
} }
}); }
},
/// Upload bill to server
Future<void> _uploadBill(
BuildContext context,
WidgetRef ref,
ValueNotifier<String?> selectedImagePath,
ValueNotifier<bool> isUploadingBill,
) async {
if (selectedImagePath.value == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng chọn ảnh hóa đơn trước'),
backgroundColor: AppColors.danger,
duration: Duration(seconds: 2),
),
);
return;
}
// Show confirmation dialog
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Xác nhận upload'),
content: const Text(
'Bạn có muốn upload hóa đơn này không?',
style: TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Hủy'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue, backgroundColor: AppColors.primaryBlue,
), ),
child: const Text('Đã thanh toán'), child: const Text('Upload'),
), ),
], ],
), ),
); );
}
/// Upload payment proof if (confirmed != true || !context.mounted) return;
void _uploadProof(BuildContext context) {
// TODO: Implement image picker and upload try {
// Start upload
isUploadingBill.value = true;
final repository = await ref.read(orderRepositoryProvider.future);
final result = await repository.uploadBill(
filePath: selectedImagePath.value!,
orderId: orderId,
);
if (!context.mounted) return;
isUploadingBill.value = false;
// Show success message and navigate
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Tính năng upload bill đang được phát triển'), content: Row(
children: [
FaIcon(FontAwesomeIcons.circleCheck, color: Colors.white, size: 20),
SizedBox(width: 12),
Text('Upload hóa đơn thành công!'),
],
),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
// Navigate to order success page after successful upload
Future.delayed(const Duration(milliseconds: 500), () {
if (context.mounted) {
context.pushReplacementNamed(
RouteNames.orderSuccess,
queryParameters: {
'orderNumber': orderId,
'total': amount.toString(),
'isNegotiation': 'false',
},
);
}
});
} catch (e) {
if (!context.mounted) return;
isUploadingBill.value = false;
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const FaIcon(FontAwesomeIcons.circleXmark, color: Colors.white, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text('Lỗi upload: ${e.toString()}'),
),
],
),
backgroundColor: AppColors.danger,
duration: const Duration(seconds: 3),
),
);
}
} }
/// Format currency /// Format currency