From 359c31a4d4269618c3fdbaef44630c0a6ce7d48a Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Tue, 2 Dec 2025 15:58:10 +0700 Subject: [PATCH] update invoice --- docs/invoice.sh | 97 ++ html/invoice-detail.html | 632 ++++++++++++ html/invoice-list.html | 351 +++++++ lib/core/constants/api_constants.dart | 12 + lib/core/router/app_router.dart | 27 + .../presentation/pages/account_page.dart | 8 + .../invoice_remote_datasource.dart | 80 ++ .../invoices/data/models/invoice_model.dart | 306 ++++++ .../repositories/invoice_repository_impl.dart | 43 + .../invoices/domain/entities/invoice.dart | 272 ++++++ .../repositories/invoice_repository.dart | 18 + .../pages/invoice_detail_page.dart | 921 ++++++++++++++++++ .../presentation/pages/invoices_page.dart | 362 +++++++ .../providers/invoices_provider.dart | 67 ++ .../providers/invoices_provider.g.dart | 202 ++++ 15 files changed, 3398 insertions(+) create mode 100644 docs/invoice.sh create mode 100644 html/invoice-detail.html create mode 100644 html/invoice-list.html create mode 100644 lib/features/invoices/data/datasources/invoice_remote_datasource.dart create mode 100644 lib/features/invoices/data/models/invoice_model.dart create mode 100644 lib/features/invoices/data/repositories/invoice_repository_impl.dart create mode 100644 lib/features/invoices/domain/entities/invoice.dart create mode 100644 lib/features/invoices/domain/repositories/invoice_repository.dart create mode 100644 lib/features/invoices/presentation/pages/invoice_detail_page.dart create mode 100644 lib/features/invoices/presentation/pages/invoices_page.dart create mode 100644 lib/features/invoices/presentation/providers/invoices_provider.dart create mode 100644 lib/features/invoices/presentation/providers/invoices_provider.g.dart diff --git a/docs/invoice.sh b/docs/invoice.sh new file mode 100644 index 0000000..1042149 --- /dev/null +++ b/docs/invoice.sh @@ -0,0 +1,97 @@ +#get list of invoices +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.invoice.get_list' \ +--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \ +--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \ +--header 'Content-Type: application/json' \ +--data '{ + "limit_page_length" : 0, + "limit_start" : 0 +}' + +#response +{ + "message": [ + { + "name": "ACC-SINV-2025-00041", + "posting_date": "2025-12-02", + "status": "Chưa thanh toán", + "status_color": "Danger", + "order_id": null, + "grand_total": 486400.0 + }, + { + "name": "ACC-SINV-2025-00026", + "posting_date": "2025-11-25", + "status": "Đã trả", + "status_color": "Success", + "order_id": "SAL-ORD-2025-00119", + "grand_total": 1153433.6 + }, + { + "name": "ACC-SINV-2025-00025", + "posting_date": "2025-11-24", + "status": "Đã trả", + "status_color": "Success", + "order_id": "SAL-ORD-2025-00104", + "grand_total": 3580257.894 + } + ] +} + +#get invoice detail +curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.invoice.get_detail' \ +--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \ +--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \ +--header 'Content-Type: application/json' \ +--data '{ + "name" : "ACC-SINV-2025-00041" +}' + +#response +{ + "message": { + "name": "ACC-SINV-2025-00041", + "posting_date": "2025-12-02", + "status": "Chưa thanh toán", + "status_color": "Danger", + "customer_name": "Ha Duy Lam", + "order_id": null, + "seller_info": { + "phone": "0243 543 0726", + "email": "info@viglacera.com.vn", + "fax": "(024) 3553 6671", + "tax_code": "0105908818", + "company_name": "Công Ty Cổ Phần Kinh Doanh Gạch Ốp Lát Viglacera", + "address_line1": "Tầng 2 tòa nhà Viglacera, số 1 đại lộ Thăng Long", + "city_code": "01", + "ward_code": "00637", + "city_name": "Thành phố Hà Nội", + "ward_name": "Phường Đại Mỗ" + }, + "buyer_info": { + "name": "phuoc-thanh toán", + "address_title": "phuoc", + "address_line1": "123 tt", + "phone": "0985225855", + "email": null, + "fax": null, + "tax_code": null, + "city_code": "75", + "ward_code": "25252", + "city_name": "Tỉnh Đồng Nai", + "ward_name": "Xã Phú Riềng" + }, + "items": [ + { + "item_name": "Hội An HOA E01", + "item_code": "HOA E01", + "qty": 1.0, + "rate": 486400.0, + "amount": 486400.0 + } + ], + "total": 486400.0, + "discount_amount": 0.0, + "grand_total": 486400.0 + } +} \ No newline at end of file diff --git a/html/invoice-detail.html b/html/invoice-detail.html new file mode 100644 index 0000000..a2ff811 --- /dev/null +++ b/html/invoice-detail.html @@ -0,0 +1,632 @@ + + + + + + Chi tiết Hóa đơn - EuroTile Worker + + + + + + +
+ +
+ + + +

Chi tiết Hóa đơn

+ +
+ +
+
+ +
+
+ +

HÓA ĐƠN GTGT

+
#INV20240001
+ Đã thanh toán + +
+ + +
+
Ngày xuất:
+
03/08/2024
+
+
+
Đơn hàng:
+
#DH001234
+
+
+
+ + +
+
+

+ + Đơn vị bán hàng +

+

Công ty: CÔNG TY CP EUROTILE VIỆT NAM

+

Mã số thuế: 0301234567

+

Địa chỉ: 123 Đường Nguyễn Văn Linh, Quận 7, TP.HCM

+

Điện thoại: (028) 1900 1234

+

Email: sales@eurotile.vn

+
+ +
+

+ + Đơn vị mua hàng +

+

Người mua hàng: Lê Hoàng Hiệp

+

Tên đơn vị: Công ty TNHH Xây dựng Minh Long

+

Mã số thuế: 0134000687

+

Địa chỉ: 11 Đường Hoàng Hữu Nam, Phường Linh Chiểu, TP. Thủ Đức, TP.HCM

+

Điện thoại: 0339797979

+

Email: minhlong.org@gmail.com

+
+
+
+ + +
+

+ + Chi tiết hàng hóa +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Tên hàng hóaSố lượngĐơn giáThành tiền
1 +
Gạch Eurotile MỘC LAM E03
+
SKU: ET-ML-E03-60x60
+
30,12285.000đ8.550.000đ
2 +
Gạch Eurotile STONE GREY S02
+
SKU: ET-SG-S02-80x80
+
20,24217.500đ4.350.000đ
+ + +
+
+ Tổng tiền hàng: + 12.900.000đ +
+
+ Chiết khấu VIP (1%): + -129.000đ +
+ + +
+ TỔNG THANH TOÁN: + 12.771.000đ +
+
+ + + +
+ + + + +
+ +
+
+
+ + + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/html/invoice-list.html b/html/invoice-list.html new file mode 100644 index 0000000..4dfb2f4 --- /dev/null +++ b/html/invoice-list.html @@ -0,0 +1,351 @@ + + + + + + Hóa đơn đã mua - EuroTile Worker + + + + + + +
+ +
+ + + +

Hóa đơn đã mua

+
+
+ +
+ +
+
+
+
#INV20240001
+
Ngày xuất: 03/08/2024
+
+ Đã thanh toán +
+ +
+
+ Đơn hàng: + #DH001234 +
+
+ Tổng tiền: + 12.771.000đ +
+
+ + +
+ + +
+
+
+
#INV20240002
+
Ngày xuất: 15/07/2024
+
+ Thanh toán 1 phần +
+ +
+
+ Đơn hàng: + #DH001198 +
+
+ Tổng tiền: + 85.600.000đ +
+
+ + +
+ + +
+
+
+
#INV20240003
+
Ngày xuất: 25/06/2024
+
+ Đã thanh toán +
+ +
+
+ Đơn hàng: + #DH001087 +
+
+ Tổng tiền: + 42.500.000đ +
+
+ + +
+ + +
+
+
+
#INV20240004
+
Ngày xuất: 10/06/2024
+
+ Chưa thanh toán +
+ +
+
+ Đơn hàng: + #DH000945 +
+
+ Tổng tiền: + 28.300.000đ +
+
+ + +
+ + +
+
+
+
#INV20240005
+
Ngày xuất: 15/05/2024
+
+ Đã thanh toán +
+ +
+
+ Đơn hàng: + #DH000821 +
+
+ Tổng tiền: + 56.750.000đ +
+
+ + +
+
+ +
+ + + + \ No newline at end of file diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index fd351be..a296b51 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -283,6 +283,18 @@ class ApiConstants { static const String getPaymentDetail = '/building_material.building_material.api.payment.get_detail'; + /// Get invoice list (Frappe API) + /// POST /api/method/building_material.building_material.api.invoice.get_list + /// Body: { "limit_start": 0, "limit_page_length": 0 } + static const String getInvoiceList = + '/building_material.building_material.api.invoice.get_list'; + + /// Get invoice detail (Frappe API) + /// POST /api/method/building_material.building_material.api.invoice.get_detail + /// Body: { "name": "ACC-SINV-2025-00041" } + static const String getInvoiceDetail = + '/building_material.building_material.api.invoice.get_detail'; + // ============================================================================ // Project Endpoints (Frappe ERPNext) // ============================================================================ diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 5131e8d..1ce7af6 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -52,6 +52,8 @@ import 'package:worker/features/showrooms/presentation/pages/design_request_deta import 'package:worker/features/showrooms/presentation/pages/model_house_detail_page.dart'; import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart'; import 'package:worker/features/account/presentation/pages/theme_settings_page.dart'; +import 'package:worker/features/invoices/presentation/pages/invoices_page.dart'; +import 'package:worker/features/invoices/presentation/pages/invoice_detail_page.dart'; /// Router Provider /// @@ -488,6 +490,27 @@ final routerProvider = Provider((ref) { MaterialPage(key: state.pageKey, child: const ThemeSettingsPage()), ), + // Invoices Route + GoRoute( + path: RouteNames.invoices, + name: RouteNames.invoices, + pageBuilder: (context, state) => + MaterialPage(key: state.pageKey, child: const InvoicesPage()), + ), + + // Invoice Detail Route + GoRoute( + path: RouteNames.invoiceDetail, + name: RouteNames.invoiceDetail, + pageBuilder: (context, state) { + final invoiceId = state.pathParameters['id']; + return MaterialPage( + key: state.pageKey, + child: InvoiceDetailPage(invoiceId: invoiceId ?? ''), + ); + }, + ), + // Chat List Route GoRoute( path: RouteNames.chat, @@ -638,6 +661,10 @@ class RouteNames { static const String themeSettings = '$account/theme-settings'; static const String settings = '$account/settings'; + // Invoice Routes + static const String invoices = '/invoices'; + static const String invoiceDetail = '$invoices/:id'; + // Promotions & Notifications Routes static const String promotions = '/promotions'; static const String promotionDetail = '$promotions/:id'; diff --git a/lib/features/account/presentation/pages/account_page.dart b/lib/features/account/presentation/pages/account_page.dart index a7eed8b..8ff7919 100644 --- a/lib/features/account/presentation/pages/account_page.dart +++ b/lib/features/account/presentation/pages/account_page.dart @@ -140,6 +140,14 @@ class AccountPage extends ConsumerWidget { context.push(RouteNames.orders); }, ), + AccountMenuItem( + icon: FontAwesomeIcons.fileInvoiceDollar, + title: 'Hóa đơn đã mua', + subtitle: 'Xem các hóa đơn đã xuất', + onTap: () { + context.push(RouteNames.invoices); + }, + ), AccountMenuItem( icon: FontAwesomeIcons.locationDot, title: 'Địa chỉ đã lưu', diff --git a/lib/features/invoices/data/datasources/invoice_remote_datasource.dart b/lib/features/invoices/data/datasources/invoice_remote_datasource.dart new file mode 100644 index 0000000..2fb7259 --- /dev/null +++ b/lib/features/invoices/data/datasources/invoice_remote_datasource.dart @@ -0,0 +1,80 @@ +/// Invoice Remote Data Source +/// +/// Handles API calls for invoice-related data. +library; + +import 'package:worker/core/constants/api_constants.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/invoices/data/models/invoice_model.dart'; + +/// Invoice Remote Data Source +class InvoiceRemoteDataSource { + const InvoiceRemoteDataSource(this._dioClient); + + final DioClient _dioClient; + + /// Get invoices list + /// + /// Calls: POST /api/method/building_material.building_material.api.invoice.get_list + /// Returns: List of invoices + Future> getInvoicesList({ + int limitStart = 0, + int limitPageLength = 0, + }) async { + try { + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.getInvoiceList}', + data: { + 'limit_start': limitStart, + 'limit_page_length': limitPageLength, + }, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from getInvoicesList API'); + } + + // API returns: { "message": [...] } + final message = data['message']; + if (message == null) { + throw Exception('No message field in getInvoicesList response'); + } + + final List invoicesList = message as List; + return invoicesList + .map((json) => InvoiceModel.fromJson(json as Map)) + .toList(); + } catch (e) { + throw Exception('Failed to get invoices list: $e'); + } + } + + /// Get invoice detail + /// + /// Calls: POST /api/method/building_material.building_material.api.invoice.get_detail + /// Returns: Invoice detail + Future getInvoiceDetail(String name) async { + try { + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.getInvoiceDetail}', + data: {'name': name}, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from getInvoiceDetail API'); + } + + // API returns: { "message": {...} } + final message = data['message']; + if (message == null) { + throw Exception('No message field in getInvoiceDetail response'); + } + + return InvoiceModel.fromJson(message as Map); + } catch (e) { + throw Exception('Failed to get invoice detail: $e'); + } + } +} diff --git a/lib/features/invoices/data/models/invoice_model.dart b/lib/features/invoices/data/models/invoice_model.dart new file mode 100644 index 0000000..8e20993 --- /dev/null +++ b/lib/features/invoices/data/models/invoice_model.dart @@ -0,0 +1,306 @@ +/// Data Model: Invoice Model +/// +/// Model for invoice data with API serialization. +/// Not stored in local database. +library; + +import 'package:worker/features/invoices/domain/entities/invoice.dart'; + +/// Seller Info Model +class SellerInfoModel { + final String? phone; + final String? email; + final String? fax; + final String? taxCode; + final String? companyName; + final String? addressLine1; + final String? cityCode; + final String? wardCode; + final String? cityName; + final String? wardName; + + const SellerInfoModel({ + this.phone, + this.email, + this.fax, + this.taxCode, + this.companyName, + this.addressLine1, + this.cityCode, + this.wardCode, + this.cityName, + this.wardName, + }); + + factory SellerInfoModel.fromJson(Map json) { + return SellerInfoModel( + phone: json['phone'] as String?, + email: json['email'] as String?, + fax: json['fax'] as String?, + taxCode: json['tax_code'] as String?, + companyName: json['company_name'] as String?, + addressLine1: json['address_line1'] as String?, + cityCode: json['city_code'] as String?, + wardCode: json['ward_code'] as String?, + cityName: json['city_name'] as String?, + wardName: json['ward_name'] as String?, + ); + } + + Map toJson() => { + 'phone': phone, + 'email': email, + 'fax': fax, + 'tax_code': taxCode, + 'company_name': companyName, + 'address_line1': addressLine1, + 'city_code': cityCode, + 'ward_code': wardCode, + 'city_name': cityName, + 'ward_name': wardName, + }; + + SellerInfo toEntity() => SellerInfo( + phone: phone, + email: email, + fax: fax, + taxCode: taxCode, + companyName: companyName, + addressLine1: addressLine1, + cityCode: cityCode, + wardCode: wardCode, + cityName: cityName, + wardName: wardName, + ); +} + +/// Buyer Info Model +class BuyerInfoModel { + final String? name; + final String? addressTitle; + final String? addressLine1; + final String? phone; + final String? email; + final String? fax; + final String? taxCode; + final String? cityCode; + final String? wardCode; + final String? cityName; + final String? wardName; + + const BuyerInfoModel({ + this.name, + this.addressTitle, + this.addressLine1, + this.phone, + this.email, + this.fax, + this.taxCode, + this.cityCode, + this.wardCode, + this.cityName, + this.wardName, + }); + + factory BuyerInfoModel.fromJson(Map json) { + return BuyerInfoModel( + name: json['name'] as String?, + addressTitle: json['address_title'] as String?, + addressLine1: json['address_line1'] as String?, + phone: json['phone'] as String?, + email: json['email'] as String?, + fax: json['fax'] as String?, + taxCode: json['tax_code'] as String?, + cityCode: json['city_code'] as String?, + wardCode: json['ward_code'] as String?, + cityName: json['city_name'] as String?, + wardName: json['ward_name'] as String?, + ); + } + + Map toJson() => { + 'name': name, + 'address_title': addressTitle, + 'address_line1': addressLine1, + 'phone': phone, + 'email': email, + 'fax': fax, + 'tax_code': taxCode, + 'city_code': cityCode, + 'ward_code': wardCode, + 'city_name': cityName, + 'ward_name': wardName, + }; + + BuyerInfo toEntity() => BuyerInfo( + name: name, + addressTitle: addressTitle, + addressLine1: addressLine1, + phone: phone, + email: email, + fax: fax, + taxCode: taxCode, + cityCode: cityCode, + wardCode: wardCode, + cityName: cityName, + wardName: wardName, + ); +} + +/// Invoice Item Model +class InvoiceItemModel { + final String itemName; + final String itemCode; + final double qty; + final double rate; + final double amount; + + const InvoiceItemModel({ + required this.itemName, + required this.itemCode, + required this.qty, + required this.rate, + required this.amount, + }); + + factory InvoiceItemModel.fromJson(Map json) { + return InvoiceItemModel( + itemName: json['item_name'] as String? ?? '', + itemCode: json['item_code'] as String? ?? '', + qty: (json['qty'] as num?)?.toDouble() ?? 0.0, + rate: (json['rate'] as num?)?.toDouble() ?? 0.0, + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + ); + } + + Map toJson() => { + 'item_name': itemName, + 'item_code': itemCode, + 'qty': qty, + 'rate': rate, + 'amount': amount, + }; + + InvoiceItem toEntity() => InvoiceItem( + itemName: itemName, + itemCode: itemCode, + qty: qty, + rate: rate, + amount: amount, + ); +} + +/// Invoice Model +/// +/// Model for API parsing only (no Hive storage). +class InvoiceModel { + final String name; + final String postingDate; + final String status; + final String statusColor; + final String? orderId; + final double grandTotal; + + // Detail-only fields + final String? customerName; + final SellerInfoModel? sellerInfo; + final BuyerInfoModel? buyerInfo; + final List? items; + final double? total; + final double? discountAmount; + + const InvoiceModel({ + required this.name, + required this.postingDate, + required this.status, + required this.statusColor, + this.orderId, + required this.grandTotal, + this.customerName, + this.sellerInfo, + this.buyerInfo, + this.items, + this.total, + this.discountAmount, + }); + + /// Create from JSON (API response - list item) + factory InvoiceModel.fromJson(Map json) { + return InvoiceModel( + name: json['name'] as String? ?? '', + postingDate: json['posting_date'] as String? ?? '', + status: json['status'] as String? ?? '', + statusColor: json['status_color'] as String? ?? 'Secondary', + orderId: json['order_id'] as String?, + grandTotal: (json['grand_total'] as num?)?.toDouble() ?? 0.0, + customerName: json['customer_name'] as String?, + sellerInfo: json['seller_info'] != null + ? SellerInfoModel.fromJson(json['seller_info'] as Map) + : null, + buyerInfo: json['buyer_info'] != null + ? BuyerInfoModel.fromJson(json['buyer_info'] as Map) + : null, + items: json['items'] != null + ? (json['items'] as List) + .map((e) => InvoiceItemModel.fromJson(e as Map)) + .toList() + : null, + total: (json['total'] as num?)?.toDouble(), + discountAmount: (json['discount_amount'] as num?)?.toDouble(), + ); + } + + /// Convert to JSON + Map toJson() => { + 'name': name, + 'posting_date': postingDate, + 'status': status, + 'status_color': statusColor, + 'order_id': orderId, + 'grand_total': grandTotal, + 'customer_name': customerName, + 'seller_info': sellerInfo?.toJson(), + 'buyer_info': buyerInfo?.toJson(), + 'items': items?.map((e) => e.toJson()).toList(), + 'total': total, + 'discount_amount': discountAmount, + }; + + /// Convert to domain entity + Invoice toEntity() { + return Invoice( + name: name, + postingDate: DateTime.tryParse(postingDate) ?? DateTime.now(), + status: status, + statusColor: statusColor, + orderId: orderId, + grandTotal: grandTotal, + customerName: customerName, + sellerInfo: sellerInfo?.toEntity(), + buyerInfo: buyerInfo?.toEntity(), + items: items?.map((e) => e.toEntity()).toList(), + total: total, + discountAmount: discountAmount, + ); + } + + /// Create from domain entity + factory InvoiceModel.fromEntity(Invoice entity) { + return InvoiceModel( + name: entity.name, + postingDate: entity.postingDate.toIso8601String().split('T').first, + status: entity.status, + statusColor: entity.statusColor, + orderId: entity.orderId, + grandTotal: entity.grandTotal, + customerName: entity.customerName, + total: entity.total, + discountAmount: entity.discountAmount, + ); + } + + @override + String toString() { + return 'InvoiceModel(name: $name, status: $status, grandTotal: $grandTotal)'; + } +} diff --git a/lib/features/invoices/data/repositories/invoice_repository_impl.dart b/lib/features/invoices/data/repositories/invoice_repository_impl.dart new file mode 100644 index 0000000..9348487 --- /dev/null +++ b/lib/features/invoices/data/repositories/invoice_repository_impl.dart @@ -0,0 +1,43 @@ +/// Invoice Repository Implementation +/// +/// Implements the invoice repository interface. +library; + +import 'package:worker/features/invoices/data/datasources/invoice_remote_datasource.dart'; +import 'package:worker/features/invoices/domain/entities/invoice.dart'; +import 'package:worker/features/invoices/domain/repositories/invoice_repository.dart'; + +/// Invoice Repository Implementation +class InvoiceRepositoryImpl implements InvoiceRepository { + const InvoiceRepositoryImpl(this._remoteDataSource); + + final InvoiceRemoteDataSource _remoteDataSource; + + @override + Future> getInvoicesList({ + int limitStart = 0, + int limitPageLength = 0, + }) async { + try { + final invoicesData = await _remoteDataSource.getInvoicesList( + limitStart: limitStart, + limitPageLength: limitPageLength, + ); + // Convert Model → Entity + return invoicesData.map((model) => model.toEntity()).toList(); + } catch (e) { + throw Exception('Failed to get invoices list: $e'); + } + } + + @override + Future getInvoiceDetail(String name) async { + try { + final invoiceData = await _remoteDataSource.getInvoiceDetail(name); + // Convert Model → Entity + return invoiceData.toEntity(); + } catch (e) { + throw Exception('Failed to get invoice detail: $e'); + } + } +} diff --git a/lib/features/invoices/domain/entities/invoice.dart b/lib/features/invoices/domain/entities/invoice.dart new file mode 100644 index 0000000..77aca90 --- /dev/null +++ b/lib/features/invoices/domain/entities/invoice.dart @@ -0,0 +1,272 @@ +/// Domain Entity: Invoice +/// +/// Represents an invoice from the API. +/// Used for both list and detail views. +library; + +import 'package:equatable/equatable.dart'; + +/// Seller/Company Information +class SellerInfo extends Equatable { + final String? phone; + final String? email; + final String? fax; + final String? taxCode; + final String? companyName; + final String? addressLine1; + final String? cityCode; + final String? wardCode; + final String? cityName; + final String? wardName; + + const SellerInfo({ + this.phone, + this.email, + this.fax, + this.taxCode, + this.companyName, + this.addressLine1, + this.cityCode, + this.wardCode, + this.cityName, + this.wardName, + }); + + /// Get formatted full address + String get fullAddress { + final parts = []; + if (addressLine1 != null && addressLine1!.isNotEmpty) { + parts.add(addressLine1!); + } + if (wardName != null && wardName!.isNotEmpty) { + parts.add(wardName!); + } + if (cityName != null && cityName!.isNotEmpty) { + parts.add(cityName!); + } + return parts.join(', '); + } + + @override + List get props => [ + phone, + email, + fax, + taxCode, + companyName, + addressLine1, + cityCode, + wardCode, + cityName, + wardName, + ]; +} + +/// Buyer/Customer Information +class BuyerInfo extends Equatable { + final String? name; + final String? addressTitle; + final String? addressLine1; + final String? phone; + final String? email; + final String? fax; + final String? taxCode; + final String? cityCode; + final String? wardCode; + final String? cityName; + final String? wardName; + + const BuyerInfo({ + this.name, + this.addressTitle, + this.addressLine1, + this.phone, + this.email, + this.fax, + this.taxCode, + this.cityCode, + this.wardCode, + this.cityName, + this.wardName, + }); + + /// Get formatted full address + String get fullAddress { + final parts = []; + if (addressLine1 != null && addressLine1!.isNotEmpty) { + parts.add(addressLine1!); + } + if (wardName != null && wardName!.isNotEmpty) { + parts.add(wardName!); + } + if (cityName != null && cityName!.isNotEmpty) { + parts.add(cityName!); + } + return parts.join(', '); + } + + @override + List get props => [ + name, + addressTitle, + addressLine1, + phone, + email, + fax, + taxCode, + cityCode, + wardCode, + cityName, + wardName, + ]; +} + +/// Invoice Line Item +class InvoiceItem extends Equatable { + final String itemName; + final String itemCode; + final double qty; + final double rate; + final double amount; + + const InvoiceItem({ + required this.itemName, + required this.itemCode, + required this.qty, + required this.rate, + required this.amount, + }); + + @override + List get props => [itemName, itemCode, qty, rate, amount]; +} + +/// Invoice Entity +/// +/// Contains invoice information from API: +/// - name: Invoice ID (e.g., "ACC-SINV-2025-00041") +/// - postingDate: Invoice date +/// - status: Status label (Vietnamese) +/// - statusColor: Status color (Danger, Success, etc.) +/// - orderId: Related order ID (nullable) +/// - grandTotal: Total amount +/// - customerName: Customer name (detail only) +/// - sellerInfo: Seller company info (detail only) +/// - buyerInfo: Buyer info (detail only) +/// - items: Invoice line items (detail only) +/// - total: Subtotal before discount (detail only) +/// - discountAmount: Discount amount (detail only) +class Invoice extends Equatable { + /// Invoice ID (e.g., "ACC-SINV-2025-00041") + final String name; + + /// Invoice posting date + final DateTime postingDate; + + /// Status label (Vietnamese) + final String status; + + /// Status color (Danger, Success, Warning, etc.) + final String statusColor; + + /// Related order ID (nullable) + final String? orderId; + + /// Grand total amount + final double grandTotal; + + // Detail-only fields (nullable for list view) + + /// Customer name + final String? customerName; + + /// Seller company information + final SellerInfo? sellerInfo; + + /// Buyer information + final BuyerInfo? buyerInfo; + + /// Invoice line items + final List? items; + + /// Subtotal before discount + final double? total; + + /// Discount amount + final double? discountAmount; + + const Invoice({ + required this.name, + required this.postingDate, + required this.status, + required this.statusColor, + this.orderId, + required this.grandTotal, + this.customerName, + this.sellerInfo, + this.buyerInfo, + this.items, + this.total, + this.discountAmount, + }); + + /// Check if this is a detail invoice (has all detail fields) + bool get isDetail => sellerInfo != null && buyerInfo != null && items != null; + + /// Get formatted posting date + String get formattedDate { + return '${postingDate.day.toString().padLeft(2, '0')}/${postingDate.month.toString().padLeft(2, '0')}/${postingDate.year}'; + } + + /// Copy with method for immutability + Invoice copyWith({ + String? name, + DateTime? postingDate, + String? status, + String? statusColor, + String? orderId, + double? grandTotal, + String? customerName, + SellerInfo? sellerInfo, + BuyerInfo? buyerInfo, + List? items, + double? total, + double? discountAmount, + }) { + return Invoice( + name: name ?? this.name, + postingDate: postingDate ?? this.postingDate, + status: status ?? this.status, + statusColor: statusColor ?? this.statusColor, + orderId: orderId ?? this.orderId, + grandTotal: grandTotal ?? this.grandTotal, + customerName: customerName ?? this.customerName, + sellerInfo: sellerInfo ?? this.sellerInfo, + buyerInfo: buyerInfo ?? this.buyerInfo, + items: items ?? this.items, + total: total ?? this.total, + discountAmount: discountAmount ?? this.discountAmount, + ); + } + + @override + List get props => [ + name, + postingDate, + status, + statusColor, + orderId, + grandTotal, + customerName, + sellerInfo, + buyerInfo, + items, + total, + discountAmount, + ]; + + @override + String toString() { + return 'Invoice(name: $name, status: $status, grandTotal: $grandTotal)'; + } +} diff --git a/lib/features/invoices/domain/repositories/invoice_repository.dart b/lib/features/invoices/domain/repositories/invoice_repository.dart new file mode 100644 index 0000000..dd5fa36 --- /dev/null +++ b/lib/features/invoices/domain/repositories/invoice_repository.dart @@ -0,0 +1,18 @@ +/// Invoice Repository Interface +/// +/// Defines the contract for invoice-related data operations. +library; + +import 'package:worker/features/invoices/domain/entities/invoice.dart'; + +/// Invoice Repository Interface +abstract class InvoiceRepository { + /// Get list of invoices + Future> getInvoicesList({ + int limitStart = 0, + int limitPageLength = 0, + }); + + /// Get invoice detail by ID + Future getInvoiceDetail(String name); +} diff --git a/lib/features/invoices/presentation/pages/invoice_detail_page.dart b/lib/features/invoices/presentation/pages/invoice_detail_page.dart new file mode 100644 index 0000000..22c4011 --- /dev/null +++ b/lib/features/invoices/presentation/pages/invoice_detail_page.dart @@ -0,0 +1,921 @@ +/// Page: Invoice Detail Page +/// +/// Displays invoice detail following html/invoice-detail.html design. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/utils/extensions.dart'; +import 'package:worker/features/invoices/domain/entities/invoice.dart'; +import 'package:worker/features/invoices/presentation/providers/invoices_provider.dart'; + +/// Invoice Detail Page +/// +/// Features: +/// - Invoice header with status +/// - Seller and buyer information (2-column grid) +/// - Product list table with unit price +/// - Invoice summary (total, discount, grand total) +/// - Share and contact support actions +class InvoiceDetailPage extends ConsumerWidget { + const InvoiceDetailPage({ + super.key, + required this.invoiceId, + }); + + final String invoiceId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final invoiceAsync = ref.watch(invoiceDetailProvider(invoiceId)); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surfaceContainerLowest, + appBar: AppBar( + leading: IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => context.pop(), + ), + title: Text( + 'Chi tiết Hóa đơn', + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + elevation: AppBarSpecs.elevation, + backgroundColor: colorScheme.surface, + centerTitle: false, + actions: [ + IconButton( + icon: FaIcon( + FontAwesomeIcons.shareNodes, + size: 20, + color: colorScheme.onSurface, + ), + onPressed: () => _shareInvoice(context), + ), + const SizedBox(width: AppSpacing.sm), + ], + ), + body: invoiceAsync.when( + data: (invoice) => _buildContent(context, invoice), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => _buildErrorState(context, ref, error), + ), + ); + } + + Widget _buildContent(BuildContext context, Invoice invoice) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Invoice Header Card + _buildHeaderCard(context, invoice), + const SizedBox(height: 16), + + // Products Section + _buildProductsCard(context, invoice), + const SizedBox(height: 16), + + // Action Button + _buildActionButton(context), + const SizedBox(height: 40), + ], + ), + ); + } + + /// Build invoice header card + Widget _buildHeaderCard(BuildContext context, Invoice invoice) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.1), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Invoice Header Section (centered) + Container( + padding: const EdgeInsets.only(bottom: 24), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colorScheme.outlineVariant, + width: 2, + ), + ), + ), + child: Column( + children: [ + // Invoice Icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary, + colorScheme.primary.withValues(alpha: 0.8), + ], + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.fileInvoiceDollar, + size: 32, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 16), + + // Title + Text( + 'HÓA ĐƠN GTGT', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + + // Invoice Number + Text( + '#${invoice.name}', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 12), + + // Status Badge + _StatusBadge( + status: invoice.status, + statusColor: invoice.statusColor, + ), + const SizedBox(height: 16), + + // Invoice Meta Info + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _MetaItem(label: 'Ngày xuất:', value: invoice.formattedDate), + if (invoice.orderId != null) ...[ + const SizedBox(width: 32), + _MetaItem(label: 'Đơn hàng:', value: '#${invoice.orderId}'), + ], + ], + ), + ], + ), + ), + + // Company Information Section (2-column grid) + if (invoice.sellerInfo != null || invoice.buyerInfo != null) ...[ + const SizedBox(height: 24), + _buildCompanyInfoSection(context, invoice), + ], + ], + ), + ), + ); + } + + /// Build company info section (seller and buyer) - 2 column grid + Widget _buildCompanyInfoSection(BuildContext context, Invoice invoice) { + final colorScheme = Theme.of(context).colorScheme; + final screenWidth = MediaQuery.of(context).size.width; + final isWideScreen = screenWidth > 600; + + if (isWideScreen) { + // 2-column grid for wide screens + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Seller Info + if (invoice.sellerInfo != null) + Expanded( + child: _CompanyInfoBlock( + icon: FontAwesomeIcons.building, + iconColor: colorScheme.primary, + title: 'Đơn vị bán hàng', + lines: _buildSellerInfoLines(invoice), + ), + ), + + if (invoice.sellerInfo != null && invoice.buyerInfo != null) + const SizedBox(width: 24), + + // Buyer Info + if (invoice.buyerInfo != null) + Expanded( + child: _CompanyInfoBlock( + icon: FontAwesomeIcons.userTie, + iconColor: Colors.green.shade600, + title: 'Đơn vị mua hàng', + lines: _buildBuyerInfoLines(invoice), + ), + ), + ], + ); + } else { + // Single column for narrow screens + return Column( + children: [ + // Seller Info + if (invoice.sellerInfo != null) + _CompanyInfoBlock( + icon: FontAwesomeIcons.building, + iconColor: colorScheme.primary, + title: 'Đơn vị bán hàng', + lines: _buildSellerInfoLines(invoice), + ), + + if (invoice.sellerInfo != null && invoice.buyerInfo != null) + const SizedBox(height: 24), + + // Buyer Info + if (invoice.buyerInfo != null) + _CompanyInfoBlock( + icon: FontAwesomeIcons.userTie, + iconColor: Colors.green.shade600, + title: 'Đơn vị mua hàng', + lines: _buildBuyerInfoLines(invoice), + ), + ], + ); + } + } + + List<_InfoLine> _buildSellerInfoLines(Invoice invoice) { + return [ + if (invoice.sellerInfo!.companyName != null) + _InfoLine(label: 'Công ty', value: invoice.sellerInfo!.companyName!), + if (invoice.sellerInfo!.taxCode != null) + _InfoLine(label: 'Mã số thuế', value: invoice.sellerInfo!.taxCode!), + if (invoice.sellerInfo!.fullAddress.isNotEmpty) + _InfoLine(label: 'Địa chỉ', value: invoice.sellerInfo!.fullAddress), + if (invoice.sellerInfo!.phone != null) + _InfoLine(label: 'Điện thoại', value: invoice.sellerInfo!.phone!), + if (invoice.sellerInfo!.email != null) + _InfoLine(label: 'Email', value: invoice.sellerInfo!.email!), + ]; + } + + List<_InfoLine> _buildBuyerInfoLines(Invoice invoice) { + return [ + if (invoice.buyerInfo!.name != null) + _InfoLine(label: 'Người mua hàng', value: invoice.buyerInfo!.name!), + if (invoice.customerName != null) + _InfoLine(label: 'Tên đơn vị', value: invoice.customerName!), + if (invoice.buyerInfo!.taxCode != null) + _InfoLine(label: 'Mã số thuế', value: invoice.buyerInfo!.taxCode!), + if (invoice.buyerInfo!.fullAddress.isNotEmpty) + _InfoLine(label: 'Địa chỉ', value: invoice.buyerInfo!.fullAddress), + if (invoice.buyerInfo!.phone != null) + _InfoLine(label: 'Điện thoại', value: invoice.buyerInfo!.phone!), + if (invoice.buyerInfo!.email != null) + _InfoLine(label: 'Email', value: invoice.buyerInfo!.email!), + ]; + } + + /// Build products card + Widget _buildProductsCard(BuildContext context, Invoice invoice) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.1), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section Title + Row( + children: [ + FaIcon( + FontAwesomeIcons.boxOpen, + size: 18, + color: colorScheme.onSurface, + ), + const SizedBox(width: 8), + Text( + 'Chi tiết hàng hóa', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Products Table + if (invoice.items != null && invoice.items!.isNotEmpty) + _buildProductsTable(context, invoice) + else + Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'Không có thông tin sản phẩm', + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + ), + ), + ), + ), + + const SizedBox(height: 20), + + // Invoice Summary + _buildInvoiceSummary(context, invoice), + ], + ), + ), + ); + } + + /// Build products table - with Đơn giá column + Widget _buildProductsTable(BuildContext context, Invoice invoice) { + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + // Table Header + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + border: Border( + bottom: BorderSide( + color: colorScheme.outlineVariant, + width: 2, + ), + ), + ), + child: Row( + children: [ + // # column + SizedBox( + width: 32, + child: Text( + '#', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ), + // Tên hàng hóa column + Expanded( + flex: 3, + child: Text( + 'Tên hàng hóa', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ), + // Số lượng column + SizedBox( + width: 55, + child: Text( + 'SL', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + // Đơn giá column + SizedBox( + width: 80, + child: Text( + 'Đơn giá', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.right, + ), + ), + // Thành tiền column + SizedBox( + width: 90, + child: Text( + 'Thành tiền', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ), + + // Table Body + ...invoice.items!.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // # column + SizedBox( + width: 32, + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + ), + ), + ), + // Tên hàng hóa column + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.itemName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + 'SKU: ${item.itemCode}', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + // Số lượng column + SizedBox( + width: 55, + child: Text( + item.qty.toStringAsFixed(item.qty.truncateToDouble() == item.qty ? 0 : 2), + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + ), + // Đơn giá column + SizedBox( + width: 80, + child: Text( + item.rate.toVNCurrency, + style: TextStyle( + fontSize: 13, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.right, + ), + ), + // Thành tiền column + SizedBox( + width: 90, + child: Text( + item.amount.toVNCurrency, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + }), + ], + ); + } + + /// Build invoice summary + Widget _buildInvoiceSummary(BuildContext context, Invoice invoice) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + // Subtotal + if (invoice.total != null) + _SummaryRow( + label: 'Tổng tiền hàng:', + value: invoice.total!.toVNCurrency, + ), + + // Discount + if (invoice.discountAmount != null && invoice.discountAmount! > 0) + _SummaryRow( + label: 'Chiết khấu:', + value: '-${invoice.discountAmount!.toVNCurrency}', + valueColor: const Color(0xFF059669), + ), + + // Grand Total + Container( + padding: const EdgeInsets.only(top: 16), + margin: const EdgeInsets.only(top: 8), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: colorScheme.outlineVariant, width: 2), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'TỔNG THANH TOÁN:', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + Text( + invoice.grandTotal.toVNCurrency, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFFDC2626), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Build action button + Widget _buildActionButton(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _contactSupport(context), + icon: const FaIcon(FontAwesomeIcons.comments, size: 18), + label: const Text('Liên hệ hỗ trợ'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + side: BorderSide(color: colorScheme.outlineVariant, width: 2), + ), + ), + ); + } + + /// Build error state + Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FaIcon( + FontAwesomeIcons.circleExclamation, + size: 64, + color: colorScheme.error.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + Text( + 'Có lỗi xảy ra', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + error.toString(), + style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.invalidate(invoiceDetailProvider(invoiceId)), + child: const Text('Thử lại'), + ), + ], + ), + ); + } + + /// Share invoice + void _shareInvoice(BuildContext context) { + SharePlus.instance.share( + ShareParams( + text: 'Chi tiết hóa đơn #$invoiceId - EuroTile Worker', + subject: 'Hóa đơn #$invoiceId', + ), + ); + } + + /// Contact support + void _contactSupport(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Hotline hỗ trợ: 1900 1234'), + duration: Duration(seconds: 3), + ), + ); + } +} + +/// Status Badge Widget - with uppercase text +class _StatusBadge extends StatelessWidget { + const _StatusBadge({ + required this.status, + required this.statusColor, + }); + + final String status; + final String statusColor; + + @override + Widget build(BuildContext context) { + Color backgroundColor; + Color textColor; + + switch (statusColor.toLowerCase()) { + case 'success': + backgroundColor = const Color(0xFFD1FAE5); + textColor = const Color(0xFF065F46); + case 'danger': + backgroundColor = const Color(0xFFFEF3C7); + textColor = const Color(0xFFD97706); + case 'warning': + backgroundColor = const Color(0xFFFEF3C7); + textColor = const Color(0xFFD97706); + case 'info': + backgroundColor = const Color(0xFFE0E7FF); + textColor = const Color(0xFF3730A3); + default: + backgroundColor = const Color(0xFFF3F4F6); + textColor = const Color(0xFF6B7280); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + status.toUpperCase(), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ); + } +} + +/// Meta Item Widget +class _MetaItem extends StatelessWidget { + const _MetaItem({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ); + } +} + +/// Company Info Block Widget +class _CompanyInfoBlock extends StatelessWidget { + const _CompanyInfoBlock({ + required this.icon, + required this.iconColor, + required this.title, + required this.lines, + }); + + final IconData icon; + final Color iconColor; + final String title; + final List<_InfoLine> lines; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FaIcon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + ...lines, + ], + ); + } +} + +/// Info Line Widget +class _InfoLine extends StatelessWidget { + const _InfoLine({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: RichText( + text: TextSpan( + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurfaceVariant, + height: 1.6, + ), + children: [ + TextSpan( + text: '$label: ', + style: const TextStyle(fontWeight: FontWeight.w400), + ), + TextSpan( + text: value, + style: TextStyle( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + ); + } +} + +/// Summary Row Widget +class _SummaryRow extends StatelessWidget { + const _SummaryRow({ + required this.label, + required this.value, + this.valueColor, + }); + + final String label; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 15, + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: valueColor ?? colorScheme.onSurface, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/invoices/presentation/pages/invoices_page.dart b/lib/features/invoices/presentation/pages/invoices_page.dart new file mode 100644 index 0000000..7ae869e --- /dev/null +++ b/lib/features/invoices/presentation/pages/invoices_page.dart @@ -0,0 +1,362 @@ +/// Page: Invoices Page +/// +/// Displays list of invoices following html/invoice-list.html design. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/router/app_router.dart'; +import 'package:worker/core/utils/extensions.dart'; +import 'package:worker/features/invoices/domain/entities/invoice.dart'; +import 'package:worker/features/invoices/presentation/providers/invoices_provider.dart'; + +/// Invoices Page +/// +/// Features: +/// - List of invoice cards +/// - Status badges (Đã thanh toán, Chưa thanh toán, Thanh toán 1 phần) +/// - Pull-to-refresh +/// - Empty state +/// - Navigation to invoice detail +class InvoicesPage extends ConsumerWidget { + const InvoicesPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final invoicesAsync = ref.watch(invoicesProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surfaceContainerLowest, + appBar: AppBar( + leading: IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => context.pop(), + ), + title: Text( + 'Hóa đơn đã mua', + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + elevation: AppBarSpecs.elevation, + backgroundColor: colorScheme.surface, + centerTitle: false, + actions: const [SizedBox(width: AppSpacing.sm)], + ), + body: invoicesAsync.when( + data: (invoices) { + if (invoices.isEmpty) { + return _buildEmptyState(context, ref); + } + + return RefreshIndicator( + onRefresh: () async { + await ref.read(invoicesProvider.notifier).refresh(); + }, + child: ListView.builder( + padding: const EdgeInsets.all(20), + itemCount: invoices.length, + itemBuilder: (context, index) { + final invoice = invoices[index]; + return _InvoiceCard( + invoice: invoice, + onTap: () => context.push( + RouteNames.invoiceDetail.replaceFirst(':id', invoice.name), + ), + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => _buildErrorState(context, ref, error), + ), + ); + } + + /// Build error state + Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FaIcon( + FontAwesomeIcons.circleExclamation, + size: 64, + color: colorScheme.error.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + Text( + 'Có lỗi xảy ra', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + error.toString(), + style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.invalidate(invoicesProvider), + child: const Text('Thử lại'), + ), + ], + ), + ); + } + + /// Build empty state + Widget _buildEmptyState(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + return RefreshIndicator( + onRefresh: () async { + await ref.read(invoicesProvider.notifier).refresh(); + }, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + SizedBox( + height: 500, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FaIcon( + FontAwesomeIcons.fileInvoiceDollar, + size: 64, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 20), + Text( + 'Không có hóa đơn nào', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 8), + Text( + 'Khi bạn mua hàng, hóa đơn sẽ xuất hiện ở đây', + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Invoice Card Widget +class _InvoiceCard extends StatelessWidget { + const _InvoiceCard({ + required this.invoice, + this.onTap, + }); + + final Invoice invoice; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.only(bottom: 16), + elevation: 2, + shadowColor: colorScheme.shadow.withValues(alpha: 0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colorScheme.outline.withValues(alpha: 0.1), width: 1), + ), + color: colorScheme.surface, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Invoice ID and Status + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Invoice ID and Date + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '#${invoice.name}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + 'Ngày xuất: ${invoice.formattedDate}', + style: TextStyle( + fontSize: 13, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Status Badge + _StatusBadge( + status: invoice.status, + statusColor: invoice.statusColor, + ), + ], + ), + + const SizedBox(height: 12), + + // Details Section + Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: colorScheme.outlineVariant), + bottom: BorderSide(color: colorScheme.outlineVariant), + ), + ), + child: Column( + children: [ + // Order ID (if available) + if (invoice.orderId != null) + _DetailRow( + label: 'Đơn hàng:', + value: '#${invoice.orderId}', + ), + _DetailRow( + label: 'Tổng tiền:', + value: invoice.grandTotal.toVNCurrency, + isTotal: true, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Status Badge Widget +class _StatusBadge extends StatelessWidget { + const _StatusBadge({ + required this.status, + required this.statusColor, + }); + + final String status; + final String statusColor; + + @override + Widget build(BuildContext context) { + Color backgroundColor; + Color textColor; + + switch (statusColor.toLowerCase()) { + case 'success': + backgroundColor = const Color(0xFFD1FAE5); + textColor = const Color(0xFF065F46); + case 'danger': + backgroundColor = const Color(0xFFFEF3C7); + textColor = const Color(0xFFD97706); + case 'warning': + backgroundColor = const Color(0xFFFEF3C7); + textColor = const Color(0xFFD97706); + case 'info': + backgroundColor = const Color(0xFFE0E7FF); + textColor = const Color(0xFF3730A3); + default: + backgroundColor = const Color(0xFFF3F4F6); + textColor = const Color(0xFF6B7280); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + status, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ); + } +} + +/// Detail Row Widget +class _DetailRow extends StatelessWidget { + const _DetailRow({ + required this.label, + required this.value, + this.isTotal = false, + }); + + final String label; + final String value; + final bool isTotal; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: TextStyle( + fontSize: isTotal ? 16 : 14, + fontWeight: isTotal ? FontWeight.w700 : FontWeight.w600, + color: isTotal ? colorScheme.error : colorScheme.onSurface, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/invoices/presentation/providers/invoices_provider.dart b/lib/features/invoices/presentation/providers/invoices_provider.dart new file mode 100644 index 0000000..2b98715 --- /dev/null +++ b/lib/features/invoices/presentation/providers/invoices_provider.dart @@ -0,0 +1,67 @@ +/// Invoices Provider +/// +/// Riverpod providers for managing invoices state. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/core/network/dio_client.dart'; +import 'package:worker/features/invoices/data/datasources/invoice_remote_datasource.dart'; +import 'package:worker/features/invoices/data/repositories/invoice_repository_impl.dart'; +import 'package:worker/features/invoices/domain/entities/invoice.dart'; +import 'package:worker/features/invoices/domain/repositories/invoice_repository.dart'; + +part 'invoices_provider.g.dart'; + +/// Invoice Repository Provider +@riverpod +Future invoiceRepository(Ref ref) async { + final dioClient = await ref.watch(dioClientProvider.future); + final remoteDataSource = InvoiceRemoteDataSource(dioClient); + return InvoiceRepositoryImpl(remoteDataSource); +} + +/// Invoices Provider +/// +/// Provides list of all invoices from repository. +@riverpod +class Invoices extends _$Invoices { + @override + Future> build() async { + try { + final repository = await ref.read(invoiceRepositoryProvider.future); + final invoices = await repository.getInvoicesList( + limitStart: 0, + limitPageLength: 0, // 0 = get all + ); + // Sort by posting date (newest first) + invoices.sort((a, b) => b.postingDate.compareTo(a.postingDate)); + return invoices; + } catch (e) { + throw Exception('Failed to load invoices: $e'); + } + } + + /// Refresh invoices + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = await ref.read(invoiceRepositoryProvider.future); + final invoices = await repository.getInvoicesList( + limitStart: 0, + limitPageLength: 0, + ); + // Sort by posting date (newest first) + invoices.sort((a, b) => b.postingDate.compareTo(a.postingDate)); + return invoices; + }); + } +} + +/// Invoice Detail Provider +/// +/// Provides invoice detail by ID. +@riverpod +Future invoiceDetail(Ref ref, String name) async { + final repository = await ref.watch(invoiceRepositoryProvider.future); + return await repository.getInvoiceDetail(name); +} diff --git a/lib/features/invoices/presentation/providers/invoices_provider.g.dart b/lib/features/invoices/presentation/providers/invoices_provider.g.dart new file mode 100644 index 0000000..acdf3ce --- /dev/null +++ b/lib/features/invoices/presentation/providers/invoices_provider.g.dart @@ -0,0 +1,202 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'invoices_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Invoice Repository Provider + +@ProviderFor(invoiceRepository) +const invoiceRepositoryProvider = InvoiceRepositoryProvider._(); + +/// Invoice Repository Provider + +final class InvoiceRepositoryProvider + extends + $FunctionalProvider< + AsyncValue, + InvoiceRepository, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + /// Invoice Repository Provider + const InvoiceRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'invoiceRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$invoiceRepositoryHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return invoiceRepository(ref); + } +} + +String _$invoiceRepositoryHash() => r'16ac0418e4a5522544346b8af22d3fcf33071016'; + +/// Invoices Provider +/// +/// Provides list of all invoices from repository. + +@ProviderFor(Invoices) +const invoicesProvider = InvoicesProvider._(); + +/// Invoices Provider +/// +/// Provides list of all invoices from repository. +final class InvoicesProvider + extends $AsyncNotifierProvider> { + /// Invoices Provider + /// + /// Provides list of all invoices from repository. + const InvoicesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'invoicesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$invoicesHash(); + + @$internal + @override + Invoices create() => Invoices(); +} + +String _$invoicesHash() => r'fa724059f84f945c52bb7b8508e768d1980e8fe3'; + +/// Invoices Provider +/// +/// Provides list of all invoices from repository. + +abstract class _$Invoices extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Invoice Detail Provider +/// +/// Provides invoice detail by ID. + +@ProviderFor(invoiceDetail) +const invoiceDetailProvider = InvoiceDetailFamily._(); + +/// Invoice Detail Provider +/// +/// Provides invoice detail by ID. + +final class InvoiceDetailProvider + extends $FunctionalProvider, Invoice, FutureOr> + with $FutureModifier, $FutureProvider { + /// Invoice Detail Provider + /// + /// Provides invoice detail by ID. + const InvoiceDetailProvider._({ + required InvoiceDetailFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'invoiceDetailProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$invoiceDetailHash(); + + @override + String toString() { + return r'invoiceDetailProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return invoiceDetail(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is InvoiceDetailProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$invoiceDetailHash() => r'ca2993098ab182836a9c0272ae785eba87b83c83'; + +/// Invoice Detail Provider +/// +/// Provides invoice detail by ID. + +final class InvoiceDetailFamily extends $Family + with $FunctionalFamilyOverride, String> { + const InvoiceDetailFamily._() + : super( + retry: null, + name: r'invoiceDetailProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Invoice Detail Provider + /// + /// Provides invoice detail by ID. + + InvoiceDetailProvider call(String name) => + InvoiceDetailProvider._(argument: name, from: this); + + @override + String toString() => r'invoiceDetailProvider'; +}