From efed3c1a6f8d8e4b6d16b4a884d77e8fe0fc0bf9 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 16 Oct 2025 18:53:22 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20API=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/backup/backend_change_requests.md | 14 +- .../approval_repository_remote.dart | 6 +- .../approvals/domain/entities/approval.dart | 45 +- .../repositories/approval_repository.dart | 4 +- .../controllers/approval_controller.dart | 88 ++++ .../presentation/pages/approval_page.dart | 379 ++++++++++++---- .../auth/application/auth_service.dart | 82 ++++ .../auth/data/dtos/auth_session_dto.dart | 162 +++++++ .../repositories/auth_repository_remote.dart | 43 ++ .../auth/domain/entities/auth_permission.dart | 32 ++ .../auth/domain/entities/auth_session.dart | 31 ++ .../domain/entities/authenticated_user.dart | 29 ++ .../auth/domain/entities/login_request.dart | 21 + .../domain/repositories/auth_repository.dart | 11 + .../data/dtos/dashboard_summary_dto.dart | 204 +++++++++ .../dashboard_repository_remote.dart | 28 ++ .../domain/entities/dashboard_kpi.dart | 33 ++ .../entities/dashboard_pending_approval.dart | 21 + .../domain/entities/dashboard_summary.dart | 35 ++ .../dashboard_transaction_summary.dart | 25 ++ .../repositories/dashboard_repository.dart | 7 + .../controllers/dashboard_controller.dart | 50 +++ .../presentation/pages/dashboard_page.dart | 409 ++++++++++++------ .../stock_transaction_repository_remote.dart | 3 +- .../entities/stock_transaction_input.dart | 49 ++- .../login/presentation/pages/login_page.dart | 68 ++- .../customer_repository_remote.dart | 3 +- .../repositories/group_repository_remote.dart | 3 +- .../group_permission_repository_remote.dart | 3 +- .../repositories/menu_repository_remote.dart | 3 +- .../product_repository_remote.dart | 3 +- .../repositories/user_repository_remote.dart | 3 +- .../vendor_repository_remote.dart | 3 +- .../warehouse_repository_remote.dart | 3 +- .../reporting_repository_remote.dart | 6 +- .../pages/postal_search_page.dart | 2 +- lib/injection_container.dart | 31 +- .../approval_page_permission_test.dart | 4 +- .../data/approval_repository_remote_test.dart | 98 +++++ .../controllers/approval_controller_test.dart | 8 +- .../pages/approval_page_test.dart | 7 +- .../presentation/pages/login_page_test.dart | 66 +++ .../reporting_repository_remote_test.dart | 63 ++- test/navigation/navigation_flow_test.dart | 74 +++- 44 files changed, 1969 insertions(+), 293 deletions(-) create mode 100644 lib/features/auth/application/auth_service.dart create mode 100644 lib/features/auth/data/dtos/auth_session_dto.dart create mode 100644 lib/features/auth/data/repositories/auth_repository_remote.dart create mode 100644 lib/features/auth/domain/entities/auth_permission.dart create mode 100644 lib/features/auth/domain/entities/auth_session.dart create mode 100644 lib/features/auth/domain/entities/authenticated_user.dart create mode 100644 lib/features/auth/domain/entities/login_request.dart create mode 100644 lib/features/auth/domain/repositories/auth_repository.dart create mode 100644 lib/features/dashboard/data/dtos/dashboard_summary_dto.dart create mode 100644 lib/features/dashboard/data/repositories/dashboard_repository_remote.dart create mode 100644 lib/features/dashboard/domain/entities/dashboard_kpi.dart create mode 100644 lib/features/dashboard/domain/entities/dashboard_pending_approval.dart create mode 100644 lib/features/dashboard/domain/entities/dashboard_summary.dart create mode 100644 lib/features/dashboard/domain/entities/dashboard_transaction_summary.dart create mode 100644 lib/features/dashboard/domain/repositories/dashboard_repository.dart create mode 100644 lib/features/dashboard/presentation/controllers/dashboard_controller.dart diff --git a/doc/backup/backend_change_requests.md b/doc/backup/backend_change_requests.md index b4f993c..63c54d6 100644 --- a/doc/backup/backend_change_requests.md +++ b/doc/backup/backend_change_requests.md @@ -1,4 +1,4 @@ -# 백엔드 수정 요청서 (2024-08-XX 갱신) +# 백엔드 수정 요청서 (2025-10-16 갱신) ## 1. 배경 - Flutter 프론트엔드(`superport_v2`)가 최신 백엔드(`superport_api_v2`)와 실연동을 준비하면서, 일부 엔드포인트가 미구현이거나 응답 스키마가 불완전해 화면 기능을 마무리하기 어렵다. @@ -18,7 +18,9 @@ - `GET /api/v1/reports/transactions/export` - `GET /api/v1/reports/approvals/export` - 요구 사항: - - 쿼리 파라미터: `from`, `to`(ISO 8601), `format`(xlsx|pdf), `type_id`, `status_id`, `warehouse_id` 등 프론트에서 사용하는 필터 수용. + - 쿼리 파라미터: + - 트랜잭션: `from`, `to`(yyyy-MM-dd), `format`(xlsx|pdf), `transaction_status_id`, `approval_status_id`, `requested_by_id` + - 결재: `from`, `to`(ISO 8601), `format`(xlsx|pdf), `transaction_status_id`, `approval_status_id`, `requested_by_id` - 응답: - 파일 다운로드(바이트 스트림) 또는 `data.download_url`, `data.filename`, `data.mime_type`, `data.expires_at`을 포함한 JSON. - 인증/권한 정책 확정 후 문서화. @@ -42,6 +44,14 @@ - 위 변경 사항이 반영되면 `stock_approval_system_api_v4.md`를 업데이트하고, 각 엔드포인트 예제 응답을 최신 상태로 반영한다. - 회귀 테스트(`cargo test` + 통합 시나리오 스크립트)가 변경된 계약을 검증하도록 보강한다. +### 3.5 결재 생성/수정 API 정합성 +- `POST /api/v1/approvals`가 다음 요청 바디를 수용하도록 구현 필요: + - 필수: `transaction_id`, `approval_no`, `approval_status_id`, `requested_by_id` + - 선택: `note` + - 응답에는 갱신된 결재 요약(`data.approval`)과 현재 단계/상태 정보가 포함돼야 프론트가 즉시 리스트를 재사용할 수 있다. +- `PATCH /api/v1/approvals/{id}`는 본문에 `id`를 요구하고 `approval_status_id`, `note` 변경을 허용해야 한다. 응답은 최신 결재 정보를 반환해 상세 패널을 재조회 없이 갱신할 수 있도록 한다. +- 결재번호(`approval_no`) 중복/포맷 검증과 기본 상태(예: 대기) 자동 할당 규칙을 API 스펙에 명시해 달라. + ## 4. 수용 기준 - 상기 엔드포인트가 모두 구현되고, 요청/응답이 문서와 일치해야 한다. - 레거시 응답(204)에서 JSON 반환으로 변경될 경우, 클라이언트가 기대하는 키(`data.approval`, `data.steps` 등)를 포함해야 한다. diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart index 250e581..c037a7a 100644 --- a/lib/features/approvals/data/repositories/approval_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -139,7 +139,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { /// 새로운 결재를 생성한다. @override - Future create(ApprovalInput input) async { + Future create(ApprovalCreateInput input) async { final response = await _api.post>( _basePath, data: input.toPayload(), @@ -151,9 +151,9 @@ class ApprovalRepositoryRemote implements ApprovalRepository { /// 결재 기본 정보를 수정한다. @override - Future update(int id, ApprovalInput input) async { + Future update(ApprovalUpdateInput input) async { final response = await _api.patch>( - '$_basePath/$id', + '$_basePath/${input.id}', data: input.toPayload(), options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/approvals/domain/entities/approval.dart b/lib/features/approvals/domain/entities/approval.dart index be83c1c..6202159 100644 --- a/lib/features/approvals/domain/entities/approval.dart +++ b/lib/features/approvals/domain/entities/approval.dart @@ -198,14 +198,53 @@ extension ApprovalStepActionTypeX on ApprovalStepActionType { } /// 결재 생성 입력 모델 -class ApprovalInput { - ApprovalInput({required this.transactionId, this.note}); +/// 결재 신규 생성 입력 모델 +/// +/// - 트랜잭션, 결재번호, 상태, 상신자 정보를 백엔드 계약에 맞춰 전달한다. +class ApprovalCreateInput { + ApprovalCreateInput({ + required this.transactionId, + required this.approvalNo, + required this.approvalStatusId, + required this.requestedById, + this.note, + }); final int transactionId; + final String approvalNo; + final int approvalStatusId; + final int requestedById; final String? note; Map toPayload() { - return {'transaction_id': transactionId, 'note': note}; + final trimmedNote = note?.trim(); + return { + 'transaction_id': transactionId, + 'approval_no': approvalNo, + 'approval_status_id': approvalStatusId, + 'requested_by_id': requestedById, + if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, + }; + } +} + +/// 결재 기본 정보 수정 입력 모델 +/// +/// - 상태/비고 변경 시 결재 식별자를 포함해 패치를 수행한다. +class ApprovalUpdateInput { + ApprovalUpdateInput({required this.id, this.approvalStatusId, this.note}); + + final int id; + final int? approvalStatusId; + final String? note; + + Map toPayload() { + final trimmedNote = note?.trim(); + return { + 'id': id, + if (approvalStatusId != null) 'approval_status_id': approvalStatusId, + if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, + }; } } diff --git a/lib/features/approvals/domain/repositories/approval_repository.dart b/lib/features/approvals/domain/repositories/approval_repository.dart index f463485..aaf5081 100644 --- a/lib/features/approvals/domain/repositories/approval_repository.dart +++ b/lib/features/approvals/domain/repositories/approval_repository.dart @@ -38,10 +38,10 @@ abstract class ApprovalRepository { Future canProceed(int id); /// 결재를 생성한다. - Future create(ApprovalInput input); + Future create(ApprovalCreateInput input); /// 결재를 수정한다. - Future update(int id, ApprovalInput input); + Future update(ApprovalUpdateInput input); /// 결재를 삭제한다. Future delete(int id); diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index 013328d..5bb0be5 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -57,6 +57,7 @@ class ApprovalController extends ChangeNotifier { bool _isLoadingList = false; bool _isLoadingDetail = false; bool _isLoadingActions = false; + bool _isSubmitting = false; bool _isPerformingAction = false; int? _processingStepId; bool _isLoadingTemplates = false; @@ -72,6 +73,7 @@ class ApprovalController extends ChangeNotifier { List _actions = const []; List _templates = const []; final Map _statusLookup = {}; + List _statusOptions = const []; final Map _statusCodeAliases = Map.fromEntries( _defaultStatusCodes.entries.map( (entry) => MapEntry(entry.value, entry.value), @@ -83,6 +85,7 @@ class ApprovalController extends ChangeNotifier { bool get isLoadingList => _isLoadingList; bool get isLoadingDetail => _isLoadingDetail; bool get isLoadingActions => _isLoadingActions; + bool get isSubmitting => _isSubmitting; bool get isPerformingAction => _isPerformingAction; int? get processingStepId => _processingStepId; String? get errorMessage => _errorMessage; @@ -107,6 +110,35 @@ class ApprovalController extends ChangeNotifier { return reason; } + List get approvalStatusOptions => _statusOptions; + + int? get defaultApprovalStatusId { + if (_statusOptions.isEmpty) { + return null; + } + final defaultItem = _statusOptions.firstWhere( + (item) => item.isDefault, + orElse: () => _statusOptions.first, + ); + return defaultItem.id; + } + + LookupItem? approvalStatusById(int? id) { + if (id == null) { + return null; + } + final lookup = _statusLookup[id.toString()]; + if (lookup != null) { + return lookup; + } + for (final item in _statusOptions) { + if (item.id == id) { + return item; + } + } + return null; + } + Map get statusLookup => _statusLookup; /// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다. @@ -174,6 +206,7 @@ class ApprovalController extends ChangeNotifier { } try { final items = await repository.fetchApprovalStatuses(); + _statusOptions = List.unmodifiable(items); _statusLookup ..clear() ..addEntries( @@ -311,6 +344,53 @@ class ApprovalController extends ChangeNotifier { notifyListeners(); } + /// 결재를 생성하고 목록/상세 상태를 최신화한다. + Future createApproval(ApprovalCreateInput input) async { + _setSubmitting(true); + _errorMessage = null; + try { + final created = await _repository.create(input); + await fetch(page: 1); + _selected = created; + if (created.id != null) { + await _loadProceedStatus(created.id!); + } + notifyListeners(); + return created; + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + /// 결재 기본 정보를 수정하고 현재 페이지를 유지한다. + Future updateApproval(ApprovalUpdateInput input) async { + _setSubmitting(true); + _errorMessage = null; + try { + final updated = await _repository.update(input); + final currentPage = _result?.page ?? 1; + await fetch(page: currentPage); + _selected = updated; + if (updated.id != null) { + await _loadProceedStatus(updated.id!); + } + notifyListeners(); + return updated; + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + /// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다. /// /// - 유효한 단계 ID와 액션이 존재해야 하며, 실행 중에는 중복 호출을 방지한다. @@ -477,6 +557,14 @@ class ApprovalController extends ChangeNotifier { notifyListeners(); } + void _setSubmitting(bool value) { + if (_isSubmitting == value) { + return; + } + _isSubmitting = value; + notifyListeners(); + } + /// 액션 타입과 동일한 코드(또는 별칭)를 가진 결재 행위를 찾는다. ApprovalAction? _findActionByType(ApprovalStepActionType type) { final aliases = _actionAliases[type] ?? [type.code]; diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 774a9ff..5c0624a 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -360,111 +360,318 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { /// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다. Future _openCreateApprovalDialog() async { + final approvalNoController = TextEditingController(); final transactionController = TextEditingController(); + final requesterController = TextEditingController(); final noteController = TextEditingController(); - var submitted = false; + InventoryEmployeeSuggestion? requesterSelection; + int? statusId = _controller.defaultApprovalStatusId; + String? transactionError; + String? approvalNoError; + String? statusError; + String? requesterError; - final shouldShowToast = await showDialog( + final created = await showDialog( context: context, - builder: (dialogContext) { + builder: (_) { return StatefulBuilder( builder: (context, setState) { - final shadTheme = ShadTheme.of(context); - final errorVisible = - submitted && transactionController.text.trim().isEmpty; + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final shadTheme = ShadTheme.of(context); + final materialTheme = Theme.of(context); + final statusOptions = _controller.approvalStatusOptions; + final isSubmitting = _controller.isSubmitting; + statusId ??= _controller.defaultApprovalStatusId; - return SuperportDialog( - title: '신규 결재 등록', - description: '트랜잭션 정보를 입력하면 API 연동 시 자동 제출이 지원됩니다.', - constraints: const BoxConstraints(maxWidth: 480), - actions: [ - ShadButton.ghost( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('닫기'), - ), - ShadButton( - key: const ValueKey('approval_create_submit'), - onPressed: () { - final trimmed = transactionController.text.trim(); - setState(() => submitted = true); - if (trimmed.isEmpty) { - return; - } - Navigator.of(dialogContext).pop(true); - }, - child: const Text('임시 저장'), - ), - ], - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Text('트랜잭션 ID', style: shadTheme.textTheme.small), - const SizedBox(height: 8), - ShadInput( - key: const ValueKey('approval_create_transaction'), - controller: transactionController, - placeholder: const Text('예: 2404-TRX-001'), - onChanged: (_) => setState(() {}), - ), - if (errorVisible) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - '트랜잭션 ID를 입력해야 결재 생성이 가능합니다.', - style: shadTheme.textTheme.small.copyWith( - color: Theme.of(context).colorScheme.error, + return SuperportDialog( + title: '신규 결재 등록', + description: '트랜잭션과 결재 정보를 입력하면 즉시 생성됩니다.', + constraints: const BoxConstraints(maxWidth: 540), + actions: [ + ShadButton.ghost( + onPressed: isSubmitting + ? null + : () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ShadButton( + key: const ValueKey('approval_create_submit'), + onPressed: isSubmitting + ? null + : () async { + final approvalNo = approvalNoController.text + .trim(); + final transactionText = transactionController.text + .trim(); + final transactionId = int.tryParse( + transactionText, + ); + final note = noteController.text.trim(); + final hasStatuses = statusOptions.isNotEmpty; + + setState(() { + approvalNoError = approvalNo.isEmpty + ? '결재번호를 입력하세요.' + : null; + transactionError = transactionText.isEmpty + ? '트랜잭션 ID를 입력하세요.' + : (transactionId == null + ? '트랜잭션 ID는 숫자만 입력하세요.' + : null); + statusError = (!hasStatuses || statusId == null) + ? '결재 상태를 선택하세요.' + : null; + requesterError = requesterSelection == null + ? '상신자를 선택하세요.' + : null; + }); + + if (approvalNoError != null || + transactionError != null || + statusError != null || + requesterError != null) { + return; + } + + final input = ApprovalCreateInput( + transactionId: transactionId!, + approvalNo: approvalNo, + approvalStatusId: statusId!, + requestedById: requesterSelection!.id, + note: note.isEmpty ? null : note, + ); + final result = await _controller.createApproval( + input, + ); + if (!mounted || !context.mounted) { + return; + } + if (result != null) { + Navigator.of(context).pop(true); + } + }, + child: isSubmitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('등록'), + ), + ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text('결재번호', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + ShadInput( + controller: approvalNoController, + enabled: !isSubmitting, + placeholder: const Text('예: APP-2025-0001'), + onChanged: (_) { + if (approvalNoError != null) { + setState(() => approvalNoError = null); + } + }, + ), + if (approvalNoError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + approvalNoError!, + style: shadTheme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + const SizedBox(height: 16), + Text('트랜잭션 ID', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + ShadInput( + key: const ValueKey('approval_create_transaction'), + controller: transactionController, + enabled: !isSubmitting, + placeholder: const Text('예: 9001'), + onChanged: (_) { + if (transactionError != null) { + setState(() => transactionError = null); + } + }, + ), + if (transactionError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + transactionError!, + style: shadTheme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + const SizedBox(height: 16), + Text('결재 상태', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + if (statusOptions.isEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: shadTheme.colorScheme.border, + ), + ), + child: Text( + '결재 상태 정보를 불러오지 못했습니다. 다시 시도해주세요.', + style: shadTheme.textTheme.muted, + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + key: ValueKey(statusOptions.length), + initialValue: statusId, + enabled: !isSubmitting, + placeholder: const Text('결재 상태 선택'), + onChanged: (value) { + setState(() { + statusId = value; + statusError = null; + }); + }, + selectedOptionBuilder: (context, value) { + final selected = _controller.approvalStatusById( + value, + ); + return Text(selected?.name ?? '결재 상태 선택'); + }, + options: statusOptions + .map( + (item) => ShadOption( + value: item.id, + child: Text(item.name), + ), + ) + .toList(), + ), + if (statusError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + statusError!, + style: shadTheme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text('상신자', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: InventoryEmployeeAutocompleteField( + controller: requesterController, + initialSuggestion: requesterSelection, + onSuggestionSelected: (suggestion) { + setState(() { + requesterSelection = suggestion; + requesterError = null; + }); + }, + onChanged: () { + if (requesterController.text.trim().isEmpty) { + setState(() { + requesterSelection = null; + }); + } + }, + enabled: !isSubmitting, + placeholder: '상신자 이름 또는 사번 검색', + ), + ), + const SizedBox(width: 8), + ShadButton.ghost( + onPressed: + requesterSelection == null && + requesterController.text.isEmpty + ? null + : () { + setState(() { + requesterSelection = null; + requesterController.clear(); + requesterError = null; + }); + }, + child: const Text('초기화'), + ), + ], + ), + if (requesterError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + requesterError!, + style: shadTheme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + const SizedBox(height: 16), + Text('비고 (선택)', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + ShadTextarea( + key: const ValueKey('approval_create_note'), + controller: noteController, + enabled: !isSubmitting, + minHeight: 120, + maxHeight: 220, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: shadTheme.colorScheme.mutedForeground + .withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + '저장 안내', + style: TextStyle(fontWeight: FontWeight.w600), + ), + SizedBox(height: 6), + Text( + '저장 시 결재가 생성되고 첫 단계와 현재 상태가 API 규격에 맞춰 초기화됩니다. 등록 후 목록이 자동으로 갱신됩니다.', + ), + ], ), ), - ), - const SizedBox(height: 16), - Text('비고 (선택)', style: shadTheme.textTheme.small), - const SizedBox(height: 8), - ShadTextarea( - key: const ValueKey('approval_create_note'), - controller: noteController, - minHeight: 120, - maxHeight: 220, + ], ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: shadTheme.colorScheme.mutedForeground.withValues( - alpha: 0.08, - ), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - 'API 연동 준비 중', - style: TextStyle(fontWeight: FontWeight.w600), - ), - SizedBox(height: 6), - Text( - '현재는 결재 생성 UI만 제공됩니다. 실제 저장은 백엔드 연동 이후 지원될 예정입니다.', - ), - ], - ), - ), - ], - ), + ); + }, ); }, ); }, ); + approvalNoController.dispose(); transactionController.dispose(); + requesterController.dispose(); noteController.dispose(); - if (shouldShowToast == true && mounted) { - SuperportToast.info( - context, - '결재 생성은 API 연동 이후 지원될 예정입니다. 입력한 값은 실제로 저장되지 않았습니다.', - ); + if (created == true && mounted) { + SuperportToast.success(context, '결재를 생성했습니다.'); } } diff --git a/lib/features/auth/application/auth_service.dart b/lib/features/auth/application/auth_service.dart new file mode 100644 index 0000000..5fdbe47 --- /dev/null +++ b/lib/features/auth/application/auth_service.dart @@ -0,0 +1,82 @@ +import 'package:flutter/foundation.dart'; + +import '../../../core/network/interceptors/auth_interceptor.dart'; +import '../../../core/services/token_storage.dart'; +import '../domain/entities/auth_session.dart'; +import '../domain/entities/login_request.dart'; +import '../domain/repositories/auth_repository.dart'; + +/// 인증 세션을 관리하고 토큰을 보관하는 서비스. +class AuthService extends ChangeNotifier { + AuthService({ + required AuthRepository repository, + required TokenStorage tokenStorage, + }) : _repository = repository, + _tokenStorage = tokenStorage; + + final AuthRepository _repository; + final TokenStorage _tokenStorage; + + AuthSession? _session; + bool _rememberMe = false; + + /// 현재 로그인된 세션 (없으면 null) + AuthSession? get session => _session; + + /// 사용자가 마지막으로 선택한 자동 로그인 여부 + bool get rememberMe => _rememberMe; + + /// 로그인 후 세션을 저장하고 토큰을 보관한다. + Future login(LoginRequest request) async { + final session = await _repository.login(request); + _rememberMe = request.rememberMe; + await _persistSession(session); + return session; + } + + /// 저장된 리프레시 토큰으로 세션을 갱신한다. + Future refreshSession() async { + final refreshToken = await _tokenStorage.readRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + return null; + } + final session = await _repository.refresh(refreshToken); + await _persistSession(session); + return session; + } + + /// 앱 내에서 명시적으로 로그아웃할 때 호출한다. + Future clearSession() async { + _session = null; + _rememberMe = false; + await _tokenStorage.clear(); + notifyListeners(); + } + + /// 인터셉터에서 사용할 토큰 쌍을 반환한다. + Future refreshForInterceptor() async { + try { + final session = await refreshSession(); + if (session == null) { + return null; + } + return TokenPair( + accessToken: session.accessToken, + refreshToken: session.refreshToken, + ); + } catch (_) { + return null; + } + } + + Future _persistSession(AuthSession session) async { + _session = session; + await _tokenStorage.writeAccessToken(session.accessToken); + if (session.hasRefreshToken) { + await _tokenStorage.writeRefreshToken(session.refreshToken); + } else { + await _tokenStorage.writeRefreshToken(null); + } + notifyListeners(); + } +} diff --git a/lib/features/auth/data/dtos/auth_session_dto.dart b/lib/features/auth/data/dtos/auth_session_dto.dart new file mode 100644 index 0000000..69b0c65 --- /dev/null +++ b/lib/features/auth/data/dtos/auth_session_dto.dart @@ -0,0 +1,162 @@ +import '../../../../core/common/utils/json_utils.dart'; +import '../../domain/entities/auth_permission.dart'; +import '../../domain/entities/auth_session.dart'; +import '../../domain/entities/authenticated_user.dart'; + +/// 로그인/토큰 갱신 응답을 역직렬화하는 DTO. +class AuthSessionDto { + AuthSessionDto({ + required this.accessToken, + required this.refreshToken, + required this.user, + this.expiresAt, + this.permissions = const [], + }); + + final String accessToken; + final String refreshToken; + final DateTime? expiresAt; + final AuthenticatedUser user; + final List permissions; + + factory AuthSessionDto.fromJson(Map json) { + final token = _readString(json, 'access_token'); + final refresh = _readString(json, 'refresh_token'); + final expires = _parseDate(_readString(json, 'expires_at')); + final userMap = _readMap(json, 'user'); + final permissionList = _readList(json, 'permissions'); + return AuthSessionDto( + accessToken: token ?? '', + refreshToken: refresh ?? '', + expiresAt: expires, + user: _parseUser(userMap), + permissions: permissionList + .map(AuthPermissionDto.fromJson) + .toList(growable: false), + ); + } + + AuthSession toEntity() { + return AuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: expiresAt, + user: user, + permissions: permissions + .map((dto) => dto.toEntity()) + .toList(growable: false), + ); + } +} + +class AuthPermissionDto { + const AuthPermissionDto({required this.resource, required this.actions}); + + final String resource; + final List actions; + + factory AuthPermissionDto.fromJson(Map? json) { + if (json == null) { + throw const FormatException('권한 정보가 비어 있습니다.'); + } + final resource = _readString(json, 'resource') ?? ''; + final actions = []; + final rawActions = json['actions']; + if (rawActions is List) { + for (final item in rawActions) { + if (item is String) { + final normalized = item.trim().toLowerCase(); + if (normalized.isNotEmpty) { + actions.add(normalized); + } + continue; + } + if (item is Map) { + for (final entry in item.entries) { + final normalized = entry.value.toString().trim().toLowerCase(); + if (normalized.isNotEmpty) { + actions.add(normalized); + } + } + } + } + } + return AuthPermissionDto( + resource: resource, + actions: actions.toList(growable: false), + ); + } + + AuthPermission toEntity() => + AuthPermission(resource: resource, actions: actions); +} + +AuthenticatedUser _parseUser(Map json) { + final id = _readOptionalInt(json, 'id') ?? 0; + final name = _readString(json, 'name') ?? ''; + final employeeNo = _readString(json, 'employee_no'); + final email = _readString(json, 'email'); + final group = JsonUtils.extractMap( + json, + keys: const ['group', 'primary_group'], + ); + return AuthenticatedUser( + id: id, + name: name, + employeeNo: employeeNo, + email: email, + primaryGroupId: _readOptionalInt(group, 'id'), + primaryGroupName: _readString(group, 'name'), + ); +} + +String? _readString(Map? source, String key) { + if (source == null) { + return null; + } + final value = source[key]; + if (value is String) { + return value.trim(); + } + return null; +} + +List> _readList(Map source, String key) { + final value = source[key]; + if (value is List) { + return value.whereType>().toList(growable: false); + } + return const []; +} + +Map _readMap(Map source, String key) { + final value = source[key]; + if (value is Map) { + return value; + } + return const {}; +} + +DateTime? _parseDate(String? value) { + if (value == null || value.isEmpty) { + return null; + } + return DateTime.tryParse(value); +} + +int? _readOptionalInt(Map? source, String key) { + if (source == null) { + return null; + } + final value = source[key]; + if (value is int) { + return value; + } + if (value is String) { + return int.tryParse(value); + } + if (value is double) { + return value.round(); + } + return null; +} diff --git a/lib/features/auth/data/repositories/auth_repository_remote.dart b/lib/features/auth/data/repositories/auth_repository_remote.dart new file mode 100644 index 0000000..df72fe4 --- /dev/null +++ b/lib/features/auth/data/repositories/auth_repository_remote.dart @@ -0,0 +1,43 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_routes.dart'; +import '../../domain/entities/auth_session.dart'; +import '../../domain/entities/login_request.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../dtos/auth_session_dto.dart'; + +/// 인증 관련 엔드포인트를 호출하는 원격 저장소 구현체. +class AuthRepositoryRemote implements AuthRepository { + AuthRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '${ApiRoutes.apiV1}/auth'; + + @override + Future login(LoginRequest request) async { + final response = await _api.post>( + '$_basePath/login', + data: { + 'identifier': request.identifier, + 'password': request.password, + 'remember_me': request.rememberMe, + }, + options: Options(responseType: ResponseType.json), + ); + final json = (response.data?['data'] as Map?) ?? {}; + return AuthSessionDto.fromJson(json).toEntity(); + } + + @override + Future refresh(String refreshToken) async { + final response = await _api.post>( + '$_basePath/refresh', + data: {'refresh_token': refreshToken}, + options: Options(responseType: ResponseType.json), + ); + final json = (response.data?['data'] as Map?) ?? {}; + return AuthSessionDto.fromJson(json).toEntity(); + } +} diff --git a/lib/features/auth/domain/entities/auth_permission.dart b/lib/features/auth/domain/entities/auth_permission.dart new file mode 100644 index 0000000..b218cc1 --- /dev/null +++ b/lib/features/auth/domain/entities/auth_permission.dart @@ -0,0 +1,32 @@ +import '../../../../core/permissions/permission_manager.dart'; +import '../../../../core/permissions/permission_resources.dart'; + +/// 로그인 응답에서 내려오는 단일 권한(리소스 + 액션 목록)을 표현한다. +class AuthPermission { + const AuthPermission({required this.resource, required this.actions}); + + /// 서버가 반환한 리소스 식별자 (예: `/stock-transactions`) + final String resource; + + /// 허용된 액션 문자열 목록 (예: `view`, `create`) + final List actions; + + /// [PermissionManager]가 이해할 수 있는 포맷으로 변환한다. + Map> toPermissionMap() { + final normalized = PermissionResources.normalize(resource); + final actionSet = {}; + for (final raw in actions) { + final matched = PermissionAction.values.where( + (action) => action.name == raw.trim().toLowerCase(), + ); + if (matched.isEmpty) { + continue; + } + actionSet.addAll(matched); + } + if (actionSet.isEmpty) { + return >{}; + } + return {normalized: actionSet}; + } +} diff --git a/lib/features/auth/domain/entities/auth_session.dart b/lib/features/auth/domain/entities/auth_session.dart new file mode 100644 index 0000000..36772e3 --- /dev/null +++ b/lib/features/auth/domain/entities/auth_session.dart @@ -0,0 +1,31 @@ +import 'auth_permission.dart'; +import 'authenticated_user.dart'; + +/// 로그인 또는 토큰 갱신 결과를 표현하는 세션 모델. +class AuthSession { + const AuthSession({ + required this.accessToken, + required this.refreshToken, + required this.expiresAt, + required this.user, + this.permissions = const [], + }); + + /// API 인증에 사용되는 액세스 토큰. + final String accessToken; + + /// 토큰 갱신에 사용되는 리프레시 토큰. + final String refreshToken; + + /// 액세스 토큰 만료 시각. + final DateTime? expiresAt; + + /// 로그인한 사용자 정보. + final AuthenticatedUser user; + + /// 사용자에게 할당된 권한 목록. + final List permissions; + + /// 리프레시 토큰이 유효한지 여부를 단순 판단한다. + bool get hasRefreshToken => refreshToken.isNotEmpty; +} diff --git a/lib/features/auth/domain/entities/authenticated_user.dart b/lib/features/auth/domain/entities/authenticated_user.dart new file mode 100644 index 0000000..306764d --- /dev/null +++ b/lib/features/auth/domain/entities/authenticated_user.dart @@ -0,0 +1,29 @@ +/// 로그인 성공 시 반환되는 사용자 정보. +class AuthenticatedUser { + const AuthenticatedUser({ + required this.id, + required this.name, + this.employeeNo, + this.email, + this.primaryGroupId, + this.primaryGroupName, + }); + + /// 사용자 식별자 + final int id; + + /// 이름 + final String name; + + /// 사번 + final String? employeeNo; + + /// 이메일 + final String? email; + + /// 기본 소속 그룹 ID + final int? primaryGroupId; + + /// 기본 소속 그룹명 + final String? primaryGroupName; +} diff --git a/lib/features/auth/domain/entities/login_request.dart b/lib/features/auth/domain/entities/login_request.dart new file mode 100644 index 0000000..ba40772 --- /dev/null +++ b/lib/features/auth/domain/entities/login_request.dart @@ -0,0 +1,21 @@ +/// 로그인 요청 값을 표현하는 도메인 모델. +/// +/// - identifier: 사번 또는 이메일 등 사용자가 입력한 계정 식별자 +/// - password: 평문 비밀번호 +/// - rememberMe: 재로그인 시 토큰 유지를 원하는지 여부 +class LoginRequest { + const LoginRequest({ + required this.identifier, + required this.password, + this.rememberMe = false, + }); + + /// 사번 또는 이메일 + final String identifier; + + /// 평문 비밀번호 + final String password; + + /// 재로그인 시 토큰 지속 여부 + final bool rememberMe; +} diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..284cb17 --- /dev/null +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,11 @@ +import '../entities/auth_session.dart'; +import '../entities/login_request.dart'; + +/// 인증 관련 API 호출을 추상화한 저장소 인터페이스. +abstract class AuthRepository { + /// 로그인 API를 호출해 세션을 생성한다. + Future login(LoginRequest request); + + /// 리프레시 토큰으로 새로운 세션을 발급받는다. + Future refresh(String refreshToken); +} diff --git a/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart new file mode 100644 index 0000000..f7bab96 --- /dev/null +++ b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart @@ -0,0 +1,204 @@ +import '../../../../core/common/utils/json_utils.dart'; +import '../../domain/entities/dashboard_kpi.dart'; +import '../../domain/entities/dashboard_pending_approval.dart'; +import '../../domain/entities/dashboard_summary.dart'; +import '../../domain/entities/dashboard_transaction_summary.dart'; + +/// 대시보드 요약 응답 DTO. +class DashboardSummaryDto { + const DashboardSummaryDto({ + this.generatedAt, + this.kpis = const [], + this.recentTransactions = const [], + this.pendingApprovals = const [], + }); + + final DateTime? generatedAt; + final List kpis; + final List recentTransactions; + final List pendingApprovals; + + factory DashboardSummaryDto.fromJson(Map json) { + final generatedAt = _parseDate(json['generated_at']); + final kpiList = JsonUtils.extractList(json, keys: const ['kpis']) + .map(DashboardKpiDto.fromJson) + .toList(growable: false); + final transactionList = + JsonUtils.extractList(json, keys: const ['recent_transactions']) + .map(DashboardTransactionDto.fromJson) + .toList(growable: false); + final approvalList = + JsonUtils.extractList(json, keys: const ['pending_approvals']) + .map(DashboardApprovalDto.fromJson) + .toList(growable: false); + + return DashboardSummaryDto( + generatedAt: generatedAt, + kpis: kpiList, + recentTransactions: transactionList, + pendingApprovals: approvalList, + ); + } + + DashboardSummary toEntity() { + return DashboardSummary( + generatedAt: generatedAt, + kpis: kpis.map((dto) => dto.toEntity()).toList(growable: false), + recentTransactions: recentTransactions + .map((dto) => dto.toEntity()) + .toList(growable: false), + pendingApprovals: pendingApprovals + .map((dto) => dto.toEntity()) + .toList(growable: false), + ); + } +} + +class DashboardKpiDto { + const DashboardKpiDto({ + required this.key, + required this.label, + required this.value, + this.trendLabel, + this.delta, + }); + + final String key; + final String label; + final num value; + final String? trendLabel; + final double? delta; + + factory DashboardKpiDto.fromJson(Map json) { + final key = _readString(json, 'key') ?? ''; + final label = _readString(json, 'label') ?? key; + final value = _readNum(json, 'value'); + final trendLabel = _readString(json, 'trend_label'); + final delta = _readDouble(json, 'delta'); + return DashboardKpiDto( + key: key, + label: label, + value: value ?? 0, + trendLabel: trendLabel, + delta: delta, + ); + } + + DashboardKpi toEntity() { + return DashboardKpi( + key: key, + label: label, + value: value, + trendLabel: trendLabel, + delta: delta, + ); + } +} + +class DashboardTransactionDto { + const DashboardTransactionDto({ + required this.transactionNo, + required this.transactionDate, + required this.transactionType, + required this.statusName, + required this.createdBy, + }); + + final String transactionNo; + final String transactionDate; + final String transactionType; + final String statusName; + final String createdBy; + + factory DashboardTransactionDto.fromJson(Map json) { + return DashboardTransactionDto( + transactionNo: _readString(json, 'transaction_no') ?? '', + transactionDate: _readString(json, 'transaction_date') ?? '', + transactionType: _readString(json, 'transaction_type') ?? '', + statusName: _readString(json, 'status_name') ?? '', + createdBy: _readString(json, 'created_by') ?? '', + ); + } + + DashboardTransactionSummary toEntity() { + return DashboardTransactionSummary( + transactionNo: transactionNo, + transactionDate: transactionDate, + transactionType: transactionType, + statusName: statusName, + createdBy: createdBy, + ); + } +} + +class DashboardApprovalDto { + const DashboardApprovalDto({ + required this.approvalNo, + required this.title, + required this.stepSummary, + this.requestedAt, + }); + + final String approvalNo; + final String title; + final String stepSummary; + final String? requestedAt; + + factory DashboardApprovalDto.fromJson(Map json) { + return DashboardApprovalDto( + approvalNo: _readString(json, 'approval_no') ?? '', + title: _readString(json, 'title') ?? '', + stepSummary: _readString(json, 'step_summary') ?? '', + requestedAt: _readString(json, 'requested_at'), + ); + } + + DashboardPendingApproval toEntity() { + return DashboardPendingApproval( + approvalNo: approvalNo, + title: title, + stepSummary: stepSummary, + requestedAt: requestedAt, + ); + } +} + +DateTime? _parseDate(Object? value) { + if (value is DateTime) { + return value; + } + if (value is String) { + return DateTime.tryParse(value); + } + return null; +} + +String? _readString(Map? source, String key) { + if (source == null) return null; + final value = source[key]; + if (value is String) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + return null; +} + +num? _readNum(Map? source, String key) { + if (source == null) return null; + final value = source[key]; + if (value is num) { + return value; + } + if (value is String) { + return num.tryParse(value); + } + return null; +} + +double? _readDouble(Map? source, String key) { + final value = _readNum(source, key); + if (value == null) { + return null; + } + return value.toDouble(); +} diff --git a/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart b/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart new file mode 100644 index 0000000..bf5072f --- /dev/null +++ b/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_routes.dart'; +import '../../domain/entities/dashboard_summary.dart'; +import '../../domain/repositories/dashboard_repository.dart'; +import '../dtos/dashboard_summary_dto.dart'; + +/// 대시보드 요약 데이터를 불러오는 원격 저장소. +class DashboardRepositoryRemote implements DashboardRepository { + DashboardRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _summaryPath = '${ApiRoutes.apiV1}/dashboard/summary'; + + @override + Future fetchSummary() async { + final response = await _api.get>( + _summaryPath, + options: Options(responseType: ResponseType.json), + ); + final json = (response.data?['data'] as Map?) ?? + response.data ?? + const {}; + return DashboardSummaryDto.fromJson(json).toEntity(); + } +} diff --git a/lib/features/dashboard/domain/entities/dashboard_kpi.dart b/lib/features/dashboard/domain/entities/dashboard_kpi.dart new file mode 100644 index 0000000..d8cc514 --- /dev/null +++ b/lib/features/dashboard/domain/entities/dashboard_kpi.dart @@ -0,0 +1,33 @@ +/// 대시보드 KPI 카드에 사용할 수치 정보. +class DashboardKpi { + const DashboardKpi({ + required this.key, + required this.label, + required this.value, + this.trendLabel, + this.delta, + }); + + /// API에서 식별 목적으로 사용하는 키 (예: inbound, outbound) + final String key; + + /// 사용자에게 노출할 라벨. + final String label; + + /// KPI 수치(건수 등) + final num value; + + /// 전일 대비 등 비교 텍스트. + final String? trendLabel; + + /// 증감 퍼센트(선택) + final double? delta; + + /// 카드에 표시할 값 문자열을 생성한다. + String get displayValue { + if (value is int || value == value.roundToDouble()) { + return '${value.round()}건'; + } + return value.toString(); + } +} diff --git a/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart new file mode 100644 index 0000000..56dadde --- /dev/null +++ b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart @@ -0,0 +1,21 @@ +/// 결재 대기 요약 정보. +class DashboardPendingApproval { + const DashboardPendingApproval({ + required this.approvalNo, + required this.title, + required this.stepSummary, + this.requestedAt, + }); + + /// 결재 문서 번호 + final String approvalNo; + + /// 결재 제목 + final String title; + + /// 현재 단계/승인자 요약 + final String stepSummary; + + /// 상신 일시(문자열) + final String? requestedAt; +} diff --git a/lib/features/dashboard/domain/entities/dashboard_summary.dart b/lib/features/dashboard/domain/entities/dashboard_summary.dart new file mode 100644 index 0000000..b86c214 --- /dev/null +++ b/lib/features/dashboard/domain/entities/dashboard_summary.dart @@ -0,0 +1,35 @@ +import 'dashboard_kpi.dart'; +import 'dashboard_pending_approval.dart'; +import 'dashboard_transaction_summary.dart'; + +/// 대시보드 전체 요약 응답. +class DashboardSummary { + const DashboardSummary({ + required this.generatedAt, + required this.kpis, + required this.recentTransactions, + required this.pendingApprovals, + }); + + /// 요약 데이터 생성 시각. + final DateTime? generatedAt; + + /// KPI 카드 목록. + final List kpis; + + /// 최근 트랜잭션 목록. + final List recentTransactions; + + /// 결재 대기 목록. + final List pendingApprovals; + + /// KPI를 키로 찾는다. + DashboardKpi? findKpi(String key) { + for (final kpi in kpis) { + if (kpi.key == key) { + return kpi; + } + } + return null; + } +} diff --git a/lib/features/dashboard/domain/entities/dashboard_transaction_summary.dart b/lib/features/dashboard/domain/entities/dashboard_transaction_summary.dart new file mode 100644 index 0000000..9aa326f --- /dev/null +++ b/lib/features/dashboard/domain/entities/dashboard_transaction_summary.dart @@ -0,0 +1,25 @@ +/// 최근 트랜잭션 요약 정보. +class DashboardTransactionSummary { + const DashboardTransactionSummary({ + required this.transactionNo, + required this.transactionDate, + required this.transactionType, + required this.statusName, + required this.createdBy, + }); + + /// 트랜잭션 번호 + final String transactionNo; + + /// 발생 일자 (형식: yyyy-MM-dd) + final String transactionDate; + + /// 입고/출고/대여 등 유형 + final String transactionType; + + /// 현재 상태 명칭 + final String statusName; + + /// 작성자 이름 + final String createdBy; +} diff --git a/lib/features/dashboard/domain/repositories/dashboard_repository.dart b/lib/features/dashboard/domain/repositories/dashboard_repository.dart new file mode 100644 index 0000000..425a4b9 --- /dev/null +++ b/lib/features/dashboard/domain/repositories/dashboard_repository.dart @@ -0,0 +1,7 @@ +import '../entities/dashboard_summary.dart'; + +/// 대시보드 데이터를 제공하는 저장소 인터페이스. +abstract class DashboardRepository { + /// 대시보드 요약 정보를 조회한다. + Future fetchSummary(); +} diff --git a/lib/features/dashboard/presentation/controllers/dashboard_controller.dart b/lib/features/dashboard/presentation/controllers/dashboard_controller.dart new file mode 100644 index 0000000..fe644b6 --- /dev/null +++ b/lib/features/dashboard/presentation/controllers/dashboard_controller.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +import '../../../../core/network/failure.dart'; +import '../../domain/entities/dashboard_summary.dart'; +import '../../domain/repositories/dashboard_repository.dart'; + +/// 대시보드 화면 상태를 관리하는 컨트롤러. +class DashboardController extends ChangeNotifier { + DashboardController({required DashboardRepository repository}) + : _repository = repository; + + final DashboardRepository _repository; + + DashboardSummary? _summary; + bool _isLoading = false; + bool _isRefreshing = false; + String? _errorMessage; + + DashboardSummary? get summary => _summary; + bool get isLoading => _isLoading; + bool get isRefreshing => _isRefreshing; + String? get errorMessage => _errorMessage; + + /// 초기 로딩(캐시가 없을 때만 실행) + Future ensureLoaded() async { + if (_summary != null || _isLoading) { + return; + } + await refresh(); + } + + /// 서버에서 최신 요약을 다시 불러온다. + Future refresh() async { + _isLoading = _summary == null; + _isRefreshing = _summary != null; + _errorMessage = null; + notifyListeners(); + try { + final result = await _repository.fetchSummary(); + _summary = result; + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); + } finally { + _isLoading = false; + _isRefreshing = false; + notifyListeners(); + } + } +} diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index da03c57..15c8361 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -1,115 +1,238 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/empty_state.dart'; +import '../../domain/entities/dashboard_kpi.dart'; +import '../../domain/entities/dashboard_pending_approval.dart'; +import '../../domain/entities/dashboard_transaction_summary.dart'; +import '../../domain/repositories/dashboard_repository.dart'; +import '../controllers/dashboard_controller.dart'; + /// Superport 메인 대시보드 화면. -class DashboardPage extends StatelessWidget { +class DashboardPage extends StatefulWidget { const DashboardPage({super.key}); - static const _recentTransactions = [ - ('IN-20240312-003', '2024-03-12', '입고', '승인완료', '김담당'), - ('OUT-20240311-005', '2024-03-11', '출고', '출고대기', '이물류'), - ('RENT-20240310-001', '2024-03-10', '대여', '대여중', '박대여'), - ('APP-20240309-004', '2024-03-09', '결재', '진행중', '최결재'), - ]; + @override + State createState() => _DashboardPageState(); +} - static const _pendingApprovals = [ - ('APP-20240312-010', '설비 구매', '2/4 단계 진행 중'), - ('APP-20240311-004', '창고 정기 점검', '승인 대기'), - ('APP-20240309-002', '계약 연장', '반려 후 재상신'), +class _DashboardPageState extends State { + late final DashboardController _controller; + Timer? _autoRefreshTimer; + final DateFormat _timestampFormat = DateFormat('yyyy-MM-dd HH:mm'); + + static const _kpiPresets = [ + _KpiPreset( + key: 'inbound', + label: '오늘 입고', + icon: lucide.LucideIcons.packagePlus, + ), + _KpiPreset( + key: 'outbound', + label: '오늘 출고', + icon: lucide.LucideIcons.packageMinus, + ), + _KpiPreset( + key: 'pending_approvals', + label: '결재 대기', + icon: lucide.LucideIcons.messageSquareWarning, + ), + _KpiPreset( + key: 'customer_inquiries', + label: '고객사 문의', + icon: lucide.LucideIcons.users, + ), ]; + @override + void initState() { + super.initState(); + _controller = DashboardController( + repository: GetIt.I(), + ); + _controller.ensureLoaded(); + _autoRefreshTimer = Timer.periodic( + const Duration(minutes: 5), + (_) => _controller.refresh(), + ); + } + + @override + void dispose() { + _autoRefreshTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return AppLayout( - title: '대시보드', - subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.', - breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')], - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12, bottom: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Wrap( - spacing: 16, - runSpacing: 16, - children: const [ - _KpiCard( - icon: lucide.LucideIcons.packagePlus, - label: '오늘 입고', - value: '12건', - trend: '+3 vs 어제', + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return AppLayout( + title: '대시보드', + subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.', + breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')], + actions: [ + ShadButton.ghost( + onPressed: _controller.isLoading ? null : _controller.refresh, + leading: _controller.isRefreshing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(lucide.LucideIcons.refreshCw, size: 16), + child: const Text('새로고침'), + ), + ], + child: _buildBody(context), + ); + }, + ); + } + + Widget _buildBody(BuildContext context) { + final summary = _controller.summary; + final theme = ShadTheme.of(context); + + if (_controller.isLoading && summary == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (summary == null) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: ShadCard( + title: Text('대시보드 데이터를 불러오지 못했습니다.', style: theme.textTheme.h3), + description: Text( + _controller.errorMessage ?? '네트워크 연결을 확인한 뒤 다시 시도해 주세요.', + style: theme.textTheme.muted, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SuperportEmptyState( + title: '데이터가 없습니다', + description: '권한 또는 네트워크 상태를 확인한 뒤 다시 시도해 주세요.', ), - _KpiCard( - icon: lucide.LucideIcons.packageMinus, - label: '오늘 출고', - value: '9건', - trend: '-2 vs 어제', - ), - _KpiCard( - icon: lucide.LucideIcons.messageSquareWarning, - label: '결재 대기', - value: '5건', - trend: '평균 12시간 지연', - ), - _KpiCard( - icon: lucide.LucideIcons.users, - label: '고객사 문의', - value: '7건', - trend: '지원팀 확인 중', + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _controller.refresh, + child: const Text('다시 시도'), + ), ), ], ), - const SizedBox(height: 24), - LayoutBuilder( - builder: (context, constraints) { - final showSidePanel = constraints.maxWidth > 920; - return Flex( - direction: showSidePanel ? Axis.horizontal : Axis.vertical, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: _RecentTransactionsCard( - transactions: _recentTransactions, - ), - ), - if (showSidePanel) - const SizedBox(width: 16) - else - const SizedBox(height: 16), - Flexible( - flex: 2, - child: _PendingApprovalCard(approvals: _pendingApprovals), - ), - ], - ); - }, - ), - const SizedBox(height: 24), - const _ReminderPanel(), - ], + ), ), + ); + } + + final kpiMap = {for (final item in summary.kpis) item.key: item}; + + return SingleChildScrollView( + padding: const EdgeInsets.only(right: 12, bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_controller.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.destructive.withValues(alpha: 0.12), + ), + child: ListTile( + leading: Icon( + lucide.LucideIcons.info, + color: theme.colorScheme.destructive, + ), + title: Text( + '대시보드 데이터를 최신 상태로 동기화하지 못했습니다.', + style: theme.textTheme.small, + ), + subtitle: Text( + _controller.errorMessage!, + style: theme.textTheme.small, + ), + trailing: TextButton( + onPressed: _controller.refresh, + child: const Text('다시 시도'), + ), + ), + ), + ), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (final preset in _kpiPresets) + _KpiCard( + icon: preset.icon, + label: preset.label, + kpi: kpiMap[preset.key], + ), + ], + ), + const SizedBox(height: 12), + if (summary.generatedAt != null) + Text( + '최근 갱신: ${_timestampFormat.format(summary.generatedAt!.toLocal())}', + style: theme.textTheme.small, + ), + const SizedBox(height: 24), + LayoutBuilder( + builder: (context, constraints) { + final showSidePanel = constraints.maxWidth > 920; + return Flex( + direction: showSidePanel ? Axis.horizontal : Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: _RecentTransactionsCard( + transactions: summary.recentTransactions, + ), + ), + if (showSidePanel) + const SizedBox(width: 16) + else + const SizedBox(height: 16), + Flexible( + flex: 2, + child: _PendingApprovalCard( + approvals: summary.pendingApprovals, + ), + ), + ], + ); + }, + ), + const SizedBox(height: 24), + const _ReminderPanel(), + ], ), ); } } class _KpiCard extends StatelessWidget { - const _KpiCard({ - required this.icon, - required this.label, - required this.value, - required this.trend, - }); + const _KpiCard({required this.icon, required this.label, this.kpi}); final IconData icon; final String label; - final String value; - final String trend; + final DashboardKpi? kpi; @override Widget build(BuildContext context) { @@ -124,9 +247,9 @@ class _KpiCard extends StatelessWidget { const SizedBox(height: 12), Text(label, style: theme.textTheme.small), const SizedBox(height: 6), - Text(value, style: theme.textTheme.h3), + Text(kpi?.displayValue ?? '--', style: theme.textTheme.h3), const SizedBox(height: 8), - Text(trend, style: theme.textTheme.muted), + Text(kpi?.trendLabel ?? '데이터 동기화 중', style: theme.textTheme.muted), ], ), ), @@ -137,7 +260,7 @@ class _KpiCard extends StatelessWidget { class _RecentTransactionsCard extends StatelessWidget { const _RecentTransactionsCard({required this.transactions}); - final List<(String, String, String, String, String)> transactions; + final List transactions; @override Widget build(BuildContext context) { @@ -150,27 +273,34 @@ class _RecentTransactionsCard extends StatelessWidget { ), child: SizedBox( height: 320, - child: ShadTable.list( - header: const [ - ShadTableCell.header(child: Text('번호')), - ShadTableCell.header(child: Text('일자')), - ShadTableCell.header(child: Text('유형')), - ShadTableCell.header(child: Text('상태')), - ShadTableCell.header(child: Text('작성자')), - ], - children: [ - for (final row in transactions) - [ - ShadTableCell(child: Text(row.$1)), - ShadTableCell(child: Text(row.$2)), - ShadTableCell(child: Text(row.$3)), - ShadTableCell(child: Text(row.$4)), - ShadTableCell(child: Text(row.$5)), - ], - ], - columnSpanExtent: (index) => const FixedTableSpanExtent(140), - rowSpanExtent: (index) => const FixedTableSpanExtent(52), - ), + child: transactions.isEmpty + ? const Center( + child: SuperportEmptyState( + title: '최근 트랜잭션이 없습니다', + description: '최근 7일간 생성된 입·출·대여 트랜잭션이 없습니다.', + ), + ) + : ShadTable.list( + header: const [ + ShadTableCell.header(child: Text('번호')), + ShadTableCell.header(child: Text('일자')), + ShadTableCell.header(child: Text('유형')), + ShadTableCell.header(child: Text('상태')), + ShadTableCell.header(child: Text('작성자')), + ], + children: [ + for (final row in transactions) + [ + ShadTableCell(child: Text(row.transactionNo)), + ShadTableCell(child: Text(row.transactionDate)), + ShadTableCell(child: Text(row.transactionType)), + ShadTableCell(child: Text(row.statusName)), + ShadTableCell(child: Text(row.createdBy)), + ], + ], + columnSpanExtent: (index) => const FixedTableSpanExtent(140), + rowSpanExtent: (index) => const FixedTableSpanExtent(52), + ), ), ); } @@ -179,7 +309,7 @@ class _RecentTransactionsCard extends StatelessWidget { class _PendingApprovalCard extends StatelessWidget { const _PendingApprovalCard({required this.approvals}); - final List<(String, String, String)> approvals; + final List approvals; @override Widget build(BuildContext context) { @@ -204,44 +334,53 @@ class _PendingApprovalCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final approval in approvals) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( + for (final approval in approvals) ...[ + ListTile( + leading: const Icon(lucide.LucideIcons.fileCheck, size: 20), + title: Text(approval.approvalNo, style: theme.textTheme.small), + subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - lucide.LucideIcons.bell, - size: 18, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(approval.$1, style: theme.textTheme.small), - const SizedBox(height: 4), - Text(approval.$2, style: theme.textTheme.h4), - const SizedBox(height: 4), - Text(approval.$3, style: theme.textTheme.muted), - ], + Text(approval.title, style: theme.textTheme.p), + const SizedBox(height: 4), + Text(approval.stepSummary, style: theme.textTheme.muted), + if (approval.requestedAt != null && + approval.requestedAt!.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '상신: ${approval.requestedAt}', + style: theme.textTheme.small, + ), ), - ), - ShadButton.ghost( - size: ShadButtonSize.sm, - child: const Text('상세'), - onPressed: () {}, - ), ], ), + trailing: ShadButton.ghost( + size: ShadButtonSize.sm, + child: const Text('상세'), + onPressed: () {}, + ), ), + const Divider(), + ], ], ), ); } } +class _KpiPreset { + const _KpiPreset({ + required this.key, + required this.label, + required this.icon, + }); + + final String key; + final String label; + final IconData icon; +} + class _ReminderPanel extends StatelessWidget { const _ReminderPanel(); @@ -297,7 +436,7 @@ class _ReminderItem extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 18, color: theme.colorScheme.secondary), + Icon(icon, size: 18, color: theme.colorScheme.primary), const SizedBox(width: 12), Expanded( child: Column( diff --git a/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart index d7d43f2..487f5ef 100644 --- a/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart +++ b/lib/features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart @@ -58,9 +58,10 @@ class StockTransactionRepositoryRemote implements StockTransactionRepository { int id, StockTransactionUpdateInput input, ) async { + final payload = {'id': id, ...input.toPayload()}; final response = await _api.patch>( '$_basePath/$id', - data: input.toPayload(), + data: payload, options: Options(responseType: ResponseType.json), ); return _parseSingle(response.data); diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart index 694d39e..a4d676d 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -27,17 +27,18 @@ class StockTransactionCreateInput { final StockTransactionApprovalInput? approval; Map toPayload() { + final sanitizedNote = note?.trim(); return { if (transactionNo != null && transactionNo!.trim().isNotEmpty) 'transaction_no': transactionNo, 'transaction_type_id': transactionTypeId, 'transaction_status_id': transactionStatusId, 'warehouse_id': warehouseId, - 'transaction_date': transactionDate.toIso8601String(), + 'transaction_date': _formatNaiveDate(transactionDate), 'created_by_id': createdById, - if (note != null && note!.trim().isNotEmpty) 'note': note, + 'note': sanitizedNote, if (expectedReturnDate != null) - 'expected_return_date': expectedReturnDate!.toIso8601String(), + 'expected_return_date': _formatNaiveDate(expectedReturnDate!), if (lines.isNotEmpty) 'lines': lines.map((line) => line.toJson()).toList(growable: false), if (customers.isNotEmpty) @@ -62,11 +63,13 @@ class StockTransactionUpdateInput { final DateTime? expectedReturnDate; Map toPayload() { + final sanitizedNote = note?.trim(); return { 'transaction_status_id': transactionStatusId, - if (note != null && note!.trim().isNotEmpty) 'note': note, - if (expectedReturnDate != null) - 'expected_return_date': expectedReturnDate!.toIso8601String(), + 'note': sanitizedNote, + 'expected_return_date': expectedReturnDate == null + ? null + : _formatNaiveDate(expectedReturnDate!), }; } } @@ -88,12 +91,13 @@ class TransactionLineCreateInput { final String? note; Map toJson() { + final sanitizedNote = note?.trim(); return { 'line_no': lineNo, 'product_id': productId, 'quantity': quantity, 'unit_price': unitPrice, - if (note != null && note!.trim().isNotEmpty) 'note': note, + 'note': sanitizedNote, }; } } @@ -115,12 +119,13 @@ class TransactionLineUpdateInput { final String? note; Map toJson() { + final sanitizedNote = note?.trim(); return { 'id': id, if (lineNo != null) 'line_no': lineNo, if (quantity != null) 'quantity': quantity, if (unitPrice != null) 'unit_price': unitPrice, - if (note != null && note!.trim().isNotEmpty) 'note': note, + if (note != null) 'note': sanitizedNote, }; } } @@ -133,10 +138,8 @@ class TransactionCustomerCreateInput { final String? note; Map toJson() { - return { - 'customer_id': customerId, - if (note != null && note!.trim().isNotEmpty) 'note': note, - }; + final sanitizedNote = note?.trim(); + return {'customer_id': customerId, 'note': sanitizedNote}; } } @@ -148,10 +151,8 @@ class TransactionCustomerUpdateInput { final String? note; Map toJson() { - return { - 'id': id, - if (note != null && note!.trim().isNotEmpty) 'note': note, - }; + final sanitizedNote = note?.trim(); + return {'id': id, 'note': sanitizedNote}; } } @@ -205,12 +206,7 @@ class StockTransactionListFilter { /// 백엔드가 요구하는 `yyyy-MM-dd`(NaiveDate) 형식으로 변환한다. String _formatDate(DateTime value) { - final iso = value.toIso8601String(); - final separatorIndex = iso.indexOf('T'); - if (separatorIndex == -1) { - return iso; - } - return iso.substring(0, separatorIndex); + return _formatNaiveDate(value); } } @@ -233,7 +229,14 @@ class StockTransactionApprovalInput { 'approval_no': approvalNo, if (approvalStatusId != null) 'approval_status_id': approvalStatusId, 'requested_by_id': requestedById, - if (note != null && note!.trim().isNotEmpty) 'note': note, + 'note': note?.trim(), }; } } + +String _formatNaiveDate(DateTime value) { + final year = value.year.toString().padLeft(4, '0'); + final month = value.month.toString().padLeft(2, '0'); + final day = value.day.toString().padLeft(2, '0'); + return '$year-$month-$day'; +} diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 5747f5d..35d7e7f 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -11,6 +11,9 @@ import '../../../../core/network/api_error.dart'; import '../../../../core/network/failure.dart'; import '../../../../core/permissions/permission_manager.dart'; import '../../../../core/permissions/permission_resources.dart'; +import '../../../auth/application/auth_service.dart'; +import '../../../auth/domain/entities/auth_session.dart'; +import '../../../auth/domain/entities/login_request.dart'; import '../../../masters/group/domain/entities/group.dart'; import '../../../masters/group/domain/repositories/group_repository.dart'; import '../../../masters/group_permission/application/permission_synchronizer.dart'; @@ -49,8 +52,6 @@ class _LoginPageState extends State { final id = idController.text.trim(); final password = passwordController.text.trim(); - await Future.delayed(const Duration(milliseconds: 600)); - if (id.isEmpty || password.isEmpty) { setState(() { errorMessage = '아이디와 비밀번호를 모두 입력하세요.'; @@ -67,9 +68,19 @@ class _LoginPageState extends State { return; } + final authService = GetIt.I(); + AuthSession? session; + if (!mounted) return; try { - await _synchronizePermissions(); + session = await authService.login( + LoginRequest( + identifier: id, + password: password, + rememberMe: rememberMe, + ), + ); + await _applyPermissions(session); } catch (error) { if (!mounted) return; final failure = Failure.from(error); @@ -317,24 +328,50 @@ class _LoginPageState extends State { context.go(dashboardRoutePath); } - Future _synchronizePermissions() async { + Future _applyPermissions(AuthSession session) async { + final manager = PermissionScope.of(context); + manager.clearServerPermissions(); + + final aggregated = >{}; + for (final permission in session.permissions) { + final map = permission.toPermissionMap(); + for (final entry in map.entries) { + aggregated + .putIfAbsent(entry.key, () => {}) + .addAll(entry.value); + } + } + if (aggregated.isNotEmpty) { + manager.applyServerPermissions(aggregated); + return; + } + + await _synchronizePermissions(groupId: session.user.primaryGroupId); + } + + Future _synchronizePermissions({int? groupId}) async { final manager = PermissionScope.of(context); manager.clearServerPermissions(); final groupRepository = GetIt.I(); - final defaultGroups = await groupRepository.list( - page: 1, - pageSize: 1, - isDefault: true, - ); - Group? targetGroup = _firstGroupWithId(defaultGroups.items); + int? targetGroupId = groupId; - if (targetGroup == null) { - final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1); - targetGroup = _firstGroupWithId(fallbackGroups.items); + if (targetGroupId == null) { + final defaultGroups = await groupRepository.list( + page: 1, + pageSize: 1, + isDefault: true, + ); + var targetGroup = _firstGroupWithId(defaultGroups.items); + + if (targetGroup == null) { + final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1); + targetGroup = _firstGroupWithId(fallbackGroups.items); + } + targetGroupId = targetGroup?.id; } - if (targetGroup?.id == null) { + if (targetGroupId == null) { return; } @@ -343,8 +380,7 @@ class _LoginPageState extends State { repository: permissionRepository, manager: manager, ); - final groupId = targetGroup!.id!; - await synchronizer.syncForGroup(groupId); + await synchronizer.syncForGroup(targetGroupId); } Group? _firstGroupWithId(List groups) { diff --git a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart index 68cfaca..78b63d5 100644 --- a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart +++ b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart @@ -66,9 +66,10 @@ class CustomerRepositoryRemote implements CustomerRepository { /// 고객 정보를 수정한다. @override Future update(int id, CustomerInput input) async { + final payload = customerInputToJson(input)..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: customerInputToJson(input), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/masters/group/data/repositories/group_repository_remote.dart b/lib/features/masters/group/data/repositories/group_repository_remote.dart index df4c317..a482ca2 100644 --- a/lib/features/masters/group/data/repositories/group_repository_remote.dart +++ b/lib/features/masters/group/data/repositories/group_repository_remote.dart @@ -63,9 +63,10 @@ class GroupRepositoryRemote implements GroupRepository { /// 그룹 정보를 수정한다. @override Future update(int id, GroupInput input) async { + final payload = input.toPayload()..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: input.toPayload(), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart index 496a332..37401d6 100644 --- a/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart +++ b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart @@ -57,9 +57,10 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { /// 그룹 권한을 수정한다. @override Future update(int id, GroupPermissionInput input) async { + final payload = input.toPayload()..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: input.toPayload(), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart index 267e934..68bc264 100644 --- a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart +++ b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart @@ -56,9 +56,10 @@ class MenuRepositoryRemote implements MenuRepository { /// 메뉴 정보를 수정한다. @override Future update(int id, MenuInput input) async { + final payload = input.toPayload()..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: input.toPayload(), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/masters/product/data/repositories/product_repository_remote.dart b/lib/features/masters/product/data/repositories/product_repository_remote.dart index b6bd4a1..1f273bd 100644 --- a/lib/features/masters/product/data/repositories/product_repository_remote.dart +++ b/lib/features/masters/product/data/repositories/product_repository_remote.dart @@ -56,9 +56,10 @@ class ProductRepositoryRemote implements ProductRepository { /// 제품 정보를 수정한다. @override Future update(int id, ProductInput input) async { + final payload = productInputToJson(input)..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: productInputToJson(input), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/masters/user/data/repositories/user_repository_remote.dart b/lib/features/masters/user/data/repositories/user_repository_remote.dart index f9fdcb0..2a4ea5c 100644 --- a/lib/features/masters/user/data/repositories/user_repository_remote.dart +++ b/lib/features/masters/user/data/repositories/user_repository_remote.dart @@ -54,9 +54,10 @@ class UserRepositoryRemote implements UserRepository { /// 사용자 정보를 수정한다. @override Future update(int id, UserInput input) async { + final payload = input.toPayload()..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: input.toPayload(), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart index a183746..b55a91a 100644 --- a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart +++ b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart @@ -50,9 +50,10 @@ class VendorRepositoryRemote implements VendorRepository { @override Future update(int id, VendorInput input) async { + final payload = vendorInputToJson(input)..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: vendorInputToJson(input), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart index de0ffb8..a359bb4 100644 --- a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart +++ b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart @@ -53,9 +53,10 @@ class WarehouseRepositoryRemote implements WarehouseRepository { /// 창고 정보를 수정한다. @override Future update(int id, WarehouseInput input) async { + final payload = warehouseInputToJson(input)..['id'] = id; final response = await _api.patch>( '$_basePath/$id', - data: warehouseInputToJson(input), + data: payload, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/reporting/data/repositories/reporting_repository_remote.dart b/lib/features/reporting/data/repositories/reporting_repository_remote.dart index 9cd55da..4b7e025 100644 --- a/lib/features/reporting/data/repositories/reporting_repository_remote.dart +++ b/lib/features/reporting/data/repositories/reporting_repository_remote.dart @@ -46,8 +46,8 @@ class ReportingRepositoryRemote implements ReportingRepository { Map _buildTransactionQuery(ReportExportRequest request) { return { - 'date_from': _formatDateOnly(request.from), - 'date_to': _formatDateOnly(request.to), + 'from': _formatDateOnly(request.from), + 'to': _formatDateOnly(request.to), 'format': request.format.apiValue, if (request.transactionStatusId != null) 'transaction_status_id': request.transactionStatusId, @@ -63,6 +63,8 @@ class ReportingRepositoryRemote implements ReportingRepository { 'from': request.from.toIso8601String(), 'to': request.to.toIso8601String(), 'format': request.format.apiValue, + if (request.transactionStatusId != null) + 'transaction_status_id': request.transactionStatusId, if (request.approvalStatusId != null) 'approval_status_id': request.approvalStatusId, if (request.requestedById != null) diff --git a/lib/features/util/postal_search/presentation/pages/postal_search_page.dart b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart index bac059b..c4f7922 100644 --- a/lib/features/util/postal_search/presentation/pages/postal_search_page.dart +++ b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart @@ -36,7 +36,7 @@ class _PostalSearchPageState extends State { child: ShadCard( title: Text('우편번호 검색 모달 미리보기', style: theme.textTheme.h3), description: Text( - '검색 버튼을 눌러 모달 UI를 확인하세요. 검색 API 연동은 이후 단계에서 진행됩니다.', + '검색 버튼을 눌러 실제 우편번호 API 결과와 모달 UI를 확인하세요.', style: theme.textTheme.muted, ), footer: Row( diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 1622683..ef5b17c 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -7,6 +7,11 @@ import 'core/network/api_client.dart'; import 'core/network/api_error.dart'; import 'core/network/interceptors/auth_interceptor.dart'; import 'core/services/token_storage.dart'; +import 'features/auth/application/auth_service.dart'; +import 'features/auth/data/repositories/auth_repository_remote.dart'; +import 'features/auth/domain/repositories/auth_repository.dart'; +import 'features/dashboard/data/repositories/dashboard_repository_remote.dart'; +import 'features/dashboard/domain/repositories/dashboard_repository.dart'; import 'features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart'; import 'features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import 'features/inventory/transactions/data/repositories/stock_transaction_repository_remote.dart'; @@ -69,7 +74,16 @@ Future initInjection({ sl.registerLazySingleton(() => tokenStorage); sl.registerLazySingleton(ApiErrorMapper.new); - final authInterceptor = AuthInterceptor(tokenStorage: tokenStorage, dio: dio); + final authInterceptor = AuthInterceptor( + tokenStorage: tokenStorage, + dio: dio, + onRefresh: () { + if (!sl.isRegistered()) { + return Future.value(null); + } + return sl().refreshForInterceptor(); + }, + ); dio.interceptors.add(authInterceptor); // 개발용 로거는 필요 시 추가 (pretty_dio_logger 등) @@ -80,6 +94,21 @@ Future initInjection({ () => ApiClient(dio: dio, errorMapper: sl()), ); + // 인증 서비스 등록 + sl.registerLazySingleton( + () => AuthRepositoryRemote(apiClient: sl()), + ); + sl.registerLazySingleton( + () => AuthService( + repository: sl(), + tokenStorage: sl(), + ), + ); + + sl.registerLazySingleton( + () => DashboardRepositoryRemote(apiClient: sl()), + ); + // 리포지토리 등록 (예: 벤더) sl.registerLazySingleton( () => VendorRepositoryRemote(apiClient: sl()), diff --git a/test/features/approvals/approval_page_permission_test.dart b/test/features/approvals/approval_page_permission_test.dart index 714975b..dfd55a0 100644 --- a/test/features/approvals/approval_page_permission_test.dart +++ b/test/features/approvals/approval_page_permission_test.dart @@ -245,7 +245,7 @@ class _StubApprovalRepository implements ApprovalRepository { } @override - Future create(ApprovalInput input) { + Future create(ApprovalCreateInput input) { throw UnimplementedError(); } @@ -260,7 +260,7 @@ class _StubApprovalRepository implements ApprovalRepository { } @override - Future update(int id, ApprovalInput input) { + Future update(ApprovalUpdateInput input) { throw UnimplementedError(); } } diff --git a/test/features/approvals/data/approval_repository_remote_test.dart b/test/features/approvals/data/approval_repository_remote_test.dart index 32d3d02..e0e144d 100644 --- a/test/features/approvals/data/approval_repository_remote_test.dart +++ b/test/features/approvals/data/approval_repository_remote_test.dart @@ -68,6 +68,104 @@ void main() { expect(query['include'], 'steps,histories'); }); + test('create는 필수 필드를 전달한다', () async { + const path = '/api/v1/approvals'; + when( + () => apiClient.post>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'data': { + 'id': 5001, + 'approval_no': 'APP-2025-0001', + 'transaction_id': 9001, + }, + }, + statusCode: 201, + requestOptions: RequestOptions(path: path), + ), + ); + + final input = ApprovalCreateInput( + transactionId: 9001, + approvalNo: 'APP-2025-0001', + approvalStatusId: 1, + requestedById: 7, + note: ' 신규 결재 ', + ); + + await repository.create(input); + + final captured = verify( + () => apiClient.post>( + captureAny(), + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final payload = captured[1] as Map; + expect(payload['transaction_id'], 9001); + expect(payload['approval_no'], 'APP-2025-0001'); + expect(payload['approval_status_id'], 1); + expect(payload['requested_by_id'], 7); + expect(payload['note'], '신규 결재'); + }); + + test('update는 id를 포함해 패치를 수행한다', () async { + const path = '/api/v1/approvals/5001'; + when( + () => apiClient.patch>( + path, + data: any(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'data': { + 'id': 5001, + 'approval_no': 'APP-2025-0001', + 'approval_status': {'id': 2, 'status_name': '진행중'}, + }, + }, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + final input = ApprovalUpdateInput( + id: 5001, + approvalStatusId: 2, + note: '보류', + ); + + await repository.update(input); + + final captured = verify( + () => apiClient.patch>( + captureAny(), + data: captureAny(named: 'data'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final payload = captured[1] as Map; + expect(payload['id'], 5001); + expect(payload['approval_status_id'], 2); + expect(payload['note'], '보류'); + }); + Map buildStep({ required int id, required int order, diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart index b411de2..83d0ad6 100644 --- a/test/features/approvals/presentation/controllers/approval_controller_test.dart +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -15,7 +15,10 @@ import 'package:superport_v2/features/inventory/lookups/domain/repositories/inve class _MockApprovalRepository extends Mock implements ApprovalRepository {} /// Approval 생성 요청을 대체하기 위한 가짜 입력. -class _FakeApprovalInput extends Fake implements ApprovalInput {} +class _FakeApprovalCreateInput extends Fake implements ApprovalCreateInput {} + +/// Approval 수정 요청을 대체하기 위한 가짜 입력. +class _FakeApprovalUpdateInput extends Fake implements ApprovalUpdateInput {} /// 단계 행위 요청을 대체하기 위한 가짜 입력. class _FakeStepActionInput extends Fake implements ApprovalStepActionInput {} @@ -68,7 +71,8 @@ void main() { } setUpAll(() { - registerFallbackValue(_FakeApprovalInput()); + registerFallbackValue(_FakeApprovalCreateInput()); + registerFallbackValue(_FakeApprovalUpdateInput()); registerFallbackValue(_FakeStepActionInput()); registerFallbackValue(_FakeStepAssignmentInput()); }); diff --git a/test/features/approvals/presentation/pages/approval_page_test.dart b/test/features/approvals/presentation/pages/approval_page_test.dart index 18c6ef6..570a72f 100644 --- a/test/features/approvals/presentation/pages/approval_page_test.dart +++ b/test/features/approvals/presentation/pages/approval_page_test.dart @@ -18,7 +18,9 @@ import 'package:superport_v2/features/inventory/lookups/domain/repositories/inve class _MockApprovalRepository extends Mock implements ApprovalRepository {} -class _FakeApprovalInput extends Fake implements ApprovalInput {} +class _FakeApprovalCreateInput extends Fake implements ApprovalCreateInput {} + +class _FakeApprovalUpdateInput extends Fake implements ApprovalUpdateInput {} class _MockApprovalTemplateRepository extends Mock implements ApprovalTemplateRepository {} @@ -45,7 +47,8 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() { - registerFallbackValue(_FakeApprovalInput()); + registerFallbackValue(_FakeApprovalCreateInput()); + registerFallbackValue(_FakeApprovalUpdateInput()); }); tearDown(() async { diff --git a/test/features/login/presentation/pages/login_page_test.dart b/test/features/login/presentation/pages/login_page_test.dart index 4eeddad..2bd9912 100644 --- a/test/features/login/presentation/pages/login_page_test.dart +++ b/test/features/login/presentation/pages/login_page_test.dart @@ -6,10 +6,16 @@ import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/services/token_storage.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/permissions/permission_resources.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; +import 'package:superport_v2/features/auth/domain/entities/auth_session.dart'; +import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; +import 'package:superport_v2/features/auth/domain/entities/login_request.dart'; +import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart'; import 'package:superport_v2/features/login/presentation/pages/login_page.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; @@ -21,9 +27,69 @@ class _MockGroupRepository extends Mock implements GroupRepository {} class _MockGroupPermissionRepository extends Mock implements GroupPermissionRepository {} +class _MockAuthRepository extends Mock implements AuthRepository {} + +class _FakeTokenStorage implements TokenStorage { + String? _accessToken; + String? _refreshToken; + + @override + Future clear() async { + _accessToken = null; + _refreshToken = null; + } + + @override + Future readAccessToken() async => _accessToken; + + @override + Future readRefreshToken() async => _refreshToken; + + @override + Future writeAccessToken(String? token) async { + _accessToken = token; + } + + @override + Future writeRefreshToken(String? token) async { + _refreshToken = token; + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + registerFallbackValue( + LoginRequest(identifier: '', password: '', rememberMe: false), + ); + }); + + late _MockAuthRepository authRepository; + late _FakeTokenStorage tokenStorage; + late AuthService authService; + + setUp(() { + authRepository = _MockAuthRepository(); + tokenStorage = _FakeTokenStorage(); + authService = AuthService( + repository: authRepository, + tokenStorage: tokenStorage, + ); + final sampleSession = AuthSession( + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + user: const AuthenticatedUser(id: 1, name: '테스터'), + permissions: const [], + ); + + GetIt.I.registerSingleton(authService); + + when(() => authRepository.login(any())).thenAnswer((_) async => sampleSession); + when(() => authRepository.refresh(any())).thenThrow(UnimplementedError()); + }); + tearDown(() async { await GetIt.I.reset(); }); diff --git a/test/features/reporting/data/reporting_repository_remote_test.dart b/test/features/reporting/data/reporting_repository_remote_test.dart index 049ad2b..6874218 100644 --- a/test/features/reporting/data/reporting_repository_remote_test.dart +++ b/test/features/reporting/data/reporting_repository_remote_test.dart @@ -98,8 +98,8 @@ void main() { expect(captured.first, equals(path)); final query = captured[1] as Map; - expect(query['date_from'], '2024-01-01'); - expect(query['date_to'], '2024-01-31'); + expect(query['from'], '2024-01-01'); + expect(query['to'], '2024-01-31'); expect(query['format'], 'xlsx'); expect(query['transaction_status_id'], 3); expect(query['approval_status_id'], 7); @@ -143,10 +143,69 @@ void main() { final result = await repository.exportApprovals(request); + final captured = verify( + () => apiClient.get( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['from'], DateTime(2024, 2, 1).toIso8601String()); + expect(query['to'], DateTime(2024, 2, 15).toIso8601String()); + expect(query['format'], 'pdf'); + expect(query.containsKey('transaction_status_id'), isFalse); expect(result.hasBytes, isTrue); expect(result.bytes, isNotNull); expect(result.filename, 'approval.pdf'); expect(result.mimeType, 'application/pdf'); expect(result.hasDownloadUrl, isFalse); }); + + test('exportApprovals는 transaction_status_id 파라미터를 전달한다', () async { + const path = '/api/v1/reports/approvals/export'; + when( + () => apiClient.get( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => binaryResponse( + path, + bytes: [4, 5, 6], + filename: 'approval.xlsx', + mimeType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ), + ); + + final request = ReportExportRequest( + from: DateTime(2024, 4, 1), + to: DateTime(2024, 4, 30), + format: ReportExportFormat.xlsx, + transactionStatusId: 2, + approvalStatusId: 4, + ); + + await repository.exportApprovals(request); + + final captured = verify( + () => apiClient.get( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['transaction_status_id'], 2); + expect(query['approval_status_id'], 4); + }); } diff --git a/test/navigation/navigation_flow_test.dart b/test/navigation/navigation_flow_test.dart index c3e0154..bc18af8 100644 --- a/test/navigation/navigation_flow_test.dart +++ b/test/navigation/navigation_flow_test.dart @@ -2,12 +2,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/config/environment.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/theme/superport_shad_theme.dart'; +import 'package:superport_v2/core/services/token_storage.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; +import 'package:superport_v2/features/auth/domain/entities/auth_session.dart'; +import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; +import 'package:superport_v2/features/auth/domain/entities/login_request.dart'; +import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart'; import 'package:superport_v2/features/login/presentation/pages/login_page.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; @@ -222,9 +229,74 @@ class _StubGroupPermissionRepository implements GroupPermissionRepository { } } +class _MockAuthRepository extends Mock implements AuthRepository {} + +class _FakeTokenStorage implements TokenStorage { + String? _accessToken; + String? _refreshToken; + + @override + Future clear() async { + _accessToken = null; + _refreshToken = null; + } + + @override + Future readAccessToken() async => _accessToken; + + @override + Future readRefreshToken() async => _refreshToken; + + @override + Future writeAccessToken(String? token) async { + _accessToken = token; + } + + @override + Future writeRefreshToken(String? token) async { + _refreshToken = token; + } +} + +AuthSession _buildSampleSession() { + return const AuthSession( + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: null, + user: AuthenticatedUser(id: 1, name: '테스터'), + permissions: [], + ); +} + +void _registerAuthService( + _MockAuthRepository repository, + _FakeTokenStorage storage, +) { + final service = AuthService(repository: repository, tokenStorage: storage); + when(() => repository.login(any())).thenAnswer((_) async => _buildSampleSession()); + when(() => repository.refresh(any())).thenThrow(UnimplementedError()); + GetIt.I.registerSingleton(service); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + registerFallbackValue( + LoginRequest(identifier: '', password: '', rememberMe: false), + ); + }); + + late _MockAuthRepository authRepository; + late _FakeTokenStorage tokenStorage; + + setUp(() async { + await GetIt.I.reset(); + authRepository = _MockAuthRepository(); + tokenStorage = _FakeTokenStorage(); + _registerAuthService(authRepository, tokenStorage); + }); + setUpAll(() async { await Environment.initialize(); }); @@ -240,12 +312,10 @@ void main() { view.resetDevicePixelRatio(); }); - await GetIt.I.reset(); GetIt.I.registerSingleton(_StubGroupRepository()); GetIt.I.registerSingleton( _StubGroupPermissionRepository(), ); - addTearDown(() async => GetIt.I.reset()); final router = _createTestRouter(); await tester.pumpWidget(_TestApp(router: router));