From 3515ddac1c1da7a44b57bfbf1896131dc301489c Mon Sep 17 00:00:00 2001 From: HaiAu2501 Date: Wed, 25 Dec 2024 12:58:51 +0700 Subject: [PATCH] Add csv and file_picker dependencies; update FeesPage and DonationsTab to utilize TableRepository for fee management --- lib/features/admin/data/fees_repository.dart | 1 + lib/features/admin/data/table_repository.dart | 401 +++++++++++++ .../admin/presentation/fees_page.dart | 18 +- .../presentation/tabs/donations_tab.dart | 198 ++++++- .../admin/presentation/tabs/fees_tab.dart | 559 +++++++++++++++++- pubspec.lock | 16 + pubspec.yaml | 2 + 7 files changed, 1176 insertions(+), 19 deletions(-) create mode 100644 lib/features/admin/data/table_repository.dart diff --git a/lib/features/admin/data/fees_repository.dart b/lib/features/admin/data/fees_repository.dart index d2fc780..ef3dd32 100644 --- a/lib/features/admin/data/fees_repository.dart +++ b/lib/features/admin/data/fees_repository.dart @@ -392,6 +392,7 @@ class FeesRepository { rooms.add({ 'Số tiền đóng': 0, // Khởi tạo giá trị là 0 'Ngày đóng': null, // Khởi tạo giá trị là null + 'Người đóng': null, // Khởi tạo giá trị là null }); } floors[floorName] = rooms; diff --git a/lib/features/admin/data/table_repository.dart b/lib/features/admin/data/table_repository.dart new file mode 100644 index 0000000..f744ef8 --- /dev/null +++ b/lib/features/admin/data/table_repository.dart @@ -0,0 +1,401 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; + +class RoomData { + final int roomNumber; + final int paidAmount; + final String paymentDate; + final String payer; + + RoomData({ + required this.roomNumber, + required this.paidAmount, + required this.paymentDate, + required this.payer, + }); +} + +class TableRepository { + final String apiKey; + final String projectId; + + TableRepository({ + required this.apiKey, + required this.projectId, + }); + + /// Lấy danh sách tên phí + Future> getFeeNames(String collection, String idToken) async { + final url = 'https://firestore.googleapis.com/v1/projects/$projectId' + '/databases/(default)/documents/$collection?key=$apiKey'; + + final response = await http.get( + Uri.parse(url), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final docs = data['documents'] ?? []; + return docs.map((doc) => doc['fields']['name']['stringValue'] as String).toList(); + } else { + throw Exception('Failed to fetch fee names: ' + '${response.statusCode} ${response.body}'); + } + } + + /// Lấy dữ liệu phòng cho 1 tầng + Future> getFloorData( + String collection, + String feeName, + int floorNumber, + String idToken, + ) async { + final floorName = 'Tầng ${floorNumber.toString().padLeft(2, '0')}'; + final floorPath = '`$floorName`'; // backtick + + // Query document + final queryUrl = 'https://firestore.googleapis.com/v1/projects/$projectId' + '/databases/(default)/documents:runQuery?key=$apiKey'; + + final queryBody = { + "structuredQuery": { + "from": [ + {"collectionId": collection} + ], + "where": { + "fieldFilter": { + "field": {"fieldPath": "name"}, + "op": "EQUAL", + "value": {"stringValue": feeName} + } + }, + "select": { + // chỉ lấy fieldPath = `Tầng xx` + "fields": [ + {"fieldPath": floorPath} + ] + } + } + }; + + final res = await http.post( + Uri.parse(queryUrl), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + body: jsonEncode(queryBody), + ); + if (res.statusCode != 200) { + throw Exception('Failed to fetch floor data: ' + '${res.statusCode} ${res.body}'); + } + + final results = jsonDecode(res.body); + if (results is List && results.isNotEmpty && results[0]['document'] != null) { + final doc = results[0]['document']; + final fields = doc['fields'] ?? {}; + final floorData = fields[floorName]?['arrayValue']?['values'] ?? []; + + // 20 phòng + return List.generate(20, (i) { + final idx = i + 1; + final room = (idx < floorData.length) ? floorData[idx] : null; + final rf = room?['mapValue']?['fields'] ?? {}; + + return RoomData( + roomNumber: i + 1, + paidAmount: int.tryParse(rf['Số tiền đóng']?['integerValue'] ?? '0') ?? 0, + paymentDate: rf['Ngày đóng']?['timestampValue'] != null ? _formatDate(DateTime.parse(rf['Ngày đóng']['timestampValue'])) : 'Chưa đóng', + payer: rf['Người đóng']?['stringValue'] ?? 'Không có', + ); + }); + } else { + // trả phòng default + return List.generate(20, (i) { + return RoomData( + roomNumber: i + 1, + paidAmount: 0, + paymentDate: 'Chưa đóng', + payer: 'Không có', + ); + }); + } + } + + /// Update 1 phòng + Future updateRoomData( + String collection, + String feeName, + int floorNumber, + int roomNumber, + int paidAmount, + DateTime? paymentDate, + String? payer, + String idToken, + ) async { + // Tên field + final floorName = 'Tầng ${floorNumber.toString().padLeft(2, '0')}'; + // Path (backtick) + final floorPath = '`$floorName`'; + + // 1) Tìm doc + final queryUrl = 'https://firestore.googleapis.com/v1/projects/$projectId' + '/databases/(default)/documents:runQuery?key=$apiKey'; + + final queryBody = { + "structuredQuery": { + "from": [ + {"collectionId": collection} + ], + "where": { + "fieldFilter": { + "field": {"fieldPath": "name"}, + "op": "EQUAL", + "value": {"stringValue": feeName} + } + } + } + }; + + final queryRes = await http.post( + Uri.parse(queryUrl), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + body: jsonEncode(queryBody), + ); + if (queryRes.statusCode != 200) { + throw Exception('Failed to run query: ' + '${queryRes.statusCode} ${queryRes.body}'); + } + + final queryJson = jsonDecode(queryRes.body); + if (queryJson is List && queryJson.isNotEmpty && queryJson[0]['document'] != null) { + final docPath = queryJson[0]['document']['name']; + + // 2) Lấy doc cũ + final getUrl = 'https://firestore.googleapis.com/v1/$docPath?key=$apiKey'; + final getRes = await http.get( + Uri.parse(getUrl), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + ); + if (getRes.statusCode != 200) { + throw Exception('Failed to fetch document: ' + '${getRes.statusCode} ${getRes.body}'); + } + + final docJson = jsonDecode(getRes.body); + final fields = docJson['fields'] ?? {}; + final floorArray = (fields[floorName]?['arrayValue']?['values'] ?? []).cast(); + + // 3) Update + if (floorArray.length > roomNumber && floorArray[roomNumber] != null) { + final rf = floorArray[roomNumber]['mapValue']['fields']; + rf['Số tiền đóng'] = {'integerValue': paidAmount.toString()}; + rf['Ngày đóng'] = (paymentDate != null) ? {'timestampValue': paymentDate.toUtc().toIso8601String()} : {'nullValue': null}; + rf['Người đóng'] = (payer != null) ? {'stringValue': payer} : {'nullValue': null}; + } else { + // thêm + while (floorArray.length <= roomNumber) { + floorArray.add(null); + } + floorArray[roomNumber] = { + 'mapValue': { + 'fields': { + 'Số tiền đóng': {'integerValue': paidAmount.toString()}, + 'Ngày đóng': (paymentDate != null) ? {'timestampValue': paymentDate.toUtc().toIso8601String()} : {'nullValue': null}, + 'Người đóng': (payer != null) ? {'stringValue': payer} : {'nullValue': null}, + } + } + }; + } + + // 4) PATCH + updateMask qua **query parameters**, không để trong body + // => ?updateMask.fieldPaths=`Tầng 01` + // Phải url-encode backtick (` -> %60) và dấu cách ( -> %20) + final encodedFieldPath = Uri.encodeQueryComponent(floorPath); + final patchUrl = 'https://firestore.googleapis.com/v1/$docPath' + '?updateMask.fieldPaths=$encodedFieldPath' + '&key=$apiKey'; + + // body: document resource => "fields": ... + final patchBody = jsonEncode({ + "fields": { + floorName: { + "arrayValue": {"values": floorArray} + } + } + }); + + final patchRes = await http.patch( + Uri.parse(patchUrl), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + body: patchBody, + ); + if (patchRes.statusCode != 200) { + throw Exception('Failed to update room data: ${patchRes.body}'); + } + } else { + throw Exception("Document with name=$feeName not found."); + } + } + + /// (Tuỳ chọn) update nhiều phòng + Future updateRoomsData( + String collection, + String feeName, + int floorNumber, + List roomList, + String idToken, + ) async { + final floorName = 'Tầng ${floorNumber.toString().padLeft(2, '0')}'; + final floorPath = '`$floorName`'; + + // 1) Tìm doc + final queryUrl = 'https://firestore.googleapis.com/v1/projects/$projectId' + '/databases/(default)/documents:runQuery?key=$apiKey'; + + final queryBody = { + "structuredQuery": { + "from": [ + {"collectionId": collection} + ], + "where": { + "fieldFilter": { + "field": {"fieldPath": "name"}, + "op": "EQUAL", + "value": {"stringValue": feeName} + } + } + } + }; + + final queryRes = await http.post( + Uri.parse(queryUrl), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + body: jsonEncode(queryBody), + ); + if (queryRes.statusCode != 200) { + throw Exception('Failed to run query: ' + '${queryRes.statusCode} ${queryRes.body}'); + } + + final queryJson = jsonDecode(queryRes.body); + if (queryJson is List && queryJson.isNotEmpty && queryJson[0]['document'] != null) { + final docPath = queryJson[0]['document']['name']; + + // 2) Lấy doc cũ + final getUrl = 'https://firestore.googleapis.com/v1/$docPath?key=$apiKey'; + final getRes = await http.get( + Uri.parse(getUrl), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + ); + if (getRes.statusCode != 200) { + throw Exception('Failed to fetch document: ' + '${getRes.statusCode} ${getRes.body}'); + } + + final docJson = jsonDecode(getRes.body); + final fields = docJson['fields'] ?? {}; + final floorArray = (fields[floorName]?['arrayValue']?['values'] ?? []).cast(); + + // Tạo mảng 21 phần tử + final newFloorArray = List.generate(21, (_) => null); + for (int i = 0; i < floorArray.length; i++) { + if (i < newFloorArray.length) { + newFloorArray[i] = floorArray[i]; + } + } + + // Duyệt roomList + for (final rd in roomList) { + if (rd.roomNumber < 1 || rd.roomNumber >= newFloorArray.length) { + continue; + } + var isoStamp = ''; + if (rd.paymentDate != 'Chưa đóng') { + isoStamp = _parseAndToIso(rd.paymentDate); + } + newFloorArray[rd.roomNumber] = { + 'mapValue': { + 'fields': { + 'Số tiền đóng': {'integerValue': rd.paidAmount.toString()}, + 'Ngày đóng': isoStamp.isNotEmpty ? {'timestampValue': isoStamp} : {'nullValue': null}, + 'Người đóng': (rd.payer != 'Không có') ? {'stringValue': rd.payer} : {'nullValue': null}, + } + } + }; + } + + // 4) PATCH (chỉ update field floorName) + final encodedFieldPath = Uri.encodeQueryComponent(floorPath); + final patchUrl = 'https://firestore.googleapis.com/v1/$docPath' + '?updateMask.fieldPaths=$encodedFieldPath' + '&key=$apiKey'; + + final patchBody = jsonEncode({ + "fields": { + floorName: { + "arrayValue": {"values": newFloorArray} + } + } + }); + + final patchRes = await http.patch( + Uri.parse(patchUrl), + headers: { + 'Authorization': 'Bearer $idToken', + 'Content-Type': 'application/json', + }, + body: patchBody, + ); + if (patchRes.statusCode != 200) { + throw Exception('Failed to bulk update: ${patchRes.body}'); + } + } else { + throw Exception("Document with name=$feeName not found."); + } + } + + // Helpers + String _formatDate(DateTime date) { + return DateFormat('dd/MM/yyyy').format(date); + } + + String _parseAndToIso(String dateStr) { + try { + final parts = dateStr.split('/'); + if (parts.length == 3) { + final day = int.parse(parts[0]); + final month = int.parse(parts[1]); + var year = int.parse(parts[2]); + if (year < 100) { + year += 2000; + } + final dt = DateTime(year, month, day); + return dt.toUtc().toIso8601String(); + } + } catch (_) { + // ignore + } + return DateTime.now().toUtc().toIso8601String(); + } +} diff --git a/lib/features/admin/presentation/fees_page.dart b/lib/features/admin/presentation/fees_page.dart index ecf6f14..a3cc44b 100644 --- a/lib/features/admin/presentation/fees_page.dart +++ b/lib/features/admin/presentation/fees_page.dart @@ -5,6 +5,7 @@ import 'tabs/finance_tab.dart'; import 'tabs/fees_tab.dart'; import 'tabs/donations_tab.dart'; import '../data/fees_repository.dart'; +import '../data/table_repository.dart'; import '../../.authentication/data/auth_service.dart'; import '../../.authentication/presentation/login_page.dart'; @@ -24,6 +25,7 @@ class FeesPage extends StatefulWidget { class _FeesPageState extends State with SingleTickerProviderStateMixin { late FeesRepository feesRepository; + late TableRepository tableRepository; late TabController _tabController; @override @@ -34,6 +36,10 @@ class _FeesPageState extends State with SingleTickerProviderStateMixin apiKey: widget.authService.apiKey, projectId: widget.authService.projectId, ); + tableRepository = TableRepository( + apiKey: widget.authService.apiKey, + projectId: widget.authService.projectId, + ); _tabController = TabController(length: 3, vsync: this); } @@ -69,8 +75,16 @@ class _FeesPageState extends State with SingleTickerProviderStateMixin authService: widget.authService, idToken: widget.idToken, ), - FeesTab(), // Placeholder cho tab "Phí bắt buộc" - DonationsTab(), // Placeholder cho tab "Khoản đóng góp" + FeesTab( + tableRepository: tableRepository, + authService: widget.authService, + idToken: widget.idToken, + ), // Placeholder cho tab "Phí bắt buộc" + DonationsTab( + tableRepository: tableRepository, + authService: widget.authService, + idToken: widget.idToken, + ), // Placeholder cho tab "Khoản đóng góp" ], ), ), diff --git a/lib/features/admin/presentation/tabs/donations_tab.dart b/lib/features/admin/presentation/tabs/donations_tab.dart index da8e095..64f1319 100644 --- a/lib/features/admin/presentation/tabs/donations_tab.dart +++ b/lib/features/admin/presentation/tabs/donations_tab.dart @@ -1,17 +1,197 @@ -// lib/features/admin/presentation/tabs/donations_tab.dart +// lib/features/admin/presentation/widgets/donations_tab.dart import 'package:flutter/material.dart'; +import '../../data/table_repository.dart'; +import '../../../.authentication/data/auth_service.dart'; -class DonationsTab extends StatelessWidget { - const DonationsTab({Key? key}) : super(key: key); +class DonationsTab extends StatefulWidget { + final TableRepository tableRepository; + final AuthenticationService authService; + final String idToken; + + const DonationsTab({ + Key? key, + required this.tableRepository, + required this.authService, + required this.idToken, + }) : super(key: key); + + @override + _DonationsTabState createState() => _DonationsTabState(); +} + +class _DonationsTabState extends State { + List feeNames = []; + String? selectedFee; + int? selectedFloor; + + List roomData = []; + bool isLoading = false; + String errorMessage = ''; + + @override + void initState() { + super.initState(); + _fetchFeeNames(); + } + + Future _fetchFeeNames() async { + setState(() { + isLoading = true; + errorMessage = ''; + }); + + try { + final names = await widget.tableRepository.getFeeNames('donations-table', widget.idToken); + setState(() { + feeNames = names; + }); + } catch (e) { + setState(() { + errorMessage = 'Lỗi khi tải danh sách phí: $e'; + }); + } finally { + setState(() { + isLoading = false; + }); + } + } + + Future _fetchRoomData() async { + if (selectedFee == null || selectedFloor == null) return; + + setState(() { + isLoading = true; + errorMessage = ''; + roomData = []; + }); + + try { + final data = await widget.tableRepository.getFloorData('donations-table', selectedFee!, selectedFloor!, widget.idToken); + setState(() { + roomData = data; + }); + } catch (e) { + setState(() { + errorMessage = 'Lỗi khi tải dữ liệu phòng: $e'; + }); + } finally { + setState(() { + isLoading = false; + }); + } + } + + // Dropdown items for floor numbers + List> get floorDropdownItems { + return List.generate(50, (index) { + int floor = index + 1; + return DropdownMenuItem( + value: floor, + child: Text('Tầng ${floor.toString().padLeft(2, '0')}'), + ); + }); + } @override Widget build(BuildContext context) { - return Center( - child: Text( - 'Khoản đóng góp đang được phát triển...', - style: TextStyle(fontSize: 16, color: Colors.grey), - ), - ); + return isLoading + ? Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // Dropdown for Fee Names + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Chọn Khoản Phí', + border: OutlineInputBorder(), + ), + value: selectedFee, + items: feeNames + .map((name) => DropdownMenuItem( + value: name, + child: Text(name), + )) + .toList(), + onChanged: (value) { + setState(() { + selectedFee = value; + roomData = []; + }); + }, + validator: (value) => value == null ? 'Vui lòng chọn khoản phí' : null, + ), + SizedBox(height: 16), + + // Dropdown for Floor Numbers + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Chọn Tầng', + border: OutlineInputBorder(), + ), + value: selectedFloor, + items: floorDropdownItems, + onChanged: (value) { + setState(() { + selectedFloor = value; + roomData = []; + }); + }, + validator: (value) => value == null ? 'Vui lòng chọn tầng' : null, + ), + SizedBox(height: 16), + + // Button to Fetch Data + ElevatedButton( + onPressed: () { + if (selectedFee != null && selectedFloor != null) { + _fetchRoomData(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Vui lòng chọn cả khoản phí và tầng')), + ); + } + }, + child: Text('Xem Dữ Liệu'), + ), + SizedBox(height: 16), + + // Display Error Message + if (errorMessage.isNotEmpty) + Text( + errorMessage, + style: TextStyle(color: Colors.red), + ), + + // Display Table Data + if (roomData.isNotEmpty) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, // Cuộn ngang + child: SingleChildScrollView( + scrollDirection: Axis.vertical, // Cuộn dọc + child: DataTable( + columns: [ + DataColumn(label: Text('Số Phòng')), + DataColumn(label: Text('Tiền Đã Đóng')), + DataColumn(label: Text('Ngày Đóng')), + ], + rows: roomData.map((room) { + return DataRow( + cells: [ + DataCell(Text(room.roomNumber.toString())), + DataCell(Text('${room.paidAmount} kVNĐ')), + DataCell(Text(room.paymentDate)), + ], + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ); } } diff --git a/lib/features/admin/presentation/tabs/fees_tab.dart b/lib/features/admin/presentation/tabs/fees_tab.dart index 589b740..a949b51 100644 --- a/lib/features/admin/presentation/tabs/fees_tab.dart +++ b/lib/features/admin/presentation/tabs/fees_tab.dart @@ -1,17 +1,560 @@ -// lib/features/admin/presentation/tabs/fees_tab.dart +// lib/features/admin/presentation/widgets/fees_tab.dart import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:csv/csv.dart'; +import 'package:intl/intl.dart'; +import 'dart:convert'; +import '../../data/table_repository.dart'; +import '../../../.authentication/data/auth_service.dart'; -class FeesTab extends StatelessWidget { - const FeesTab({Key? key}) : super(key: key); +class FeesTab extends StatefulWidget { + final TableRepository tableRepository; + final AuthenticationService authService; + final String idToken; + + const FeesTab({ + Key? key, + required this.tableRepository, + required this.authService, + required this.idToken, + }) : super(key: key); + + @override + _FeesTabState createState() => _FeesTabState(); +} + +class _FeesTabState extends State { + List feeNames = []; + String? selectedFee; + int? selectedFloor; + + List roomData = []; + bool isLoading = false; + String errorMessage = ''; + + @override + void initState() { + super.initState(); + _fetchFeeNames(); + } + + /// Lấy danh sách tên phí + Future _fetchFeeNames() async { + setState(() { + isLoading = true; + errorMessage = ''; + }); + + try { + final names = await widget.tableRepository.getFeeNames('fees-table', widget.idToken); + setState(() { + feeNames = names; + }); + } catch (e) { + setState(() { + errorMessage = 'Lỗi khi tải danh sách phí: $e'; + }); + } finally { + setState(() { + isLoading = false; + }); + } + } + + /// Lấy dữ liệu phòng + Future _fetchRoomData() async { + if (selectedFee == null || selectedFloor == null) return; + + setState(() { + isLoading = true; + errorMessage = ''; + roomData = []; + }); + + try { + final data = await widget.tableRepository.getFloorData('fees-table', selectedFee!, selectedFloor!, widget.idToken); + setState(() { + roomData = data; + }); + } catch (e) { + setState(() { + errorMessage = 'Lỗi khi tải dữ liệu phòng: $e'; + }); + } finally { + setState(() { + isLoading = false; + }); + } + } + + List> get floorDropdownItems { + return List.generate(50, (index) { + final floorNum = index + 1; + return DropdownMenuItem( + value: floorNum, + child: Text('Tầng ${floorNum.toString().padLeft(2, '0')}'), + ); + }); + } + + /// Dialog cập nhật dữ liệu + void _showUpdateDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("Cập nhật dữ liệu"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton.icon( + icon: Icon(Icons.edit), + label: Text("Nhập dữ liệu phòng"), + onPressed: () { + Navigator.pop(context); + _showManualUpdateForm(); + }, + ), + SizedBox(height: 10), + ElevatedButton.icon( + icon: Icon(Icons.upload_file), + label: Text("Tải lên file CSV"), + onPressed: () { + Navigator.pop(context); + _showCSVUpload(); + }, + ), + ], + ), + ); + }, + ); + } + + /// Form cập nhật thủ công + void _showManualUpdateForm() { + showDialog( + context: context, + builder: (context) { + return ManualUpdateDialog( + tableRepository: widget.tableRepository, + collection: 'fees-table', + feeName: selectedFee!, + floorNumber: selectedFloor!, + idToken: widget.idToken, + onUpdate: _fetchRoomData, + ); + }, + ); + } + + /// Tải file CSV + Future _showCSVUpload() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['csv'], + ); + if (result != null && result.files.single.bytes != null) { + final bytes = result.files.single.bytes!; + final csvString = utf8.decode(bytes); + final csvTable = CsvToListConverter().convert(csvString); + + List parsedData = []; + for (var row in csvTable) { + if (row.length < 4) continue; + final roomNumber = int.tryParse(row[0].toString()) ?? 0; + final paidAmount = int.tryParse(row[1].toString()) ?? 0; + final paymentDateStr = row[2].toString(); + final payerStr = row[3].toString(); + + if (roomNumber < 1 || roomNumber > 20) continue; + + DateTime? paymentDate; + try { + final parts = paymentDateStr.split('/'); + if (parts.length == 3) { + final day = int.parse(parts[0]); + final month = int.parse(parts[1]); + final year = int.parse(parts[2]); + paymentDate = DateTime(2000 + year, month, day); + } + } catch (_) { + paymentDate = null; + } + + parsedData.add(RoomData( + roomNumber: roomNumber, + paidAmount: paidAmount, + paymentDate: paymentDate != null ? _formatDate(paymentDate) : 'Chưa đóng', + payer: payerStr.isNotEmpty ? payerStr : 'Không có', + )); + } + + if (parsedData.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('File CSV không hợp lệ hoặc không có dữ liệu.')), + ); + return; + } + + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text("Xác nhận cập nhật"), + content: Text("Bạn có chắc chắn muốn cập nhật dữ liệu từ file CSV?"), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: Text("Hủy")), + TextButton(onPressed: () => Navigator.pop(context, true), child: Text("Đồng ý")), + ], + ), + ); + + if (confirm == true) { + setState(() { + isLoading = true; + errorMessage = ''; + }); + try { + await widget.tableRepository.updateRoomsData( + 'fees-table', + selectedFee!, + selectedFloor!, + parsedData, + widget.idToken, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cập nhật dữ liệu thành công.')), + ); + _fetchRoomData(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Lỗi khi cập nhật dữ liệu: $e')), + ); + } finally { + setState(() => isLoading = false); + } + } + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Lỗi khi tải file: $e')), + ); + } + } + + /// Định dạng DateTime -> dd/MM/yy + String _formatDate(DateTime date) { + final day = date.day.toString().padLeft(2, '0'); + final month = date.month.toString().padLeft(2, '0'); + final year = (date.year % 100).toString().padLeft(2, '0'); + return '$day/$month/$year'; + } + + @override + Widget build(BuildContext context) { + return isLoading + ? Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // Chọn Khoản Phí + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Chọn Khoản Phí', + border: OutlineInputBorder(), + ), + value: selectedFee, + items: feeNames.map((name) { + return DropdownMenuItem(value: name, child: Text(name)); + }).toList(), + onChanged: (value) { + setState(() { + selectedFee = value; + roomData.clear(); + }); + }, + ), + SizedBox(height: 16), + + // Chọn Tầng + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Chọn Tầng', + border: OutlineInputBorder(), + ), + value: selectedFloor, + items: floorDropdownItems, + onChanged: (value) { + setState(() { + selectedFloor = value; + roomData.clear(); + }); + }, + ), + SizedBox(height: 16), + + // Nút Xem Dữ Liệu, Cập Nhật Dữ Liệu + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: () { + if (selectedFee != null && selectedFloor != null) { + _fetchRoomData(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Vui lòng chọn khoản phí và tầng')), + ); + } + }, + child: Text('Xem Dữ Liệu'), + ), + ElevatedButton.icon( + onPressed: () { + if (selectedFee != null && selectedFloor != null) { + _showUpdateDialog(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Vui lòng chọn phí & tầng trước khi cập nhật')), + ); + } + }, + icon: Icon(Icons.update), + label: Text('Cập nhật dữ liệu'), + ), + ], + ), + SizedBox(height: 16), + + if (errorMessage.isNotEmpty) + Text( + errorMessage, + style: TextStyle(color: Colors.red), + ), + + // Hiển thị bảng phòng + if (roomData.isNotEmpty) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: DataTable( + columns: [ + DataColumn(label: Text('Số Phòng')), + DataColumn(label: Text('Tiền Đã Đóng')), + DataColumn(label: Text('Ngày Đóng')), + DataColumn(label: Text('Người Đóng')), + ], + rows: roomData.map((rd) { + return DataRow(cells: [ + DataCell(Text(rd.roomNumber.toString())), + DataCell(Text('${rd.paidAmount} kVNĐ')), + DataCell(Text(rd.paymentDate)), + DataCell(Text(rd.payer)), + ]); + }).toList(), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Dialog cập nhật thủ công 1 phòng +class ManualUpdateDialog extends StatefulWidget { + final TableRepository tableRepository; + final String collection; + final String feeName; + final int floorNumber; + final String idToken; + final VoidCallback onUpdate; + + const ManualUpdateDialog({ + Key? key, + required this.tableRepository, + required this.collection, + required this.feeName, + required this.floorNumber, + required this.idToken, + required this.onUpdate, + }) : super(key: key); + + @override + _ManualUpdateDialogState createState() => _ManualUpdateDialogState(); +} + +class _ManualUpdateDialogState extends State { + final _formKey = GlobalKey(); + final _roomNumberController = TextEditingController(); + final _paidAmountController = TextEditingController(); + final _payerController = TextEditingController(); + DateTime? _selectedDate; + + bool isSubmitting = false; + String errorMessage = ''; + + @override + void dispose() { + _roomNumberController.dispose(); + _paidAmountController.dispose(); + _payerController.dispose(); + super.dispose(); + } + + Future _submitUpdates() async { + if (!_formKey.currentState!.validate() || _selectedDate == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Vui lòng điền đủ thông tin & chọn ngày đóng')), + ); + return; + } + setState(() { + isSubmitting = true; + errorMessage = ''; + }); + + try { + final roomNumber = int.parse(_roomNumberController.text.trim()); + final paidAmount = int.parse(_paidAmountController.text.trim()); + final payerStr = _payerController.text.trim().isNotEmpty ? _payerController.text.trim() : 'Không có'; + final DateTime dt = DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day); + + await widget.tableRepository.updateRoomData( + widget.collection, + widget.feeName, + widget.floorNumber, + roomNumber, + paidAmount, + dt, + payerStr != 'Không có' ? payerStr : null, + widget.idToken, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cập nhật dữ liệu thành công.')), + ); + + widget.onUpdate(); + Navigator.pop(context); + } catch (e) { + setState(() => errorMessage = 'Lỗi khi cập nhật dữ liệu: $e'); + } finally { + setState(() => isSubmitting = false); + } + } @override Widget build(BuildContext context) { - return Center( - child: Text( - 'Phí bắt buộc đang được phát triển...', - style: TextStyle(fontSize: 16, color: Colors.grey), - ), + return AlertDialog( + title: Text("Nhập Dữ Liệu Phòng"), + content: isSubmitting + ? Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Số Phòng + TextFormField( + controller: _roomNumberController, + decoration: InputDecoration( + labelText: 'Số Phòng (1-20)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (val) { + if (val == null || val.trim().isEmpty) return 'Vui lòng nhập số phòng'; + final num = int.tryParse(val.trim()); + if (num == null || num < 1 || num > 20) return 'Số phòng phải từ 1..20'; + return null; + }, + ), + SizedBox(height: 10), + + // Tiền Đóng + TextFormField( + controller: _paidAmountController, + decoration: InputDecoration( + labelText: 'Tiền Đóng (kVNĐ)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + validator: (val) { + if (val == null || val.trim().isEmpty) return 'Vui lòng nhập tiền đóng'; + final num = int.tryParse(val.trim()); + if (num == null || num < 0) return 'Tiền đóng phải là số >= 0'; + return null; + }, + ), + SizedBox(height: 10), + + // Ngày Đóng + InkWell( + onTap: () async { + final pick = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (pick != null) { + setState(() => _selectedDate = pick); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Ngày Đóng', + border: OutlineInputBorder(), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _selectedDate != null ? "${_selectedDate!.day.toString().padLeft(2, '0')}/${_selectedDate!.month.toString().padLeft(2, '0')}/${_selectedDate!.year}" : 'Chọn ngày...', + style: TextStyle( + color: _selectedDate != null ? Colors.black : Colors.grey, + ), + ), + Icon(Icons.calendar_today), + ], + ), + ), + ), + SizedBox(height: 10), + + // Người Đóng + TextFormField( + controller: _payerController, + decoration: InputDecoration( + labelText: 'Người Đóng', + border: OutlineInputBorder(), + ), + ), + SizedBox(height: 10), + + if (errorMessage.isNotEmpty) Text(errorMessage, style: TextStyle(color: Colors.red)), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: isSubmitting ? null : () => Navigator.pop(context), + child: Text("Hủy"), + ), + ElevatedButton( + onPressed: isSubmitting ? null : _submitUpdates, + child: Text("Cập nhật"), + ), + ], ); } } diff --git a/pubspec.lock b/pubspec.lock index 9be24c8..b57bc4e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.5" + csv: + dependency: "direct main" + description: + name: csv + sha256: "63ed2871dd6471193dffc52c0e6c76fb86269c00244d244297abbb355c84a86e" + url: "https://pub.dev" + source: hosted + version: "5.1.1" cupertino_icons: dependency: "direct main" description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: be325344c1f3070354a1d84a231a1ba75ea85d413774ec4bdf444c023342e030 + url: "https://pub.dev" + source: hosted + version: "5.5.0" file_selector_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a5ca098..ab56739 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: fluentui_system_icons: ^1.1.261 fl_chart: ^0.69.2 image_picker: ^1.1.2 + csv: ^5.0.0 + file_picker: ^5.2.1 # List of packages that the project depends on for development