From a3894cc878231ab334a0a922242b01aa07dee9c6 Mon Sep 17 00:00:00 2001 From: sihoo Date: Tue, 8 Sep 2026 19:58:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=EB=A7=88=ED=8A=B8=EA=B8=B0=EA=B8=B0?= =?UTF-8?q?=20=EB=B0=98=EC=B6=9C=20=EB=8C=80=EC=9E=A5:=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=95=99=EC=83=9D=20=ED=97=88=EC=9A=A9=20+=20?= =?UTF-8?q?=EB=A7=8E=EC=9D=80=20=EC=8B=9C=EA=B0=84=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=95=99=EC=83=9D=20=EA=B2=80=ED=86=A0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "전체 학생 허용" 아이콘을 새로고침 버튼 옆에 추가 - 대기 중인 요청을 한 명씩 승인하는 대신 확인 팝업 후 한 번에 전부 승인 - "많은 시간요청 학생" 아이콘 추가 - 학생이 시간을 비정상적으로 길게 적어낼 수 있으니, 2시간 이상 신청한 대기 중인 요청만 따로 모아 보여줌 (해당 건수를 빨간 배지로 표시). 목록에서 바로 승인/거절 가능 - 컨트롤러에 durationHours/longPendingRequests/approveAllPending 추가. 일괄 승인은 서버에 batch API가 없어서 대기 요청마다 순서대로 승인 요청을 보내고 마지막에 한 번만 새로고침 Co-Authored-By: Claude Sonnet 5 --- .../device_checkout_ledger_controller.dart | 67 +++++++ lib/ui/device_checkout_ledger_page.dart | 173 ++++++++++++++++++ 2 files changed, 240 insertions(+) diff --git a/lib/function/device_checkout_ledger_controller.dart b/lib/function/device_checkout_ledger_controller.dart index 46b11d7..c942bfa 100644 --- a/lib/function/device_checkout_ledger_controller.dart +++ b/lib/function/device_checkout_ledger_controller.dart @@ -16,6 +16,26 @@ class DeviceCheckoutLedgerController extends ChangeNotifier { bool get isLoading => _isLoading; bool get isWorking => _isWorking; + /// ⏰ 요청 시간이 얼마나 되는지(시간 단위). 형식이 이상하면 null. + double? durationHours(dynamic request) { + try { + final start = DateTime.parse(request['requestedStart']); + final end = DateTime.parse(request['requestedEnd']); + final minutes = end.difference(start).inMinutes; + return minutes > 0 ? minutes / 60.0 : null; + } catch (_) { + return null; + } + } + + /// 🚨 [장시간 요청 감지] 2시간 넘게 신청한, 아직 대기 중인 요청 목록. + /// 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니 전체 승인 전에 따로 훑어볼 수 있게 한다. + List get longPendingRequests => _requests.where((r) { + if (r['status'] != 'PENDING') return false; + final hours = durationHours(r); + return hours != null && hours >= 2; + }).toList(); + void _safeNotify() { if (!_disposed) notifyListeners(); } @@ -63,6 +83,53 @@ class DeviceCheckoutLedgerController extends ChangeNotifier { }); } + // 🙌 [전체 학생 허용] 대기 중인 요청을 한 명씩 누르기 귀찮을 때, 한 번에 전부 승인한다. + // 서버에 일괄 승인 API가 따로 없어서, 대기 중인 요청마다 승인 요청을 순서대로 보낸다. + Future<(bool success, String message)> approveAllPending({ + required String teacherId, + required String teacherName, + }) async { + final pendingIds = _requests + .where((r) => r['status'] == 'PENDING') + .map((r) => r['id'] as int) + .toList(); + if (pendingIds.isEmpty) { + return (true, '승인 대기 중인 요청이 없습니다.'); + } + + _isWorking = true; + _safeNotify(); + int successCount = 0; + for (final id in pendingIds) { + try { + final response = await http.post( + Uri.parse('$baseUrl/api/device-checkout/approve'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "requestId": id, + "teacherId": teacherId, + "teacherName": teacherName, + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + successCount++; + } + } catch (_) { + // 개별 요청 실패는 건너뛰고 나머지는 계속 진행한다. + } + } + await fetchAll(); + _isWorking = false; + _safeNotify(); + + final allOk = successCount == pendingIds.length; + return ( + allOk, + '${pendingIds.length}건 중 $successCount건 승인 완료${allOk ? '' : ' (일부 실패)'}', + ); + } + Future<(bool success, String message)> reject(int requestId) async { return _postAction('/api/device-checkout/reject', {"requestId": requestId}); } diff --git a/lib/ui/device_checkout_ledger_page.dart b/lib/ui/device_checkout_ledger_page.dart index a3bed3d..3090715 100644 --- a/lib/ui/device_checkout_ledger_page.dart +++ b/lib/ui/device_checkout_ledger_page.dart @@ -49,6 +49,163 @@ class _DeviceCheckoutLedgerPageState extends State { AppNotice.show(context, message); } + // 🙌 [전체 학생 허용] 한 명씩 누르기 귀찮을 때, 대기 중인 요청을 한 번에 전부 승인한다. + void _showApproveAllConfirmDialog() { + final pendingCount = _controller.requests + .where((r) => r['status'] == 'PENDING') + .length; + if (pendingCount == 0) { + AppNotice.show(context, '승인 대기 중인 요청이 없습니다.'); + return; + } + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('전체 학생 허용'), + content: Text('대기 중인 요청 $pendingCount건을 한 번에 전부 승인하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () async { + Navigator.pop(dialogContext); + final (_, message) = await _controller.approveAllPending( + teacherId: widget.teacherId ?? '간편인증', + teacherName: widget.teacherName ?? '간편인증 선생', + ); + if (!mounted) return; + AppNotice.show(context, message); + }, + child: const Text('전체 승인'), + ), + ], + ), + ); + } + + // 🚨 [많은 시간요청 학생] 2시간 넘게 신청한 대기 중인 요청만 따로 모아 보여준다. + // 학생이 시간을 비정상적으로 길게 적어냈을 수 있으니, 전체 승인 전에 훑어보는 용도. + void _showLongRequestsDialog() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange), + SizedBox(width: 8), + Text('많은 시간요청 학생'), + ], + ), + content: SizedBox( + width: 380, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final longRequests = _controller.longPendingRequests; + if (longRequests.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text( + '2시간 넘게 신청한 대기 중인 요청이 없습니다.', + style: TextStyle(color: Colors.grey), + ), + ); + } + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.separated( + shrinkWrap: true, + itemCount: longRequests.length, + separatorBuilder: (_, _) => const Divider(height: 20), + itemBuilder: (context, index) { + final r = longRequests[index]; + final hours = _controller.durationHours(r); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${r['studentName']} (${r['studentId']})', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + '${_timeRange(r['requestedStart'], r['requestedEnd'])} ' + '(약 ${hours?.toStringAsFixed(1)}시간)', + style: const TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(height: 2), + Text( + r['purpose'], + style: TextStyle( + color: Colors.grey[700], + fontSize: 13, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _controller.isWorking + ? null + : () { + Navigator.pop(dialogContext); + _reject(r['id']); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red), + ), + child: const Text('거절'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: _controller.isWorking + ? null + : () { + Navigator.pop(dialogContext); + _approve(r['id']); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + child: const Text('승인'), + ), + ), + ], + ), + ], + ); + }, + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('닫기'), + ), + ], + ), + ); + } + // 🖐️ [하드웨어 자동 감지 전까지 임시] 기기를 실제로 돌려받았을 때 누르는 버튼. Future _confirmReturn(int id) async { final (_, message) = await _controller.confirmReturn(id); @@ -86,6 +243,7 @@ class _DeviceCheckoutLedgerPageState extends State { final pendingCount = requests .where((r) => r['status'] == 'PENDING') .length; + final longCount = _controller.longPendingRequests.length; return Scaffold( backgroundColor: AppPalette.mist, @@ -101,6 +259,21 @@ class _DeviceCheckoutLedgerPageState extends State { onPressed: _controller.fetchAll, tooltip: '새로고침', ), + IconButton( + icon: const Icon(Icons.done_all_rounded), + onPressed: _showApproveAllConfirmDialog, + tooltip: '전체 학생 허용', + ), + IconButton( + icon: Badge( + isLabelVisible: longCount > 0, + label: Text('$longCount'), + backgroundColor: Colors.red, + child: const Icon(Icons.warning_amber_rounded), + ), + onPressed: _showLongRequestsDialog, + tooltip: '많은 시간요청 학생', + ), ], ), body: _controller.isLoading