diff --git a/app/resident_manager/lib/main.dart b/app/resident_manager/lib/main.dart index 2f52bc0..cfc18c7 100644 --- a/app/resident_manager/lib/main.dart +++ b/app/resident_manager/lib/main.dart @@ -11,10 +11,11 @@ import "src/widgets/login.dart"; import "src/widgets/payment.dart"; import "src/widgets/personal_info.dart"; import "src/widgets/register.dart"; +import "src/widgets/admin/fees.dart"; +import "src/widgets/admin/payments.dart"; import "src/widgets/admin/reg_queue.dart"; import "src/widgets/admin/residents.dart"; import "src/widgets/admin/rooms.dart"; -import "src/widgets/admin/fees.dart"; class MainApplication extends StateAwareWidget { const MainApplication({super.key, required super.state}); @@ -49,6 +50,7 @@ class _MainApplicationState extends AbstractCommonState { ApplicationRoute.adminResidentsPage: (context) => ResidentsPage(state: state), ApplicationRoute.adminRoomsPage: (context) => RoomsPage(state: state), ApplicationRoute.adminFeesPage: (context) => FeeListPage(state: state), + ApplicationRoute.adminPaymentsPage: (context) => PaymentListPage(state: state), }, initialRoute: initialRoute, localizationsDelegates: state.localization.localizationsDelegates, diff --git a/app/resident_manager/lib/src/models/payment_status.dart b/app/resident_manager/lib/src/models/payment_status.dart index 26b330e..bdceae8 100644 --- a/app/resident_manager/lib/src/models/payment_status.dart +++ b/app/resident_manager/lib/src/models/payment_status.dart @@ -10,12 +10,14 @@ class PaymentStatus { final double lowerBound; final double upperBound; final Payment? payment; + final int room; PaymentStatus({ required this.fee, required this.lowerBound, required this.upperBound, required this.payment, + required this.room, }); PaymentStatus.fromJson(dynamic data) @@ -24,6 +26,7 @@ class PaymentStatus { lowerBound: data["lower_bound"] as double, upperBound: data["upper_bound"] as double, payment: data["payment"] == null ? null : Payment.fromJson(data["payment"]), + room: data["room"] as int, ); static Future> count({ @@ -74,4 +77,36 @@ class PaymentStatus { return Result(result["code"], null); } + + static Future?>> adminQuery({ + required ApplicationState state, + required int? room, + required bool? paid, + required int offset, + required DateTime createdAfter, + required DateTime createdBefore, + }) async { + if (!state.loggedInAsAdmin) { + return Result(-1, null); + } + + final response = await state.get( + "/api/v1/admin/fees/payments", + queryParameters: { + if (room != null) "room": room.toString(), + if (paid != null) "paid": paid.toString(), + "offset": offset.toString(), + "created_after": createdAfter.toUtc().toIso8601String(), + "created_before": createdBefore.toUtc().toIso8601String(), + }, + ); + final result = json.decode(utf8.decode(response.bodyBytes)); + + if (result["code"] == 0) { + final data = result["data"] as List; + return Result(0, List.from(data.map(PaymentStatus.fromJson))); + } + + return Result(result["code"], null); + } } diff --git a/app/resident_manager/lib/src/routes.dart b/app/resident_manager/lib/src/routes.dart index ab04121..413ec34 100644 --- a/app/resident_manager/lib/src/routes.dart +++ b/app/resident_manager/lib/src/routes.dart @@ -8,4 +8,5 @@ class ApplicationRoute { static const String adminResidentsPage = "/admin/residents"; static const String adminRoomsPage = "/admin/rooms"; static const String adminFeesPage = "/admin/fees"; + static const String adminPaymentsPage = "/admin/payments"; } diff --git a/app/resident_manager/lib/src/translations.dart b/app/resident_manager/lib/src/translations.dart index a81c188..fa9a5fc 100644 --- a/app/resident_manager/lib/src/translations.dart +++ b/app/resident_manager/lib/src/translations.dart @@ -122,6 +122,8 @@ class AppLocale { static const String FeePerMotorbike = "FeePerMotorbike"; static const String FeePerCar = "FeePerCar"; static const String OK = "OK"; + static const String PaidTimestamp = "PaidTimestamp"; + static const String PaymentList = "PaymentList"; // Error codes static const String Error0 = "Error0"; @@ -273,6 +275,8 @@ class AppLocale { FeePerMotorbike: "Fee per motorbike", FeePerCar: "Fee per car", OK: "OK", + PaidTimestamp: "Paid timestamp", + PaymentList: "Payment list", // Error codes Error0: "Operation completed successfully.", @@ -425,6 +429,8 @@ class AppLocale { FeePerMotorbike: "Phí theo số lượng xe máy", FeePerCar: "Phí theo số lượng ô tô", OK: "OK", + PaidTimestamp: "Thời gian thanh toán", + PaymentList: "Danh sách thanh toán", // Error codes Error0: "Thao tác thành công.", diff --git a/app/resident_manager/lib/src/utils.dart b/app/resident_manager/lib/src/utils.dart index 98f4e51..aa53c50 100644 --- a/app/resident_manager/lib/src/utils.dart +++ b/app/resident_manager/lib/src/utils.dart @@ -168,6 +168,20 @@ DateTime snowflakeTime(int id) => fromEpoch(Duration(milliseconds: id >> 16)); String formatDateTime(DateTime time) => "${time.day}/${time.month}/${time.year} ${time.hour}:${time.minute}:${time.second}"; +String formatVND(num value) { + final str = value.round().toString(); + final buffer = StringBuffer(); + + for (int i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 == 0) { + buffer.write(","); + } + buffer.write(str[i]); + } + + return buffer.toString(); +} + String? nameValidator(BuildContext context, {required bool required, required String? value}) { if (value == null || value.isEmpty) { if (required) { diff --git a/app/resident_manager/lib/src/widgets/admin/fees.dart b/app/resident_manager/lib/src/widgets/admin/fees.dart index 7846857..4efb933 100644 --- a/app/resident_manager/lib/src/widgets/admin/fees.dart +++ b/app/resident_manager/lib/src/widgets/admin/fees.dart @@ -646,11 +646,11 @@ class _FeeListPageState extends AbstractCommonState with CommonScaf formatDateTime(f.createdAt.toLocal()), f.deadline.format("dd/mm/yyyy"), f.description, - f.lower.round().toString(), - f.upper.round().toString(), - f.perArea.round().toString(), - f.perMotorbike.round().toString(), - f.perCar.round().toString(), + formatVND(f.lower), + formatVND(f.upper), + formatVND(f.perArea), + formatVND(f.perMotorbike), + formatVND(f.perCar), ]; return DataRow2( diff --git a/app/resident_manager/lib/src/widgets/admin/payments.dart b/app/resident_manager/lib/src/widgets/admin/payments.dart new file mode 100644 index 0000000..75e8692 --- /dev/null +++ b/app/resident_manager/lib/src/widgets/admin/payments.dart @@ -0,0 +1,419 @@ +import "dart:async"; +import "dart:io"; +import "dart:math"; + +import "package:data_table_2/data_table_2.dart"; +import "package:flutter/material.dart"; +import "package:flutter_localization/flutter_localization.dart"; + +import "../common.dart"; +import "../utils.dart"; +import "../../config.dart"; +import "../../translations.dart"; +import "../../utils.dart"; +import "../../models/payment_status.dart"; + +class PaymentListPage extends StateAwareWidget { + const PaymentListPage({super.key, required super.state}); + + @override + AbstractCommonState createState() => _PaymentListPageState(); +} + +class _Pagination extends FutureHolder { + int offset = 0; + int count = 0; + int get offsetLimit => max(offset, (count + DB_PAGINATION_QUERY - 1) ~/ DB_PAGINATION_QUERY - 1); + + // final _PaymentListPageState _state; + + // _Pagination(this._state); + + @override + Future run() async => 0; +} + +class _QueryLoader extends FutureHolder { + final statuses = []; + + final _PaymentListPageState _state; + + _QueryLoader(this._state); + + @override + Future run() async { + try { + final result = await PaymentStatus.adminQuery( + state: _state.state, + room: int.tryParse(_state.room), + paid: _state.paid, + offset: DB_PAGINATION_QUERY * _state.pagination.offset, + createdAfter: _state.createdAfter ?? epoch, + createdBefore: _state.createdBefore ?? DateTime.now().add(const Duration(seconds: 3)), // SQL server timestamp may not synchronize with client + ); + + final data = result.data; + if (data != null) { + statuses.clear(); + statuses.addAll(data); + } + + return result.code; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: _state.mounted ? AppLocale.ConnectionError.getString(_state.context) : AppLocale.ConnectionError); + return null; + } + + rethrow; + } finally { + _state.refresh(); + } + } +} + +class _PaymentListPageState extends AbstractCommonState with CommonScaffoldStateMixin { + String room = ""; + bool? paid; + DateTime? createdAfter; + DateTime? createdBefore; + + bool get searching => room.isNotEmpty || paid != null || createdAfter != null || createdBefore != null; + + _Pagination? _pagination; + _Pagination get pagination => _pagination ??= _Pagination(); + + _QueryLoader? _queryLoader; + _QueryLoader get queryLoader => _queryLoader ??= _QueryLoader(this); + + void reload() { + pagination.reload(); + queryLoader.reload(); + refresh(); + } + + final _horizontalScroll = ScrollController(); + + @override + CommonScaffold build(BuildContext context) { + return CommonScaffold( + widgetState: this, + title: Text(AppLocale.PaymentList.getString(context), style: const TextStyle(fontWeight: FontWeight.bold)), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + AdminMonitorWidget( + state: state, + pushNamed: Navigator.pushReplacementNamed, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left_outlined), + onPressed: () { + if (pagination.offset > 0) { + pagination.offset--; + reload(); + } + }, + ), + FutureBuilder( + future: pagination.future, + builder: (context, _) { + final offset = pagination.offset; + return Text("${offset + 1}"); + }, + ), + IconButton( + icon: const Icon(Icons.chevron_right_outlined), + onPressed: () { + // if (pagination.offset < pagination.offsetLimit) { + pagination.offset++; + reload(); + // } + }, + ), + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () { + pagination.offset = 0; + reload(); + }, + ), + TextButton.icon( + icon: Icon(searching ? Icons.search_outlined : Icons.search_off_outlined), + label: Text( + searching ? AppLocale.Searching.getString(context) : AppLocale.Search.getString(context), + style: TextStyle(decoration: searching ? TextDecoration.underline : null), + ), + onPressed: () async { + final roomController = TextEditingController(text: room); + bool? tempPaid = paid; + DateTime? tempCreatedAfter = createdAfter; + DateTime? tempCreatedBefore = createdBefore; + + final formKey = GlobalKey(); + + void onSubmit(BuildContext context) { + if (formKey.currentState?.validate() ?? false) { + room = roomController.text; + paid = tempPaid; + createdAfter = tempCreatedAfter; + createdBefore = tempCreatedBefore; + pagination.offset = 0; + + Navigator.pop(context, true); + reload(); + } + } + + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(AppLocale.ConfigureFilter.getString(context)), + content: Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: roomController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.room_outlined), + label: Text(AppLocale.Room.getString(context)), + ), + onFieldSubmitted: (_) => onSubmit(context), + validator: (value) => roomValidator(context, required: false, value: value), + ), + const SizedBox.square(dimension: 10), + Row( + children: [ + Text(AppLocale.PaymentStatus.getString(context)), + const SizedBox.square(dimension: 5), + DropdownButton( + value: tempPaid, + hint: Text(AppLocale.PaymentStatus.getString(context)), + items: [ + DropdownMenuItem(value: null, child: Text(AppLocale.All.getString(context))), + DropdownMenuItem(value: true, child: Text(AppLocale.AlreadyPaid.getString(context))), + DropdownMenuItem(value: false, child: Text(AppLocale.NotPaid.getString(context))), + ], + onChanged: (value) { + setState(() => tempPaid = value); + }, + ), + ], + ), + const SizedBox.square(dimension: 10), + Row( + children: [ + Text(AppLocale.CreatedAfter.getString(context)), + const SizedBox.square(dimension: 5), + TextButton( + onPressed: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: tempCreatedAfter, + firstDate: DateTime(2024), + lastDate: DateTime(2100), + ); + setState(() => tempCreatedAfter = picked); + }, + child: Text(tempCreatedAfter?.toLocal().toString() ?? "---"), + ), + ], + ), + const SizedBox.square(dimension: 10), + Row( + children: [ + Text(AppLocale.CreatedBefore.getString(context)), + const SizedBox.square(dimension: 5), + TextButton( + onPressed: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: tempCreatedBefore, + firstDate: DateTime(2024), + lastDate: DateTime(2100), + ); + setState(() => tempCreatedBefore = picked); + }, + child: Text(tempCreatedBefore?.toLocal().toString() ?? "---"), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + tempPaid = null; + tempCreatedAfter = null; + tempCreatedBefore = null; + + onSubmit(context); + }, + child: Text(AppLocale.ClearAll.getString(context)), + ), + TextButton( + onPressed: () => onSubmit(context), + child: Text(AppLocale.Search.getString(context)), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ], + ), + ), + ), + // SliverToBoxAdapter(child: Center(child: _notification)), + FutureBuilder( + future: queryLoader.future, + initialData: queryLoader.lastData, + builder: (context, _) { + if (queryLoader.isLoading) { + return const SliverToBoxAdapter( + child: SizedBox( + height: 500, + child: LoadingIndicator(), + ), + ); + } + + final code = queryLoader.lastData; + if (code != 0) { + return SliverToBoxAdapter( + child: SizedBox( + height: 500, + child: ErrorIndicator(errorCode: code, callback: reload), + ), + ); + } + + const fontSize = 14.0, height = 1.2; + final headerText = [ + AppLocale.FeeName.getString(context), + AppLocale.Description.getString(context), + AppLocale.Room.getString(context), + AppLocale.CreationTime.getString(context), + AppLocale.PaidTimestamp.getString(context), + AppLocale.FeeLowerBound.getString(context), + AppLocale.FeeUpperBound.getString(context), + AppLocale.AmountPaid.getString(context), + ]; + + final columnNumeric = [false, false, false, false, false, true, true, true]; + final columnSize = [ + ColumnSize.M, + ColumnSize.L, + ColumnSize.S, + ColumnSize.M, + ColumnSize.M, + ColumnSize.M, + ColumnSize.M, + ColumnSize.M, + ]; + final columns = List.generate( + headerText.length, + (index) => DataColumn2( + label: Text( + headerText[index], + softWrap: true, + style: const TextStyle(fontSize: fontSize, height: height), + ), + numeric: columnNumeric[index], + size: columnSize[index], + ), + ); + + return SliverLayoutBuilder( + builder: (context, constraints) => SliverToBoxAdapter( + child: SizedBox( + height: constraints.remainingPaintExtent, + child: Padding( + padding: const EdgeInsets.all(5), + child: DataTable2( + columns: columns, + columnSpacing: 5, + dataRowHeight: 4 * height * fontSize, + fixedTopRows: 1, + headingRowHeight: 4 * height * fontSize, + horizontalScrollController: _horizontalScroll, + minWidth: 1200, + rows: queryLoader.statuses.map( + (p) { + final text = [ + p.fee.name, + p.fee.description, + p.room.toString(), + formatDateTime(p.fee.createdAt.toLocal()), + p.payment != null ? formatDateTime(p.payment!.createdAt.toLocal()) : "---", + formatVND(p.lowerBound), + formatVND(p.upperBound), + p.payment == null ? "---" : formatVND(p.payment!.amount), + ]; + + return DataRow2( + cells: List.generate( + text.length, + (index) => DataCell( + Padding( + padding: const EdgeInsets.only(top: 5, bottom: 5), + child: Text( + text[index], + maxLines: 3, + overflow: TextOverflow.ellipsis, + softWrap: true, + style: const TextStyle(fontSize: fontSize, height: height), + ), + ), + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(headerText[index]), + content: Builder( + builder: (context) { + final mediaQuery = MediaQuery.of(context); + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: 0.75 * mediaQuery.size.height), + child: SingleChildScrollView(child: Text(text[index])), + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocale.OK.getString(context)), + ), + ], + ), + ), + ), + ).toList(growable: false), + ); + }, + ).toList(growable: false), + ), + ), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/app/resident_manager/lib/src/widgets/admin/reg_queue.dart b/app/resident_manager/lib/src/widgets/admin/reg_queue.dart index 1f2b098..65e2f25 100644 --- a/app/resident_manager/lib/src/widgets/admin/reg_queue.dart +++ b/app/resident_manager/lib/src/widgets/admin/reg_queue.dart @@ -342,29 +342,15 @@ class _RegisterQueuePageState extends AbstractCommonState wit padding: const EdgeInsets.all(5), child: DataTable2( columns: [ - DataColumn2( - label: Text(AppLocale.Fullname.getString(context)), - size: ColumnSize.L, - onSort: onSort, - ), - DataColumn2( - label: Text(AppLocale.Room.getString(context)), - onSort: onSort, - ), + DataColumn2(label: Text(AppLocale.Fullname.getString(context)), size: ColumnSize.L, onSort: onSort), + DataColumn2(label: Text(AppLocale.Room.getString(context)), size: ColumnSize.S, onSort: onSort), DataColumn2(label: Text(AppLocale.DateOfBirth.getString(context))), DataColumn2(label: Text(AppLocale.Phone.getString(context))), DataColumn2(label: Text(AppLocale.Email.getString(context)), size: ColumnSize.L), - DataColumn2( - label: Text(AppLocale.CreationTime.getString(context)), - size: ColumnSize.L, - onSort: onSort, - ), - DataColumn2( - label: Text(AppLocale.Username.getString(context)), - size: ColumnSize.L, - onSort: onSort, - ), + DataColumn2(label: Text(AppLocale.CreationTime.getString(context)), size: ColumnSize.L, onSort: onSort), + DataColumn2(label: Text(AppLocale.Username.getString(context)), size: ColumnSize.L, onSort: onSort), ], + columnSpacing: 5, fixedTopRows: 1, horizontalScrollController: _horizontalScroll, minWidth: 1200, diff --git a/app/resident_manager/lib/src/widgets/admin/residents.dart b/app/resident_manager/lib/src/widgets/admin/residents.dart index f0ffc7c..c1efa07 100644 --- a/app/resident_manager/lib/src/widgets/admin/residents.dart +++ b/app/resident_manager/lib/src/widgets/admin/residents.dart @@ -507,33 +507,19 @@ class _ResidentsPageState extends AbstractCommonState with Common padding: const EdgeInsets.all(5), child: DataTable2( columns: [ - DataColumn2( - label: Text(AppLocale.Fullname.getString(context)), - size: ColumnSize.L, - onSort: onSort, - ), - DataColumn2( - label: Text(AppLocale.Room.getString(context)), - onSort: onSort, - ), + DataColumn2(label: Text(AppLocale.Fullname.getString(context)), size: ColumnSize.L, onSort: onSort), + DataColumn2(label: Text(AppLocale.Room.getString(context)), size: ColumnSize.S, onSort: onSort), DataColumn2(label: Text(AppLocale.DateOfBirth.getString(context))), DataColumn2(label: Text(AppLocale.Phone.getString(context))), DataColumn2(label: Text(AppLocale.Email.getString(context)), size: ColumnSize.L), - DataColumn2( - label: Text(AppLocale.CreationTime.getString(context)), - size: ColumnSize.L, - onSort: onSort, - ), - DataColumn2( - label: Text(AppLocale.Username.getString(context)), - size: ColumnSize.L, - onSort: onSort, - ), - DataColumn2(label: Text(AppLocale.Option.getString(context))), + DataColumn2(label: Text(AppLocale.CreationTime.getString(context)), size: ColumnSize.L, onSort: onSort), + DataColumn2(label: Text(AppLocale.Username.getString(context)), size: ColumnSize.L, onSort: onSort), + DataColumn2(label: Text(AppLocale.Option.getString(context)), size: ColumnSize.S), ], + columnSpacing: 5, fixedTopRows: 1, horizontalScrollController: _horizontalScroll, - minWidth: 1200, + minWidth: 1500, rows: List.from( queryLoader.residents.map( (r) => DataRow2( diff --git a/app/resident_manager/lib/src/widgets/common.dart b/app/resident_manager/lib/src/widgets/common.dart index e76c072..5ba9ba4 100644 --- a/app/resident_manager/lib/src/widgets/common.dart +++ b/app/resident_manager/lib/src/widgets/common.dart @@ -210,6 +210,12 @@ class _CommonScaffoldState extends State with CommonScaf final text = [ s.fee.name, s.fee.description, - s.lowerBound.round().toString(), - s.upperBound.round().toString(), + formatVND(s.lowerBound), + formatVND(s.upperBound), formatDateTime(s.fee.createdAt.toLocal()), - s.payment?.amount.round().toString() ?? "---", + s.payment == null ? "---" : formatVND(s.payment!.amount), ]; return DataRow2( cells: [ diff --git a/scripts/database.sql b/scripts/database.sql index 816378f..27067dc 100644 --- a/scripts/database.sql +++ b/scripts/database.sql @@ -81,5 +81,13 @@ IF NOT EXISTS (SELECT 1 FROM sys.objects WHERE name = 'payments' AND type = 'U') CONSTRAINT UQ_payments_room_fee_id UNIQUE (room, fee_id) ) +IF NOT EXISTS (SELECT 1 FROM sys.objects WHERE name = 'bills' AND type = 'U') + CREATE TABLE bills ( + room SMALLINT NOT NULL, + month DATE NOT NULL, + amount BIGINT NOT NULL, -- amount = 100 * [water/electricity unit] + type TINYINT NOT NULL, -- 0: water, 1: electricity + ) + IF NOT EXISTS (SELECT 1 FROM sys.types WHERE name = 'BIGINTARRAY') CREATE TYPE BIGINTARRAY AS TABLE (value BIGINT NOT NULL) diff --git a/scripts/procedures/query_room_fees.sql b/scripts/procedures/query_room_fees.sql index 6786baa..f2a41cc 100644 --- a/scripts/procedures/query_room_fees.sql +++ b/scripts/procedures/query_room_fees.sql @@ -31,16 +31,17 @@ BEGIN payments.id AS payment_id, payments.room AS payment_room, payments.amount AS payment_amount, - payments.fee_id AS payment_fee_id + payments.fee_id AS payment_fee_id, + rooms.room AS room FROM fees - INNER JOIN rooms ON rooms.room = @Room - LEFT JOIN payments ON payments.fee_id = fees.id AND payments.room = @Room + INNER JOIN rooms ON (@Room IS NULL OR @Room = rooms.room) + LEFT JOIN payments ON payments.fee_id = fees.id AND payments.room = rooms.room WHERE fees.id >= @FromId AND fees.id <= @ToId AND ( @Paid IS NULL OR (@Paid = 0 AND payments.id IS NULL) OR (@Paid = 1 AND payments.id IS NOT NULL) ) - ORDER BY fees.id + ORDER BY fees.id DESC OFFSET @Offset ROWS FETCH NEXT @FetchNext ROWS ONLY END diff --git a/scripts/sample.py b/scripts/sample.py index 4d53b91..29f43c6 100644 --- a/scripts/sample.py +++ b/scripts/sample.py @@ -86,7 +86,7 @@ async def main() -> None: await connection.execute("DELETE FROM payments") await connection.execute("DELETE FROM accounts") await connection.execute("DELETE FROM rooms") - await connection.execute("DELETE FROM fee") + await connection.execute("DELETE FROM fees") tasks = [asyncio.create_task(populate_account(i)) for i in range(10000)] tasks.append(asyncio.create_task(populate_room())) diff --git a/server/v1/models/payment_status.py b/server/v1/models/payment_status.py index 8d78a25..c48bf4b 100644 --- a/server/v1/models/payment_status.py +++ b/server/v1/models/payment_status.py @@ -25,6 +25,7 @@ class PaymentStatus(pydantic.BaseModel): lower_bound: Annotated[float, pydantic.Field(description="The lower bound of the fee, in VND")] upper_bound: Annotated[float, pydantic.Field(description="The upper bound of the fee, in VND")] payment: Annotated[Optional[Payment], pydantic.Field(description="The payment associated to the fee if the room has already paid this fee")] + room: Annotated[int, pydantic.Field(description="The room associated to this payment status")] @classmethod def from_row(cls, row: Row) -> PaymentStatus: @@ -56,6 +57,7 @@ def from_row(cls, row: Row) -> PaymentStatus: lower_bound=row.lower_bound / 100, upper_bound=row.upper_bound / 100, payment=payment, + room=row.room, ) @staticmethod @@ -91,16 +93,17 @@ async def count( @classmethod async def query( cls, - room: int, + room: Optional[int], *, offset: int = 0, paid: Optional[bool] = None, created_after: datetime, created_before: datetime, ) -> Result[Optional[List[PaymentStatus]]]: - matching_rooms = await Room.query(offset=0, room=room) - if len(matching_rooms) == 0 or not matching_rooms[0].has_data: - return Result(code=606, data=None) + if room is not None: + matching_rooms = await Room.query(offset=0, room=room) + if len(matching_rooms) == 0 or not matching_rooms[0].has_data: + return Result(code=606, data=None) created_after = max(created_after.astimezone(timezone.utc), EPOCH) created_before = max(created_before.astimezone(timezone.utc), EPOCH) diff --git a/server/v1/routes/admin/fees/__init__.py b/server/v1/routes/admin/fees/__init__.py index b1d7ea4..6f06585 100644 --- a/server/v1/routes/admin/fees/__init__.py +++ b/server/v1/routes/admin/fees/__init__.py @@ -1,3 +1,4 @@ from .count import * from .create import * +from .payments import * from .root import * diff --git a/server/v1/routes/admin/fees/payments.py b/server/v1/routes/admin/fees/payments.py new file mode 100644 index 0000000..e689c6d --- /dev/null +++ b/server/v1/routes/admin/fees/payments.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated, List, Optional + +from fastapi import Depends, Query, Response, status + +from ....app import api_v1 +from ....models import AdminPermission, PaymentStatus, Result +from .....config import EPOCH + + +__all__ = ("admin_fees_payments",) + + +@api_v1.get( + "/admin/fees/payments", + name="Payment query", + description="Query a list of payments", + tags=["admin"], + responses={ + status.HTTP_200_OK: { + "description": "List of payments", + "model": Result[List[PaymentStatus]], + }, + status.HTTP_400_BAD_REQUEST: { + "description": "Incorrect authorization data", + "model": Result[None], + }, + }, + status_code=status.HTTP_200_OK, +) +async def admin_fees_payments( + admin: Annotated[AdminPermission, Depends(AdminPermission.from_token)], + response: Response, + *, + room: Annotated[Optional[int], Query(description="Query payments associated to this room only")] = None, + paid: Annotated[Optional[bool], Query(description="Whether to query paid or unpaid fees only")] = None, + offset: Annotated[int, Query(description="Query offset")] = 0, + created_after: Annotated[ + datetime, + Query(description="Query fees created after this timestamp"), + ] = EPOCH, + created_before: Annotated[ + datetime, + Query( + description="Query fees created before this timestamp", + default_factory=lambda: datetime.now(timezone.utc), + ), + ], +) -> Result[Optional[List[PaymentStatus]]]: + if admin.admin: + return await PaymentStatus.query( + room, + offset=offset, + paid=paid, + created_after=created_after, + created_before=created_before, + ) + + response.status_code = status.HTTP_400_BAD_REQUEST + return Result(code=401, data=None) diff --git a/server/v1/routes/residents/fees/root.py b/server/v1/routes/residents/fees/root.py index d77cae9..a4d300e 100644 --- a/server/v1/routes/residents/fees/root.py +++ b/server/v1/routes/residents/fees/root.py @@ -34,7 +34,7 @@ async def residents_fees( response: Response, *, offset: Annotated[int, Query(description="Query offset")] = 0, - paid: Annotated[Optional[bool], Query(description="Whether to query paid or unpaid queries only")] = None, + paid: Annotated[Optional[bool], Query(description="Whether to query paid or unpaid fees only")] = None, created_after: Annotated[ datetime, Query(description="Query fees created after this timestamp"),