From 8067416c09a46682fcf4c38abdd58d9f97b41bd7 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 14 Oct 2025 18:10:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=B0=EC=9E=AC=C2=B7=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=8B=A4=EC=97=B0=EB=8F=99=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dtos/approval_proceed_status_dto.dart | 28 +++ .../approval_repository_remote.dart | 22 ++- .../approval_template_repository_remote.dart | 3 +- .../entities/approval_proceed_status.dart | 14 ++ .../repositories/approval_repository.dart | 4 + .../approval_history_repository_remote.dart | 3 +- .../approval_history_controller.dart | 6 +- .../controllers/approval_controller.dart | 173 +++++++++++++++--- .../presentation/pages/approval_page.dart | 58 ++++-- .../approval_step_repository_remote.dart | 3 +- .../controllers/approval_step_controller.dart | 31 ++-- .../pages/approval_step_page.dart | 3 +- .../approval_template_controller.dart | 31 ++-- .../customer_repository_remote.dart | 14 +- .../repositories/customer_repository.dart | 3 + .../controllers/customer_controller.dart | 26 ++- .../masters/group/data/dtos/group_dto.dart | 4 +- .../repositories/group_repository_remote.dart | 13 +- .../domain/repositories/group_repository.dart | 2 + .../controllers/group_controller.dart | 69 +++++-- .../application/permission_synchronizer.dart | 51 ++++++ .../data/dtos/group_permission_dto.dart | 25 ++- .../group_permission_repository_remote.dart | 3 +- .../domain/entities/group_permission.dart | 9 +- .../mappers/group_permission_mapper.dart | 34 ++++ .../group_permission_controller.dart | 79 ++++++-- .../pages/group_permission_page.dart | 14 ++ .../repositories/menu_repository_remote.dart | 3 +- .../controllers/menu_controller.dart | 31 ++-- .../product_repository_remote.dart | 3 +- .../controllers/product_controller.dart | 31 ++-- .../repositories/uom_repository_remote.dart | 3 +- .../repositories/user_repository_remote.dart | 3 +- .../controllers/user_controller.dart | 82 +++++++-- .../user/presentation/pages/user_page.dart | 16 ++ .../vendor_repository_remote.dart | 3 +- .../controllers/vendor_controller.dart | 26 ++- .../warehouse_repository_remote.dart | 5 +- .../repositories/warehouse_repository.dart | 1 + .../controllers/warehouse_controller.dart | 26 ++- .../postal_search_repository_remote.dart | 11 +- .../postal_search_repository.dart | 9 +- .../widgets/postal_search_dialog.dart | 4 +- .../approval_page_permission_test.dart | 108 ++++++++++- .../controllers/approval_controller_test.dart | 33 ++++ .../pages/approval_page_test.dart | 92 +++++++++- .../pages/approval_step_page_test.dart | 5 +- .../data/customer_repository_remote_test.dart | 119 ++++++++++++ .../data/group_repository_remote_test.dart | 65 +++++++ .../controllers/group_controller_test.dart | 65 +++++++ .../permission_synchronizer_test.dart | 115 ++++++++++++ .../domain/group_permission_mapper_test.dart | 71 +++++++ .../group_permission_controller_test.dart | 23 ++- .../pages/group_permission_page_test.dart | 34 +++- .../data/product_repository_remote_test.dart | 86 +++++++++ .../presentation/pages/product_page_test.dart | 27 +++ .../data/user_repository_remote_test.dart | 65 +++++++ .../controllers/user_controller_test.dart | 64 +++++++ .../presentation/pages/user_page_test.dart | 59 +++++- .../presentation/pages/vendor_page_test.dart | 29 +++ .../warehouse_repository_remote_test.dart | 60 ++++++ .../warehouse_controller_test.dart | 12 +- .../pages/warehouse_page_test.dart | 11 +- .../postal_search_repository_remote_test.dart | 77 ++++++++ .../widgets/postal_search_dialog_test.dart | 30 +++ test/navigation/navigation_flow_test.dart | 119 +++++++++++- 66 files changed, 2129 insertions(+), 222 deletions(-) create mode 100644 lib/features/approvals/data/dtos/approval_proceed_status_dto.dart create mode 100644 lib/features/approvals/domain/entities/approval_proceed_status.dart create mode 100644 lib/features/masters/group_permission/application/permission_synchronizer.dart create mode 100644 lib/features/masters/group_permission/domain/mappers/group_permission_mapper.dart create mode 100644 test/features/masters/customer/data/customer_repository_remote_test.dart create mode 100644 test/features/masters/group/data/group_repository_remote_test.dart create mode 100644 test/features/masters/group_permission/application/permission_synchronizer_test.dart create mode 100644 test/features/masters/group_permission/domain/group_permission_mapper_test.dart create mode 100644 test/features/masters/product/data/product_repository_remote_test.dart create mode 100644 test/features/masters/user/data/user_repository_remote_test.dart create mode 100644 test/features/masters/warehouse/data/warehouse_repository_remote_test.dart create mode 100644 test/features/util/postal_search/data/postal_search_repository_remote_test.dart diff --git a/lib/features/approvals/data/dtos/approval_proceed_status_dto.dart b/lib/features/approvals/data/dtos/approval_proceed_status_dto.dart new file mode 100644 index 0000000..273fa2a --- /dev/null +++ b/lib/features/approvals/data/dtos/approval_proceed_status_dto.dart @@ -0,0 +1,28 @@ +import '../../domain/entities/approval_proceed_status.dart'; + +/// 결재 진행 가능 여부(can-proceed) 응답 DTO. +class ApprovalProceedStatusDto { + ApprovalProceedStatusDto({ + required this.approvalId, + required this.canProceed, + this.reason, + }); + + final int approvalId; + final bool canProceed; + final String? reason; + + factory ApprovalProceedStatusDto.fromJson(Map json) { + return ApprovalProceedStatusDto( + approvalId: json['id'] as int? ?? json['approval_id'] as int? ?? 0, + canProceed: json['can_proceed'] as bool? ?? false, + reason: json['reason'] as String?, + ); + } + + ApprovalProceedStatus toEntity() => ApprovalProceedStatus( + approvalId: approvalId, + canProceed: canProceed, + reason: reason, + ); +} diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart index 8148fe2..92a2b64 100644 --- a/lib/features/approvals/data/repositories/approval_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -1,10 +1,13 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/approval.dart'; +import '../../domain/entities/approval_proceed_status.dart'; import '../../domain/repositories/approval_repository.dart'; import '../dtos/approval_dto.dart'; +import '../dtos/approval_proceed_status_dto.dart'; /// 결재 API 엔드포인트를 호출하는 원격 저장소 구현체. /// @@ -15,7 +18,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { final ApiClient _api; - static const _basePath = '/approvals'; + static const _basePath = '${ApiRoutes.apiV1}/approvals'; /// 결재 목록을 조회한다. 필터 조건이 없으면 최신순 페이지를 반환한다. @override @@ -69,7 +72,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { @override Future> listActions({bool activeOnly = true}) async { final response = await _api.get>( - '/approval-actions', + '${ApiRoutes.apiV1}/approval-actions', query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true}, options: Options(responseType: ResponseType.json), ); @@ -85,7 +88,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { @override Future performStepAction(ApprovalStepActionInput input) async { final response = await _api.post>( - '/approval-steps/${input.stepId}/actions', + '${ApiRoutes.apiV1}/approval-steps/${input.stepId}/actions', data: input.toPayload(), options: Options(responseType: ResponseType.json), ); @@ -102,7 +105,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository { @override Future assignSteps(ApprovalStepAssignmentInput input) async { final response = await _api.post>( - '/approvals/${input.approvalId}/steps', + '${ApiRoutes.apiV1}/approvals/${input.approvalId}/steps', data: input.toPayload(), options: Options(responseType: ResponseType.json), ); @@ -115,6 +118,17 @@ class ApprovalRepositoryRemote implements ApprovalRepository { return ApprovalDto.fromJson(approvalJson).toEntity(); } + /// 결재가 다음 단계로 진행 가능한지 확인한다. + @override + Future canProceed(int id) async { + final response = await _api.get>( + '$_basePath/$id/can-proceed', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ApprovalProceedStatusDto.fromJson(data).toEntity(); + } + /// 새로운 결재를 생성한다. @override Future create(ApprovalInput input) async { diff --git a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart index 9dbee3d..91ff1c5 100644 --- a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import '../../../../core/common/models/paginated_result.dart'; import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_routes.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_template_repository.dart'; import '../dtos/approval_template_dto.dart'; @@ -16,7 +17,7 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { final ApiClient _api; - static const _basePath = '/approval-templates'; + static const _basePath = '${ApiRoutes.apiV1}/approval-templates'; /// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다. @override diff --git a/lib/features/approvals/domain/entities/approval_proceed_status.dart b/lib/features/approvals/domain/entities/approval_proceed_status.dart new file mode 100644 index 0000000..1d3cfe9 --- /dev/null +++ b/lib/features/approvals/domain/entities/approval_proceed_status.dart @@ -0,0 +1,14 @@ +/// 결재 진행 가능 여부(can-proceed) 응답 엔티티. +/// +/// - 백엔드 `GET /approvals/{id}/can-proceed` 결과를 표현한다. +class ApprovalProceedStatus { + const ApprovalProceedStatus({ + required this.approvalId, + required this.canProceed, + this.reason, + }); + + final int approvalId; + final bool canProceed; + final String? reason; +} diff --git a/lib/features/approvals/domain/repositories/approval_repository.dart b/lib/features/approvals/domain/repositories/approval_repository.dart index 884bab8..9883f07 100644 --- a/lib/features/approvals/domain/repositories/approval_repository.dart +++ b/lib/features/approvals/domain/repositories/approval_repository.dart @@ -1,6 +1,7 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; import '../entities/approval.dart'; +import '../entities/approval_proceed_status.dart'; /// 결재 도메인에서 사용하는 저장소 인터페이스. /// @@ -34,6 +35,9 @@ abstract class ApprovalRepository { /// 결재 단계 일괄 생성/재배치 Future assignSteps(ApprovalStepAssignmentInput input); + /// 결재가 다음 단계로 진행 가능한지 여부를 확인한다. + Future canProceed(int id); + /// 결재를 생성한다. Future create(ApprovalInput input); diff --git a/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart index 11e83e9..10a01fa 100644 --- a/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart +++ b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/approval_history_record.dart'; import '../../domain/repositories/approval_history_repository.dart'; @@ -13,7 +14,7 @@ class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository { final ApiClient _api; - static const _basePath = '/approval-histories'; + static const _basePath = '${ApiRoutes.apiV1}/approval-histories'; /// 결재 이력 목록을 조회한다. @override diff --git a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart index 7bf9c62..ad3cd94 100644 --- a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart +++ b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../domain/entities/approval_history_record.dart'; import '../../domain/repositories/approval_history_repository.dart'; @@ -59,8 +60,9 @@ class ApprovalHistoryController extends ChangeNotifier { ); _result = response; _pageSize = response.pageSize; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index b86989f..077613f 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -1,7 +1,11 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; +import '../../../inventory/lookups/domain/entities/lookup_item.dart'; +import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../domain/entities/approval.dart'; +import '../../domain/entities/approval_proceed_status.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; @@ -23,6 +27,14 @@ const Map> _actionAliases = { ApprovalStepActionType.comment: ['comment', '코멘트', '의견'], }; +const Map _defaultStatusCodes = { + ApprovalStatusFilter.pending: 'pending', + ApprovalStatusFilter.inProgress: 'in_progress', + ApprovalStatusFilter.onHold: 'on_hold', + ApprovalStatusFilter.approved: 'approved', + ApprovalStatusFilter.rejected: 'rejected', +}; + /// 결재 목록 및 상세 화면 상태 컨트롤러 /// /// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다. @@ -31,11 +43,14 @@ class ApprovalController extends ChangeNotifier { ApprovalController({ required ApprovalRepository approvalRepository, required ApprovalTemplateRepository templateRepository, + InventoryLookupRepository? lookupRepository, }) : _repository = approvalRepository, - _templateRepository = templateRepository; + _templateRepository = templateRepository, + _lookupRepository = lookupRepository; final ApprovalRepository _repository; final ApprovalTemplateRepository _templateRepository; + final InventoryLookupRepository? _lookupRepository; PaginatedResult? _result; Approval? _selected; @@ -47,6 +62,7 @@ class ApprovalController extends ChangeNotifier { bool _isLoadingTemplates = false; bool _isApplyingTemplate = false; int? _applyingTemplateId; + ApprovalProceedStatus? _proceedStatus; String? _errorMessage; String _query = ''; ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; @@ -54,6 +70,12 @@ class ApprovalController extends ChangeNotifier { DateTime? _toDate; List _actions = const []; List _templates = const []; + final Map _statusLookup = {}; + final Map _statusCodeAliases = Map.fromEntries( + _defaultStatusCodes.entries.map( + (entry) => MapEntry(entry.value, entry.value), + ), + ); PaginatedResult? get result => _result; Approval? get selected => _selected; @@ -73,6 +95,17 @@ class ApprovalController extends ChangeNotifier { bool get isLoadingTemplates => _isLoadingTemplates; bool get isApplyingTemplate => _isApplyingTemplate; int? get applyingTemplateId => _applyingTemplateId; + ApprovalProceedStatus? get proceedStatus => _proceedStatus; + bool get canProceedSelected => _proceedStatus?.canProceed ?? true; + String? get cannotProceedReason { + final reason = _proceedStatus?.reason?.trim(); + if (reason == null || reason.isEmpty) { + return null; + } + return reason; + } + + Map get statusLookup => _statusLookup; /// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다. /// @@ -83,14 +116,7 @@ class ApprovalController extends ChangeNotifier { _errorMessage = null; notifyListeners(); try { - final statusParam = switch (_statusFilter) { - ApprovalStatusFilter.all => null, - ApprovalStatusFilter.pending => 'pending', - ApprovalStatusFilter.inProgress => 'in_progress', - ApprovalStatusFilter.onHold => 'on_hold', - ApprovalStatusFilter.approved => 'approved', - ApprovalStatusFilter.rejected => 'rejected', - }; + final statusParam = _statusCodeFor(_statusFilter); final response = await _repository.list( page: page, pageSize: _result?.pageSize ?? 20, @@ -106,10 +132,12 @@ class ApprovalController extends ChangeNotifier { final exists = response.items.any((item) => item.id == _selected?.id); if (!exists) { _selected = null; + _proceedStatus = null; } } - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingList = false; notifyListeners(); @@ -129,14 +157,78 @@ class ApprovalController extends ChangeNotifier { try { final items = await _repository.listActions(activeOnly: true); _actions = items; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingActions = false; notifyListeners(); } } + Future loadStatusLookups() async { + final repository = _lookupRepository; + if (repository == null) { + return; + } + try { + final items = await repository.fetchApprovalStatuses(); + _statusLookup + ..clear() + ..addEntries( + items.map( + (item) => MapEntry( + (item.code ?? item.name).toLowerCase(), + item, + ), + ), + ); + for (final entry in _defaultStatusCodes.entries) { + final code = entry.value.toLowerCase(); + final lookup = _statusLookup[code]; + if (lookup != null) { + _statusCodeAliases[entry.value] = lookup.code?.toLowerCase() ?? code; + } + } + notifyListeners(); + } catch (_) { + // 실패 시 기본 라벨 사용 + } + } + + String statusLabel(ApprovalStatusFilter filter) { + if (filter == ApprovalStatusFilter.all) { + return '전체 상태'; + } + final code = _statusCodeFor(filter); + if (code != null) { + final normalized = code.toLowerCase(); + final lookup = _statusLookup[normalized]; + if (lookup != null && lookup.name.isNotEmpty) { + return lookup.name; + } + } + return switch (filter) { + ApprovalStatusFilter.pending => '승인대기', + ApprovalStatusFilter.inProgress => '진행중', + ApprovalStatusFilter.onHold => '보류', + ApprovalStatusFilter.approved => '승인완료', + ApprovalStatusFilter.rejected => '반려', + ApprovalStatusFilter.all => '전체 상태', + }; + } + + String? _statusCodeFor(ApprovalStatusFilter filter) { + if (filter == ApprovalStatusFilter.all) { + return null; + } + final defaultCode = _defaultStatusCodes[filter]; + if (defaultCode == null) { + return null; + } + return _statusCodeAliases[defaultCode] ?? defaultCode; + } + /// 활성화된 결재 템플릿 목록을 조회해 캐싱한다. /// /// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다. @@ -154,8 +246,9 @@ class ApprovalController extends ChangeNotifier { isActive: true, ); _templates = result.items; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingTemplates = false; notifyListeners(); @@ -169,6 +262,7 @@ class ApprovalController extends ChangeNotifier { Future selectApproval(int id) async { _isLoadingDetail = true; _errorMessage = null; + _proceedStatus = null; notifyListeners(); try { final detail = await _repository.fetchDetail( @@ -177,8 +271,12 @@ class ApprovalController extends ChangeNotifier { includeHistories: true, ); _selected = detail; - } catch (e) { - _errorMessage = e.toString(); + if (detail.id != null) { + await _loadProceedStatus(detail.id!); + } + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingDetail = false; notifyListeners(); @@ -188,6 +286,7 @@ class ApprovalController extends ChangeNotifier { /// 선택된 결재 상세를 비우고 화면을 초기화한다. void clearSelection() { _selected = null; + _proceedStatus = null; notifyListeners(); } @@ -200,6 +299,12 @@ class ApprovalController extends ChangeNotifier { required ApprovalStepActionType type, String? note, }) async { + final approvalId = _selected?.id; + if (approvalId == null) { + _errorMessage = '선택한 결재 정보가 없어 단계를 처리할 수 없습니다.'; + notifyListeners(); + return false; + } if (step.id == null) { _errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.'; notifyListeners(); @@ -217,6 +322,14 @@ class ApprovalController extends ChangeNotifier { _errorMessage = null; notifyListeners(); try { + final proceedStatus = await _repository.canProceed(approvalId); + _proceedStatus = proceedStatus; + if (!proceedStatus.canProceed) { + _errorMessage = proceedStatus.reason ?? + '결재 단계가 현재 상태에서 진행될 수 없습니다.'; + return false; + } + final sanitizedNote = note?.trim(); final updated = await _repository.performStepAction( ApprovalStepActionInput( @@ -232,9 +345,15 @@ class ApprovalController extends ChangeNotifier { .toList(); _result = _result!.copyWith(items: items); } + if (updated.id != null) { + await _loadProceedStatus(updated.id!); + } else { + await _loadProceedStatus(approvalId); + } return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); return false; } finally { _isPerformingAction = false; @@ -291,8 +410,9 @@ class ApprovalController extends ChangeNotifier { _result = _result!.copyWith(items: items); } return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); return false; } finally { _isApplyingTemplate = false; @@ -348,4 +468,15 @@ class ApprovalController extends ChangeNotifier { } return null; } + + Future _loadProceedStatus(int approvalId) async { + try { + final status = await _repository.canProceed(approvalId); + _proceedStatus = status; + } catch (error) { + _proceedStatus = null; + final failure = Failure.from(error); + _errorMessage ??= failure.describe(); + } + } } diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index d6d4933..4d24ba5 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../core/config/environment.dart'; import '../../../../core/constants/app_sections.dart'; import '../../../../core/permissions/permission_manager.dart'; +import '../../../../core/permissions/permission_resources.dart'; import '../../../../widgets/app_layout.dart'; import '../../../../widgets/components/feedback.dart'; import '../../../../widgets/components/filter_bar.dart'; @@ -18,9 +19,10 @@ import '../../domain/entities/approval.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; +import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../controllers/approval_controller.dart'; -const _approvalsResourcePath = '/approvals/requests'; +const _approvalsResourcePath = PermissionResources.approvals; /// 결재 관리 최상위 페이지. /// @@ -85,11 +87,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { _controller = ApprovalController( approvalRepository: GetIt.I(), templateRepository: GetIt.I(), + lookupRepository: GetIt.I.isRegistered() + ? GetIt.I() + : null, )..addListener(_handleControllerUpdate); WidgetsBinding.instance.addPostFrameCallback((_) async { await Future.wait([ _controller.loadActionOptions(), _controller.loadTemplates(), + _controller.loadStatusLookups(), ]); await _controller.fetch(); }); @@ -335,6 +341,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { selectedTemplateId: _selectedTemplateId, canPerformStepActions: canPerformStepActions, canApplyTemplate: canManageTemplates, + canProceed: _controller.canProceedSelected, + cannotProceedReason: _controller.cannotProceedReason, dateFormat: _dateTimeFormat, onRefresh: () { final id = selectedApproval?.id; @@ -660,22 +668,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { return confirmed ?? false; } - String _statusLabel(ApprovalStatusFilter filter) { - switch (filter) { - case ApprovalStatusFilter.all: - return '전체 상태'; - case ApprovalStatusFilter.pending: - return '대기'; - case ApprovalStatusFilter.inProgress: - return '진행중'; - case ApprovalStatusFilter.onHold: - return '보류'; - case ApprovalStatusFilter.approved: - return '승인'; - case ApprovalStatusFilter.rejected: - return '반려'; - } - } + String _statusLabel(ApprovalStatusFilter filter) => + _controller.statusLabel(filter); String _dialogTitle(ApprovalStepActionType type) { switch (type) { @@ -827,6 +821,8 @@ class _DetailSection extends StatelessWidget { required this.selectedTemplateId, required this.canPerformStepActions, required this.canApplyTemplate, + required this.canProceed, + required this.cannotProceedReason, required this.dateFormat, required this.onRefresh, required this.onClose, @@ -849,6 +845,8 @@ class _DetailSection extends StatelessWidget { final int? selectedTemplateId; final bool canPerformStepActions; final bool canApplyTemplate; + final bool canProceed; + final String? cannotProceedReason; final intl.DateFormat dateFormat; final VoidCallback onRefresh; final VoidCallback? onClose; @@ -929,6 +927,8 @@ class _DetailSection extends StatelessWidget { selectedTemplateId: selectedTemplateId, canPerformStepActions: canPerformStepActions, canApplyTemplate: canApplyTemplate, + canProceed: canProceed, + cannotProceedReason: cannotProceedReason, onSelectTemplate: onSelectTemplate, onApplyTemplate: onApplyTemplate, onReloadTemplates: onReloadTemplates, @@ -1028,6 +1028,8 @@ class _StepTab extends StatelessWidget { required this.selectedTemplateId, required this.canPerformStepActions, required this.canApplyTemplate, + required this.canProceed, + required this.cannotProceedReason, required this.onSelectTemplate, required this.onApplyTemplate, required this.onReloadTemplates, @@ -1048,6 +1050,8 @@ class _StepTab extends StatelessWidget { final int? selectedTemplateId; final bool canPerformStepActions; final bool canApplyTemplate; + final bool canProceed; + final String? cannotProceedReason; final void Function(int?) onSelectTemplate; final void Function(int templateId) onApplyTemplate; final VoidCallback onReloadTemplates; @@ -1097,6 +1101,14 @@ class _StepTab extends StatelessWidget { style: theme.textTheme.muted, ), ), + if (!canProceed) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Text( + cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.', + style: theme.textTheme.muted, + ), + ), if (steps.isEmpty) Expanded( child: Center( @@ -1112,6 +1124,8 @@ class _StepTab extends StatelessWidget { final disabledReason = _disabledReason( step, canPerformStepActions, + canProceed, + cannotProceedReason, ); final isProcessingStep = isPerformingAction && processingStepId == step.id; @@ -1284,7 +1298,12 @@ class _StepTab extends StatelessWidget { return button; } - String? _disabledReason(ApprovalStep step, bool canPerformStepActions) { + String? _disabledReason( + ApprovalStep step, + bool canPerformStepActions, + bool canProceed, + String? cannotProceedReason, + ) { if (!canPerformStepActions) { return '결재 행위를 수행할 권한이 없습니다.'; } @@ -1294,6 +1313,9 @@ class _StepTab extends StatelessWidget { if (!hasActionOptions) { return '사용 가능한 결재 행위가 없습니다.'; } + if (!canProceed) { + return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.'; + } if (isPerformingAction && processingStepId != step.id) { return '다른 결재 단계를 처리 중입니다.'; } diff --git a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart index 09f51d3..57f1286 100644 --- a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart +++ b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart'; import 'package:superport_v2/features/approvals/step/domain/repositories/approval_step_repository.dart'; import '../dtos/approval_step_record_dto.dart'; @@ -14,7 +15,7 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { final ApiClient _api; - static const _basePath = '/approval-steps'; + static const _basePath = '${ApiRoutes.apiV1}/approval-steps'; /// 결재 단계 목록을 조회한다. @override diff --git a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart index cb4c4f7..df3034a 100644 --- a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart +++ b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../domain/entities/approval_step_input.dart'; import '../../domain/entities/approval_step_record.dart'; @@ -49,8 +50,9 @@ class ApprovalStepController extends ChangeNotifier { approverId: _approverId, ); _result = response; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -86,8 +88,9 @@ class ApprovalStepController extends ChangeNotifier { final detail = await _repository.fetchDetail(id); _selected = detail; return detail; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); return null; } finally { _isLoadingDetail = false; @@ -128,8 +131,9 @@ class ApprovalStepController extends ChangeNotifier { final nextPage = _result?.page ?? 1; await fetch(page: nextPage); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); return null; } finally { _isSaving = false; @@ -150,8 +154,9 @@ class ApprovalStepController extends ChangeNotifier { final nextPage = _result?.page ?? 1; await fetch(page: nextPage); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); return null; } finally { _isSaving = false; @@ -181,8 +186,9 @@ class ApprovalStepController extends ChangeNotifier { _result = _result!.copyWith(items: items); } return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); return false; } finally { _isSaving = false; @@ -210,8 +216,9 @@ class ApprovalStepController extends ChangeNotifier { _result = _result!.copyWith(items: items); } return record; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); return null; } finally { _isSaving = false; diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 627df11..4d59d24 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; import '../../../../../core/permissions/permission_manager.dart'; +import '../../../../../core/permissions/permission_resources.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_dialog.dart'; @@ -15,7 +16,7 @@ import '../../domain/entities/approval_step_input.dart'; import '../../domain/entities/approval_step_record.dart'; import '../../domain/repositories/approval_step_repository.dart'; -const String _stepResourcePath = '/approvals/steps'; +const String _stepResourcePath = PermissionResources.approvalSteps; /// 결재 단계 관리 진입 페이지. 기능 플래그에 따라 실제 화면 또는 준비중 화면을 노출한다. class ApprovalStepPage extends StatelessWidget { diff --git a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart index ddc8cb5..f0d9012 100644 --- a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart +++ b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../../domain/entities/approval_template.dart'; import '../../../domain/repositories/approval_template_repository.dart'; @@ -54,8 +55,9 @@ class ApprovalTemplateController extends ChangeNotifier { ); _result = response; _pageSize = response.pageSize; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -83,8 +85,9 @@ class ApprovalTemplateController extends ChangeNotifier { try { final detail = await _repository.fetchDetail(id, includeSteps: true); return detail; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } @@ -100,8 +103,9 @@ class ApprovalTemplateController extends ChangeNotifier { final created = await _repository.create(input, steps: steps); await fetch(page: 1); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -120,8 +124,9 @@ class ApprovalTemplateController extends ChangeNotifier { final updated = await _repository.update(id, input, steps: steps); await fetch(page: _result?.page ?? 1); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -136,8 +141,9 @@ class ApprovalTemplateController extends ChangeNotifier { await _repository.delete(id); await fetch(page: _result?.page ?? 1); return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -152,8 +158,9 @@ class ApprovalTemplateController extends ChangeNotifier { final restored = await _repository.restore(id); await fetch(page: _result?.page ?? 1); return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { 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 40a0794..6d53a2c 100644 --- a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart +++ b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/customer.dart'; import '../../domain/repositories/customer_repository.dart'; @@ -12,7 +13,7 @@ class CustomerRepositoryRemote implements CustomerRepository { final ApiClient _api; - static const _basePath = '/customers'; + static const _basePath = '${ApiRoutes.apiV1}/customers'; /// 고객 목록을 조회한다. @override @@ -39,6 +40,17 @@ class CustomerRepositoryRemote implements CustomerRepository { return CustomerDto.parsePaginated(response.data ?? const {}); } + @override + Future fetchDetail(int id, {bool includeZipcode = true}) async { + final response = await _api.get>( + '$_basePath/$id', + query: {if (includeZipcode) 'include': 'zipcode'}, + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return CustomerDto.fromJson(data).toEntity(); + } + /// 고객을 생성한다. @override Future create(CustomerInput input) async { diff --git a/lib/features/masters/customer/domain/repositories/customer_repository.dart b/lib/features/masters/customer/domain/repositories/customer_repository.dart index 6cb6a54..0b6a6a9 100644 --- a/lib/features/masters/customer/domain/repositories/customer_repository.dart +++ b/lib/features/masters/customer/domain/repositories/customer_repository.dart @@ -14,6 +14,9 @@ abstract class CustomerRepository { bool? isActive, }); + /// 고객 단건 상세를 조회한다. + Future fetchDetail(int id, {bool includeZipcode = true}); + /// 고객을 생성한다. Future create(CustomerInput input); diff --git a/lib/features/masters/customer/presentation/controllers/customer_controller.dart b/lib/features/masters/customer/presentation/controllers/customer_controller.dart index 3fecc94..a783416 100644 --- a/lib/features/masters/customer/presentation/controllers/customer_controller.dart +++ b/lib/features/masters/customer/presentation/controllers/customer_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../domain/entities/customer.dart'; import '../../domain/repositories/customer_repository.dart'; @@ -78,8 +79,9 @@ class CustomerController extends ChangeNotifier { if (response.pageSize > 0 && response.pageSize != _pageSize) { _pageSize = response.pageSize; } - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -129,8 +131,9 @@ class CustomerController extends ChangeNotifier { final created = await _repository.create(input); await fetch(page: 1); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -145,8 +148,9 @@ class CustomerController extends ChangeNotifier { final updated = await _repository.update(id, input); await fetch(page: _result?.page ?? 1); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -161,8 +165,9 @@ class CustomerController extends ChangeNotifier { await _repository.delete(id); await fetch(page: _result?.page ?? 1); return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -177,8 +182,9 @@ class CustomerController extends ChangeNotifier { final restored = await _repository.restore(id); await fetch(page: _result?.page ?? 1); return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { diff --git a/lib/features/masters/group/data/dtos/group_dto.dart b/lib/features/masters/group/data/dtos/group_dto.dart index 0fb4852..acc12b4 100644 --- a/lib/features/masters/group/data/dtos/group_dto.dart +++ b/lib/features/masters/group/data/dtos/group_dto.dart @@ -32,7 +32,9 @@ class GroupDto { return GroupDto( id: json['id'] as int?, groupName: json['group_name'] as String, - description: json['description'] as String?, + description: + json['description'] as String? ?? + json['group_description'] as String?, isDefault: (json['is_default'] as bool?) ?? false, isActive: (json['is_active'] as bool?) ?? true, isDeleted: (json['is_deleted'] as bool?) ?? false, 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 73220b7..a7f5569 100644 --- a/lib/features/masters/group/data/repositories/group_repository_remote.dart +++ b/lib/features/masters/group/data/repositories/group_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/group.dart'; import '../../domain/repositories/group_repository.dart'; @@ -12,7 +13,7 @@ class GroupRepositoryRemote implements GroupRepository { final ApiClient _api; - static const _basePath = '/groups'; + static const _basePath = '${ApiRoutes.apiV1}/groups'; /// 그룹 목록을 조회한다. @override @@ -22,7 +23,16 @@ class GroupRepositoryRemote implements GroupRepository { String? query, bool? isDefault, bool? isActive, + bool includePermissions = false, + bool includeEmployees = false, }) async { + final includeParts = []; + if (includePermissions) { + includeParts.add('permissions'); + } + if (includeEmployees) { + includeParts.add('employees'); + } final response = await _api.get>( _basePath, query: { @@ -31,6 +41,7 @@ class GroupRepositoryRemote implements GroupRepository { if (query != null && query.isNotEmpty) 'q': query, if (isDefault != null) 'is_default': isDefault, if (isActive != null) 'is_active': isActive, + if (includeParts.isNotEmpty) 'include': includeParts.join(','), }, options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/masters/group/domain/repositories/group_repository.dart b/lib/features/masters/group/domain/repositories/group_repository.dart index 5898c76..30a83b3 100644 --- a/lib/features/masters/group/domain/repositories/group_repository.dart +++ b/lib/features/masters/group/domain/repositories/group_repository.dart @@ -10,6 +10,8 @@ abstract class GroupRepository { String? query, bool? isDefault, bool? isActive, + bool includePermissions = false, + bool includeEmployees = false, }); /// 그룹 신규 등록 diff --git a/lib/features/masters/group/presentation/controllers/group_controller.dart b/lib/features/masters/group/presentation/controllers/group_controller.dart index b1e9add..5c0988f 100644 --- a/lib/features/masters/group/presentation/controllers/group_controller.dart +++ b/lib/features/masters/group/presentation/controllers/group_controller.dart @@ -1,8 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; + +import 'package:superport_v2/core/permissions/permission_manager.dart'; import '../../domain/entities/group.dart'; import '../../domain/repositories/group_repository.dart'; +import '../../../group_permission/application/permission_synchronizer.dart'; +import '../../../group_permission/domain/repositories/group_permission_repository.dart'; /// 기본 그룹 여부 필터. enum GroupDefaultFilter { all, defaultOnly, nonDefault } @@ -15,10 +20,17 @@ enum GroupStatusFilter { all, activeOnly, inactiveOnly } /// - 목록 조회 및 필터, 페이징 상태를 담당한다. /// - 생성/수정/삭제/복구 요청을 래핑하여 UI와 통신한다. class GroupController extends ChangeNotifier { - GroupController({required GroupRepository repository}) - : _repository = repository; + GroupController({ + required GroupRepository repository, + GroupPermissionRepository? permissionRepository, + PermissionManager? permissionManager, + }) : _repository = repository, + _permissionRepository = permissionRepository, + _permissionManager = permissionManager; final GroupRepository _repository; + final GroupPermissionRepository? _permissionRepository; + final PermissionManager? _permissionManager; PaginatedResult? _result; bool _isLoading = false; @@ -60,8 +72,9 @@ class GroupController extends ChangeNotifier { isActive: isActive, ); _result = response; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -92,9 +105,11 @@ class GroupController extends ChangeNotifier { try { final created = await _repository.create(input); await fetch(page: 1); + await _maybeSync(created.id); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -108,9 +123,11 @@ class GroupController extends ChangeNotifier { try { final updated = await _repository.update(id, input); await fetch(page: _result?.page ?? 1); + await _maybeSync(updated.id); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -124,9 +141,11 @@ class GroupController extends ChangeNotifier { try { await _repository.delete(id); await fetch(page: _result?.page ?? 1); + await _maybeSync(id); return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -140,9 +159,11 @@ class GroupController extends ChangeNotifier { try { final restored = await _repository.restore(id); await fetch(page: _result?.page ?? 1); + await _maybeSync(restored.id); return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -161,4 +182,28 @@ class GroupController extends ChangeNotifier { _isSubmitting = value; notifyListeners(); } + + Future _maybeSync(int? groupId) async { + if (groupId == null) { + return; + } + await _syncPermissionsForGroup(groupId); + } + + Future _syncPermissionsForGroup(int groupId) async { + final repository = _permissionRepository; + final manager = _permissionManager; + if (repository == null || manager == null) { + return; + } + try { + final synchronizer = PermissionSynchronizer( + repository: repository, + manager: manager, + ); + await synchronizer.syncForGroup(groupId); + } catch (_) { + // 권한 동기화 실패는 UI 동작에 영향을 주지 않도록 무시한다. + } + } } diff --git a/lib/features/masters/group_permission/application/permission_synchronizer.dart b/lib/features/masters/group_permission/application/permission_synchronizer.dart new file mode 100644 index 0000000..1e83db9 --- /dev/null +++ b/lib/features/masters/group_permission/application/permission_synchronizer.dart @@ -0,0 +1,51 @@ +import 'package:superport_v2/core/permissions/permission_manager.dart'; + +import '../domain/entities/group_permission.dart'; +import '../domain/mappers/group_permission_mapper.dart'; +import '../domain/repositories/group_permission_repository.dart'; + +/// 서버에서 그룹 메뉴 권한을 조회해 [PermissionManager]에 반영하는 동기화기. +class PermissionSynchronizer { + PermissionSynchronizer({ + required GroupPermissionRepository repository, + required PermissionManager manager, + this.pageSize = 200, + }) : _repository = repository, + _manager = manager; + + final GroupPermissionRepository _repository; + final PermissionManager _manager; + final int pageSize; + + /// 지정한 [groupId]의 메뉴 권한을 조회해 [PermissionManager]에 적용한다. + Future syncForGroup(int groupId) async { + final collected = []; + var page = 1; + + while (true) { + final response = await _repository.list( + page: page, + pageSize: pageSize, + groupId: groupId, + includeDeleted: false, + isActive: true, + ); + collected.addAll(response.items); + + final currentPageSize = response.pageSize == 0 + ? response.items.length + : response.pageSize; + if (currentPageSize == 0) { + break; + } + final fetched = page * currentPageSize; + if (fetched >= response.total) { + break; + } + page += 1; + } + + final permissionMap = buildPermissionMap(collected); + _manager.applyServerPermissions(permissionMap); + } +} diff --git a/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart b/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart index edffef9..705808c 100644 --- a/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart +++ b/lib/features/masters/group_permission/data/dtos/group_permission_dto.dart @@ -111,22 +111,39 @@ class GroupPermissionGroupDto { /// 권한 대상 메뉴 정보를 담는 DTO. class GroupPermissionMenuDto { - GroupPermissionMenuDto({required this.id, required this.menuName}); + GroupPermissionMenuDto({ + required this.id, + required this.menuCode, + required this.menuName, + this.path, + }); final int id; + final String menuCode; final String menuName; + final String? path; /// JSON에서 메뉴 정보를 파싱한다. factory GroupPermissionMenuDto.fromJson(Map json) { + final fallbackName = + json['menu_name'] as String? ?? json['name'] as String? ?? '-'; + final code = + json['menu_code'] as String? ?? json['code'] as String? ?? fallbackName; return GroupPermissionMenuDto( id: json['id'] as int? ?? json['menu_id'] as int, - menuName: json['menu_name'] as String? ?? json['name'] as String? ?? '-', + menuCode: code, + menuName: fallbackName, + path: json['path'] as String?, ); } /// DTO를 [GroupPermissionMenu] 엔티티로 변환한다. - GroupPermissionMenu toEntity() => - GroupPermissionMenu(id: id, menuName: menuName); + GroupPermissionMenu toEntity() => GroupPermissionMenu( + id: id, + menuCode: menuCode, + menuName: menuName, + path: path, + ); } /// 문자열/DateTime 값을 파싱해 [DateTime]으로 변환한다. 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 117ec90..5425366 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 @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/group_permission.dart'; import '../../domain/repositories/group_permission_repository.dart'; @@ -13,7 +14,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { final ApiClient _api; - static const _basePath = '/group-menu-permissions'; + static const _basePath = '${ApiRoutes.apiV1}/group-menu-permissions'; /// 그룹 권한 목록을 조회한다. @override diff --git a/lib/features/masters/group_permission/domain/entities/group_permission.dart b/lib/features/masters/group_permission/domain/entities/group_permission.dart index af008c0..6886947 100644 --- a/lib/features/masters/group_permission/domain/entities/group_permission.dart +++ b/lib/features/masters/group_permission/domain/entities/group_permission.dart @@ -70,10 +70,17 @@ class GroupPermissionGroup { } class GroupPermissionMenu { - GroupPermissionMenu({required this.id, required this.menuName}); + GroupPermissionMenu({ + required this.id, + required this.menuCode, + required this.menuName, + this.path, + }); final int id; + final String menuCode; final String menuName; + final String? path; } /// 그룹 권한 생성/수정 입력 모델 diff --git a/lib/features/masters/group_permission/domain/mappers/group_permission_mapper.dart b/lib/features/masters/group_permission/domain/mappers/group_permission_mapper.dart new file mode 100644 index 0000000..2dcca4d --- /dev/null +++ b/lib/features/masters/group_permission/domain/mappers/group_permission_mapper.dart @@ -0,0 +1,34 @@ +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; + +import '../entities/group_permission.dart'; + +/// 그룹-메뉴 권한 목록을 [PermissionManager]에 적용할 수 있는 맵으로 변환한다. +/// +/// - 메뉴 경로([GroupPermissionMenu.path])가 비어 있으면 해당 항목은 건너뛴다. +/// - 읽기 권한은 [PermissionAction.view]로 매핑하고, CRUD 권한은 각각 대응한다. +Map> buildPermissionMap( + Iterable permissions, +) { + final result = >{}; + for (final permission in permissions) { + final path = PermissionResources.normalize(permission.menu.path ?? ''); + if (path.isEmpty) { + continue; + } + final actions = result.putIfAbsent(path, () => {}); + if (permission.canRead) { + actions.add(PermissionAction.view); + } + if (permission.canCreate) { + actions.add(PermissionAction.create); + } + if (permission.canUpdate) { + actions.add(PermissionAction.edit); + } + if (permission.canDelete) { + actions.add(PermissionAction.delete); + } + } + return result; +} diff --git a/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart b/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart index 1fd8138..36c9555 100644 --- a/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart +++ b/lib/features/masters/group_permission/presentation/controllers/group_permission_controller.dart @@ -1,6 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; +import '../../../../../core/permissions/permission_manager.dart'; +import '../../application/permission_synchronizer.dart'; import '../../../group/domain/entities/group.dart'; import '../../../group/domain/repositories/group_repository.dart'; import '../../../menu/domain/entities/menu.dart'; @@ -20,13 +23,16 @@ class GroupPermissionController extends ChangeNotifier { required GroupPermissionRepository permissionRepository, required GroupRepository groupRepository, required MenuRepository menuRepository, + PermissionManager? permissionManager, }) : _permissionRepository = permissionRepository, _groupRepository = groupRepository, - _menuRepository = menuRepository; + _menuRepository = menuRepository, + _permissionManager = permissionManager; final GroupPermissionRepository _permissionRepository; final GroupRepository _groupRepository; final MenuRepository _menuRepository; + final PermissionManager? _permissionManager; PaginatedResult? _result; bool _isLoading = false; @@ -63,8 +69,9 @@ class GroupPermissionController extends ChangeNotifier { _groups ..clear() ..addAll(response.items); - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingGroups = false; notifyListeners(); @@ -84,8 +91,9 @@ class GroupPermissionController extends ChangeNotifier { _menus ..clear() ..addAll(response.items); - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingMenus = false; notifyListeners(); @@ -112,8 +120,9 @@ class GroupPermissionController extends ChangeNotifier { includeDeleted: _includeDeleted, ); _result = response; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -150,9 +159,11 @@ class GroupPermissionController extends ChangeNotifier { try { final created = await _permissionRepository.create(input); await fetch(page: 1); + await _syncPermissionsForGroup(input.groupId); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -166,9 +177,11 @@ class GroupPermissionController extends ChangeNotifier { try { final updated = await _permissionRepository.update(id, input); await fetch(page: _result?.page ?? 1); + await _syncPermissionsForGroup(input.groupId); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -180,11 +193,16 @@ class GroupPermissionController extends ChangeNotifier { Future delete(int id) async { _setSubmitting(true); try { + final groupId = _resolveGroupIdForPermission(id); await _permissionRepository.delete(id); await fetch(page: _result?.page ?? 1); + if (groupId != null) { + await _syncPermissionsForGroup(groupId); + } return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -198,9 +216,11 @@ class GroupPermissionController extends ChangeNotifier { try { final restored = await _permissionRepository.restore(id); await fetch(page: _result?.page ?? 1); + await _syncPermissionsForGroup(restored.group.id); return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -219,4 +239,33 @@ class GroupPermissionController extends ChangeNotifier { _isSubmitting = value; notifyListeners(); } + + Future _syncPermissionsForGroup(int groupId) async { + final manager = _permissionManager; + if (manager == null) { + return; + } + try { + final synchronizer = PermissionSynchronizer( + repository: _permissionRepository, + manager: manager, + ); + await synchronizer.syncForGroup(groupId); + } catch (_) { + // 권한 동기화 실패는 사용자 경험에 영향이 없도록 무시한다. + } + } + + int? _resolveGroupIdForPermission(int permissionId) { + final current = _result?.items; + if (current == null) { + return null; + } + for (final item in current) { + if (item.id == permissionId) { + return item.group.id; + } + } + return null; + } } diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart index 50ab9e4..8b23319 100644 --- a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -9,6 +9,7 @@ import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; import '../../../../../core/config/environment.dart'; +import '../../../../../core/permissions/permission_manager.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../group/domain/entities/group.dart'; import '../../../group/domain/repositories/group_repository.dart'; @@ -118,14 +119,27 @@ class _GroupPermissionEnabledPageState final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; + bool _initialized = false; + @override void initState() { super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_initialized) { + return; + } + final permissionManager = PermissionScope.of(context); _controller = GroupPermissionController( permissionRepository: GetIt.I(), groupRepository: GetIt.I(), menuRepository: GetIt.I(), + permissionManager: permissionManager, )..addListener(_handleControllerUpdate); + _initialized = true; WidgetsBinding.instance.addPostFrameCallback((_) async { await _controller.loadGroups(); await _controller.loadMenus(); 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 5bcdeab..d12de6e 100644 --- a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart +++ b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/menu.dart'; import '../../domain/repositories/menu_repository.dart'; @@ -12,7 +13,7 @@ class MenuRepositoryRemote implements MenuRepository { final ApiClient _api; - static const _basePath = '/menus'; + static const _basePath = '${ApiRoutes.apiV1}/menus'; /// 메뉴 목록을 조회한다. @override diff --git a/lib/features/masters/menu/presentation/controllers/menu_controller.dart b/lib/features/masters/menu/presentation/controllers/menu_controller.dart index 8ddaa7f..7f211ac 100644 --- a/lib/features/masters/menu/presentation/controllers/menu_controller.dart +++ b/lib/features/masters/menu/presentation/controllers/menu_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../domain/entities/menu.dart'; import '../../domain/repositories/menu_repository.dart'; @@ -50,8 +51,9 @@ class MenuController extends ChangeNotifier { includeDeleted: false, ); _parents = response.items; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingParents = false; notifyListeners(); @@ -78,8 +80,9 @@ class MenuController extends ChangeNotifier { includeDeleted: _includeDeleted, ); _result = response; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -118,8 +121,9 @@ class MenuController extends ChangeNotifier { await fetch(page: 1); await loadParents(); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -135,8 +139,9 @@ class MenuController extends ChangeNotifier { await fetch(page: _result?.page ?? 1); await loadParents(); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -152,8 +157,9 @@ class MenuController extends ChangeNotifier { await fetch(page: _result?.page ?? 1); await loadParents(); return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -169,8 +175,9 @@ class MenuController extends ChangeNotifier { await fetch(page: _result?.page ?? 1); await loadParents(); return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { 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 a4b59d8..3d5789a 100644 --- a/lib/features/masters/product/data/repositories/product_repository_remote.dart +++ b/lib/features/masters/product/data/repositories/product_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/product.dart'; import '../../domain/repositories/product_repository.dart'; @@ -12,7 +13,7 @@ class ProductRepositoryRemote implements ProductRepository { final ApiClient _api; - static const _basePath = '/products'; + static const _basePath = '${ApiRoutes.apiV1}/products'; /// 제품 목록을 조회한다. @override diff --git a/lib/features/masters/product/presentation/controllers/product_controller.dart b/lib/features/masters/product/presentation/controllers/product_controller.dart index 5500b42..25ea343 100644 --- a/lib/features/masters/product/presentation/controllers/product_controller.dart +++ b/lib/features/masters/product/presentation/controllers/product_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../../vendor/domain/entities/vendor.dart'; import '../../../vendor/domain/repositories/vendor_repository.dart'; @@ -77,8 +78,9 @@ class ProductController extends ChangeNotifier { if (response.pageSize > 0 && response.pageSize != _pageSize) { _pageSize = response.pageSize; } - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -94,8 +96,9 @@ class ProductController extends ChangeNotifier { final uomResult = await _uomRepository.list(page: 1, pageSize: 100); _vendorOptions = vendorResult.items; _uomOptions = uomResult.items; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingLookups = false; notifyListeners(); @@ -154,8 +157,9 @@ class ProductController extends ChangeNotifier { final created = await _productRepository.create(input); await fetch(page: 1); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -170,8 +174,9 @@ class ProductController extends ChangeNotifier { final updated = await _productRepository.update(id, input); await fetch(page: _result?.page ?? 1); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -186,8 +191,9 @@ class ProductController extends ChangeNotifier { await _productRepository.delete(id); await fetch(page: _result?.page ?? 1); return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -202,8 +208,9 @@ class ProductController extends ChangeNotifier { final restored = await _productRepository.restore(id); await fetch(page: _result?.page ?? 1); return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { diff --git a/lib/features/masters/uom/data/repositories/uom_repository_remote.dart b/lib/features/masters/uom/data/repositories/uom_repository_remote.dart index 1c72060..8b4a15f 100644 --- a/lib/features/masters/uom/data/repositories/uom_repository_remote.dart +++ b/lib/features/masters/uom/data/repositories/uom_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/uom.dart'; import '../../domain/repositories/uom_repository.dart'; @@ -12,7 +13,7 @@ class UomRepositoryRemote implements UomRepository { final ApiClient _api; - static const _basePath = '/uoms'; + static const _basePath = '${ApiRoutes.apiV1}/uoms'; /// UOM 목록을 조회한다. @override 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 207e19e..e982013 100644 --- a/lib/features/masters/user/data/repositories/user_repository_remote.dart +++ b/lib/features/masters/user/data/repositories/user_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/user.dart'; import '../../domain/repositories/user_repository.dart'; @@ -12,7 +13,7 @@ class UserRepositoryRemote implements UserRepository { final ApiClient _api; - static const _basePath = '/employees'; + static const _basePath = '${ApiRoutes.apiV1}/employees'; /// 사용자 목록을 조회한다. @override diff --git a/lib/features/masters/user/presentation/controllers/user_controller.dart b/lib/features/masters/user/presentation/controllers/user_controller.dart index 7ddf163..4f7e362 100644 --- a/lib/features/masters/user/presentation/controllers/user_controller.dart +++ b/lib/features/masters/user/presentation/controllers/user_controller.dart @@ -1,8 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; +import '../../../../../core/permissions/permission_manager.dart'; import '../../../group/domain/entities/group.dart'; import '../../../group/domain/repositories/group_repository.dart'; +import '../../../group_permission/application/permission_synchronizer.dart'; +import '../../../group_permission/domain/repositories/group_permission_repository.dart'; import '../../domain/entities/user.dart'; import '../../domain/repositories/user_repository.dart'; @@ -14,11 +18,17 @@ class UserController extends ChangeNotifier { UserController({ required UserRepository userRepository, required GroupRepository groupRepository, + GroupPermissionRepository? permissionRepository, + PermissionManager? permissionManager, }) : _userRepository = userRepository, - _groupRepository = groupRepository; + _groupRepository = groupRepository, + _permissionRepository = permissionRepository, + _permissionManager = permissionManager; final UserRepository _userRepository; final GroupRepository _groupRepository; + final GroupPermissionRepository? _permissionRepository; + final PermissionManager? _permissionManager; PaginatedResult? _result; bool _isLoading = false; @@ -47,8 +57,9 @@ class UserController extends ChangeNotifier { try { final response = await _groupRepository.list(page: 1, pageSize: 100); _groups = response.items; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoadingGroups = false; notifyListeners(); @@ -74,8 +85,9 @@ class UserController extends ChangeNotifier { isActive: isActive, ); _result = response; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -106,9 +118,11 @@ class UserController extends ChangeNotifier { try { final created = await _userRepository.create(input); await fetch(page: 1); + await _syncPermissions(input.groupId); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -122,9 +136,11 @@ class UserController extends ChangeNotifier { try { final updated = await _userRepository.update(id, input); await fetch(page: _result?.page ?? 1); + await _syncPermissions(input.groupId); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -136,11 +152,16 @@ class UserController extends ChangeNotifier { Future delete(int id) async { _setSubmitting(true); try { + final groupId = _resolveGroupId(id); await _userRepository.delete(id); await fetch(page: _result?.page ?? 1); + if (groupId != null) { + await _syncPermissions(groupId); + } return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -154,9 +175,14 @@ class UserController extends ChangeNotifier { try { final restored = await _userRepository.restore(id); await fetch(page: _result?.page ?? 1); + final groupId = restored.group?.id; + if (groupId != null) { + await _syncPermissions(groupId); + } return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -175,4 +201,34 @@ class UserController extends ChangeNotifier { _isSubmitting = value; notifyListeners(); } + + Future _syncPermissions(int groupId) async { + final manager = _permissionManager; + final repository = _permissionRepository; + if (manager == null || repository == null) { + return; + } + try { + final synchronizer = PermissionSynchronizer( + repository: repository, + manager: manager, + ); + await synchronizer.syncForGroup(groupId); + } catch (_) { + // 권한 동기화 실패는 무시하고 기존 흐름을 유지한다. + } + } + + int? _resolveGroupId(int userId) { + final items = _result?.items; + if (items == null) { + return null; + } + for (final user in items) { + if (user.id == userId) { + return user.group?.id; + } + } + return null; + } } diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index 4166ce8..00e14d8 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -8,9 +8,11 @@ import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; import '../../../../../core/config/environment.dart'; +import '../../../../../core/permissions/permission_manager.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../group/domain/entities/group.dart'; import '../../../group/domain/repositories/group_repository.dart'; +import '../../../group_permission/domain/repositories/group_permission_repository.dart'; import '../../domain/entities/user.dart'; import '../../domain/repositories/user_repository.dart'; import '../controllers/user_controller.dart'; @@ -96,17 +98,31 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { final FocusNode _searchFocus = FocusNode(); bool _groupsLoaded = false; String? _lastError; + bool _initialized = false; @override void initState() { super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_initialized) { + return; + } + final permissionManager = PermissionScope.of(context); _controller = UserController( userRepository: GetIt.I(), groupRepository: GetIt.I(), + permissionRepository: GetIt.I(), + permissionManager: permissionManager, )..addListener(_handleControllerUpdate); + _initialized = true; WidgetsBinding.instance.addPostFrameCallback((_) async { await _controller.loadGroups(); await _controller.fetch(); + if (!mounted) return; setState(() { _groupsLoaded = true; }); 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 2bb45c0..15645b4 100644 --- a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart +++ b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/vendor.dart'; import '../../domain/repositories/vendor_repository.dart'; @@ -13,7 +14,7 @@ class VendorRepositoryRemote implements VendorRepository { final ApiClient _api; - static const _basePath = '/vendors'; // TODO: 백엔드 경로 확정 시 수정 + static const _basePath = '${ApiRoutes.apiV1}/vendors'; @override Future> list({ diff --git a/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart index 93f72b5..e25251a 100644 --- a/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart +++ b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../domain/entities/vendor.dart'; import '../../domain/repositories/vendor_repository.dart'; @@ -55,8 +56,9 @@ class VendorController extends ChangeNotifier { if (response.pageSize > 0 && response.pageSize != _pageSize) { _pageSize = response.pageSize; } - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -94,8 +96,9 @@ class VendorController extends ChangeNotifier { final vendor = await _repository.create(input); await fetch(page: 1); return vendor; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -110,8 +113,9 @@ class VendorController extends ChangeNotifier { final vendor = await _repository.update(id, input); await fetch(page: _result?.page ?? 1); return vendor; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -126,8 +130,9 @@ class VendorController extends ChangeNotifier { await _repository.delete(id); await fetch(page: _result?.page ?? 1); return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -142,8 +147,9 @@ class VendorController extends ChangeNotifier { final vendor = await _repository.restore(id); await fetch(page: _result?.page ?? 1); return vendor; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { 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 334671e..7fb03d1 100644 --- a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart +++ b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/warehouse.dart'; import '../../domain/repositories/warehouse_repository.dart'; @@ -12,7 +13,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository { final ApiClient _api; - static const _basePath = '/warehouses'; + static const _basePath = '${ApiRoutes.apiV1}/warehouses'; /// 창고 목록을 조회한다. @override @@ -21,6 +22,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository { int pageSize = 20, String? query, bool? isActive, + bool includeZipcode = true, }) async { final response = await _api.get>( _basePath, @@ -29,6 +31,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository { 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, if (isActive != null) 'is_active': isActive, + if (includeZipcode) 'include': 'zipcode', }, options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart b/lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart index 2b42e9e..858b169 100644 --- a/lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart +++ b/lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart @@ -10,6 +10,7 @@ abstract class WarehouseRepository { int pageSize = 20, String? query, bool? isActive, + bool includeZipcode = true, }); /// 창고를 생성한다. diff --git a/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart index 4138069..b75484a 100644 --- a/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart +++ b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/failure.dart'; import '../../domain/entities/warehouse.dart'; import '../../domain/repositories/warehouse_repository.dart'; @@ -53,8 +54,9 @@ class WarehouseController extends ChangeNotifier { if (response.pageSize > 0 && response.pageSize != _pageSize) { _pageSize = response.pageSize; } - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); } finally { _isLoading = false; notifyListeners(); @@ -95,8 +97,9 @@ class WarehouseController extends ChangeNotifier { final created = await _repository.create(input); await fetch(page: 1); return created; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -111,8 +114,9 @@ class WarehouseController extends ChangeNotifier { final updated = await _repository.update(id, input); await fetch(page: _result?.page ?? 1); return updated; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { @@ -127,8 +131,9 @@ class WarehouseController extends ChangeNotifier { await _repository.delete(id); await fetch(page: _result?.page ?? 1); return true; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return false; } finally { @@ -143,8 +148,9 @@ class WarehouseController extends ChangeNotifier { final restored = await _repository.restore(id); await fetch(page: _result?.page ?? 1); return restored; - } catch (e) { - _errorMessage = e.toString(); + } catch (error) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); notifyListeners(); return null; } finally { diff --git a/lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart b/lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart index fd63898..d13ba39 100644 --- a/lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart +++ b/lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/core/network/api_routes.dart'; import '../../domain/entities/postal_code.dart'; import '../../domain/repositories/postal_search_repository.dart'; @@ -13,12 +14,13 @@ class PostalSearchRepositoryRemote implements PostalSearchRepository { final ApiClient _api; - static const _path = '/zipcodes'; + static const _path = '${ApiRoutes.apiV1}/zipcodes'; @override Future> search({ required String keyword, int limit = 20, + int page = 1, }) async { final trimmed = keyword.trim(); if (trimmed.isEmpty) { @@ -27,12 +29,7 @@ class PostalSearchRepositoryRemote implements PostalSearchRepository { final response = await _api.get( _path, - query: { - 'zipcode': trimmed, - 'road_name': trimmed, - 'q': trimmed, - 'page_size': limit, - }, + query: {'q': trimmed, 'page': page, 'page_size': limit}, options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/util/postal_search/domain/repositories/postal_search_repository.dart b/lib/features/util/postal_search/domain/repositories/postal_search_repository.dart index 58cb9a3..c682194 100644 --- a/lib/features/util/postal_search/domain/repositories/postal_search_repository.dart +++ b/lib/features/util/postal_search/domain/repositories/postal_search_repository.dart @@ -5,6 +5,11 @@ abstract class PostalSearchRepository { /// 키워드를 기반으로 우편번호 목록을 검색한다. /// /// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다. - /// [limit]을 지정하면 최대 반환 건수를 제한한다. - Future> search({required String keyword, int limit = 20}); + /// [limit]은 페이지 크기(`page_size`)에 대응한다. + /// [page]를 지정하면 서버 페이지네이션을 제어한다. + Future> search({ + required String keyword, + int limit = 20, + int page = 1, + }); } diff --git a/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart index 560ae3a..3e18a54 100644 --- a/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart +++ b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../domain/entities/postal_code.dart'; @@ -102,7 +103,8 @@ class _PostalSearchDialogState extends State<_PostalSearchDialog> { if (!mounted) return; setState(() { _results = const []; - _errorMessage = error.toString(); + final failure = Failure.from(error); + _errorMessage = failure.describe(); }); } finally { if (mounted) { diff --git a/test/features/approvals/approval_page_permission_test.dart b/test/features/approvals/approval_page_permission_test.dart index cd1b132..291c6e9 100644 --- a/test/features/approvals/approval_page_permission_test.dart +++ b/test/features/approvals/approval_page_permission_test.dart @@ -7,11 +7,15 @@ 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/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../helpers/test_app.dart'; @@ -42,12 +46,14 @@ void main() { testWidgets('결재 단계 액션은 승인 권한이 없으면 비활성화된다', (tester) async { final repo = _StubApprovalRepository(); final templateRepo = _StubApprovalTemplateRepository(); + final lookupRepo = _StubInventoryLookupRepository(); GetIt.I.registerSingleton(repo); GetIt.I.registerSingleton(templateRepo); + GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( overrides: { - '/approvals/requests': {PermissionAction.view}, + PermissionResources.approvals: {PermissionAction.view}, }, ); @@ -83,12 +89,14 @@ void main() { testWidgets('승인 권한이 있으면 단계 액션을 실행할 수 있다', (tester) async { final repo = _StubApprovalRepository(); final templateRepo = _StubApprovalTemplateRepository(); + final lookupRepo = _StubInventoryLookupRepository(); GetIt.I.registerSingleton(repo); GetIt.I.registerSingleton(templateRepo); + GetIt.I.registerSingleton(lookupRepo); final permissionManager = PermissionManager( overrides: { - '/approvals/requests': { + PermissionResources.approvals: { PermissionAction.view, PermissionAction.approve, }, @@ -115,6 +123,44 @@ void main() { expect(approveButton.onPressed, isNotNull); expect(find.text('결재 권한이 없어 단계 행위를 실행할 수 없습니다.'), findsNothing); }); + + testWidgets('canProceed가 false면 단계 버튼을 비활성화하고 이유를 안내한다', (tester) async { + final repo = _BlockingApprovalRepository(); + final templateRepo = _StubApprovalTemplateRepository(); + final lookupRepo = _StubInventoryLookupRepository(); + GetIt.I.registerSingleton(repo); + GetIt.I.registerSingleton(templateRepo); + GetIt.I.registerSingleton(lookupRepo); + + final permissionManager = PermissionManager( + overrides: { + PermissionResources.approvals: { + PermissionAction.view, + PermissionAction.approve, + }, + }, + ); + + await pumpApprovalPage(tester, permissionManager); + + final rowFinder = find.byKey(const ValueKey('approval_row_1')); + expect(rowFinder, findsOneWidget); + + await tester.tap(rowFinder); + await tester.pumpAndSettle(); + + final tabContext = tester.element(find.byType(TabBar)); + final tabController = DefaultTabController.of(tabContext); + tabController.animateTo(1); + await tester.pumpAndSettle(); + + expect(find.textContaining('선행 단계가 완료되지 않았습니다.'), findsWidgets); + + final approveButton = tester.widget( + find.byKey(const ValueKey('step_action_100_approve')), + ); + expect(approveButton.onPressed, isNull); + }); } class _StubApprovalRepository implements ApprovalRepository { @@ -194,6 +240,11 @@ class _StubApprovalRepository implements ApprovalRepository { return _approval; } + @override + Future canProceed(int id) async { + return ApprovalProceedStatus(approvalId: id, canProceed: true); + } + @override Future create(ApprovalInput input) { throw UnimplementedError(); @@ -215,6 +266,17 @@ class _StubApprovalRepository implements ApprovalRepository { } } +class _BlockingApprovalRepository extends _StubApprovalRepository { + @override + Future canProceed(int id) async { + return ApprovalProceedStatus( + approvalId: id, + canProceed: false, + reason: '선행 단계가 완료되지 않았습니다. 관리자에게 문의하세요.', + ); + } +} + class _StubApprovalTemplateRepository implements ApprovalTemplateRepository { _StubApprovalTemplateRepository(); @@ -285,3 +347,45 @@ class _StubApprovalTemplateRepository implements ApprovalTemplateRepository { throw UnimplementedError(); } } + +class _StubInventoryLookupRepository implements InventoryLookupRepository { + @override + Future> fetchTransactionTypes({ + bool activeOnly = true, + }) async { + return [ + LookupItem(id: 1, name: '입고', code: 'INBOUND'), + LookupItem(id: 2, name: '출고', code: 'OUTBOUND'), + ]; + } + + @override + Future> fetchTransactionStatuses({ + bool activeOnly = true, + }) async { + return [ + LookupItem(id: 10, name: '승인대기', code: 'pending'), + LookupItem(id: 11, name: '진행중', code: 'in_progress'), + ]; + } + + @override + Future> fetchApprovalStatuses({ + bool activeOnly = true, + }) async { + return [ + LookupItem(id: 20, name: '승인대기', code: 'pending'), + LookupItem(id: 21, name: '진행중', code: 'in_progress'), + ]; + } + + @override + Future> fetchApprovalActions({ + bool activeOnly = true, + }) async { + return [ + LookupItem(id: 30, name: '승인', code: 'approve'), + LookupItem(id: 31, name: '반려', code: 'reject'), + ]; + } +} diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart index a6e0a3e..8d85c07 100644 --- a/test/features/approvals/presentation/controllers/approval_controller_test.dart +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; @@ -74,6 +75,12 @@ void main() { approvalRepository: repository, templateRepository: templateRepository, ); + when(() => repository.canProceed(any())).thenAnswer( + (_) async => ApprovalProceedStatus( + approvalId: sampleApproval.id!, + canProceed: true, + ), + ); }); // fetch 메서드 관련 시나리오 @@ -166,6 +173,8 @@ void main() { includeHistories: true, ), ).called(1); + verify(() => repository.canProceed(1)).called(1); + expect(controller.canProceedSelected, isTrue); }); test('에러 발생 시 errorMessage 설정', () async { @@ -369,6 +378,30 @@ void main() { expect(controller.isPerformingAction, isFalse); }); + test('canProceed가 false면 액션을 중단한다', () async { + when(() => repository.canProceed(any())).thenAnswer( + (_) async => ApprovalProceedStatus( + approvalId: sampleApproval.id!, + canProceed: false, + reason: '선행 단계가 완료되지 않았습니다.', + ), + ); + + await controller.loadActionOptions(force: true); + await controller.fetch(); + await controller.selectApproval(sampleApproval.id!); + + final success = await controller.performStepAction( + step: sampleStep, + type: ApprovalStepActionType.approve, + ); + + expect(success, isFalse); + expect(controller.errorMessage, contains('선행 단계')); + expect(controller.canProceedSelected, isFalse); + verifyNever(() => repository.performStepAction(any())); + }); + test('행위를 찾지 못하면 요청하지 않는다', () async { when( () => repository.listActions(activeOnly: any(named: 'activeOnly')), diff --git a/test/features/approvals/presentation/pages/approval_page_test.dart b/test/features/approvals/presentation/pages/approval_page_test.dart index 84cd1a1..afb3852 100644 --- a/test/features/approvals/presentation/pages/approval_page_test.dart +++ b/test/features/approvals/presentation/pages/approval_page_test.dart @@ -5,22 +5,38 @@ import 'package:get_it/get_it.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/permissions/permission_manager.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; class _MockApprovalRepository extends Mock implements ApprovalRepository {} class _FakeApprovalInput extends Fake implements ApprovalInput {} +class _MockApprovalTemplateRepository extends Mock + implements ApprovalTemplateRepository {} + +class _MockInventoryLookupRepository extends Mock + implements InventoryLookupRepository {} + Widget _buildApp(Widget child) { - return MaterialApp( - home: ShadTheme( - data: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, + return PermissionScope( + manager: PermissionManager(), + child: MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), ), - child: Scaffold(body: child), ), ); } @@ -49,11 +65,75 @@ void main() { group('플래그 On', () { late _MockApprovalRepository repository; + late _MockApprovalTemplateRepository templateRepository; + late _MockInventoryLookupRepository lookupRepository; setUp(() { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); repository = _MockApprovalRepository(); + templateRepository = _MockApprovalTemplateRepository(); + lookupRepository = _MockInventoryLookupRepository(); GetIt.I.registerLazySingleton(() => repository); + GetIt.I.registerLazySingleton( + () => templateRepository, + ); + GetIt.I.registerLazySingleton( + () => lookupRepository, + ); + when( + () => templateRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + when( + () => repository.listActions(activeOnly: any(named: 'activeOnly')), + ).thenAnswer((_) async => const []); + when(() => lookupRepository.fetchApprovalStatuses()).thenAnswer( + (_) async => [LookupItem(id: 1, name: '승인대기', code: 'pending')], + ); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + status: any(named: 'status'), + from: any(named: 'from'), + to: any(named: 'to'), + includeHistories: any(named: 'includeHistories'), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + }); + + testWidgets('상태 룩업을 불러와 필터 라벨을 구성한다', (tester) async { + await tester.pumpWidget(_buildApp(const ApprovalPage())); + await tester.pumpAndSettle(); + + verify(() => lookupRepository.fetchApprovalStatuses()).called(1); + final statusSelectFinder = find.byKey( + const ValueKey(ApprovalStatusFilter.all), + ); + expect(statusSelectFinder, findsOneWidget); + await tester.tap(statusSelectFinder); + await tester.pumpAndSettle(); + expect(find.text('승인대기'), findsWidgets); }); }); } diff --git a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart index 2380c66..c774516 100644 --- a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart +++ b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart'; @@ -18,7 +19,9 @@ class _MockApprovalStepRepository extends Mock Widget _buildApp(Widget child) { final manager = PermissionManager( - overrides: {'/approvals/steps': PermissionAction.values.toSet()}, + overrides: { + PermissionResources.approvalSteps: PermissionAction.values.toSet(), + }, ); return MaterialApp( home: PermissionScope( diff --git a/test/features/masters/customer/data/customer_repository_remote_test.dart b/test/features/masters/customer/data/customer_repository_remote_test.dart new file mode 100644 index 0000000..62a5cf2 --- /dev/null +++ b/test/features/masters/customer/data/customer_repository_remote_test.dart @@ -0,0 +1,119 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/features/masters/customer/data/repositories/customer_repository_remote.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late CustomerRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = CustomerRepositoryRemote(apiClient: apiClient); + }); + + test('list 호출 시 필터를 쿼리에 포함한다', () async { + when( + () => apiClient.get>( + any(), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'items': [ + {'id': 1, 'customer_code': 'C-001', 'customer_name': '슈퍼포트'}, + ], + 'page': 2, + 'page_size': 30, + 'total': 1, + }, + requestOptions: RequestOptions(path: '/api/v1/customers'), + statusCode: 200, + ), + ); + + final result = await repository.list( + page: 2, + pageSize: 30, + query: 'sup', + isPartner: true, + isGeneral: false, + isActive: true, + ); + + expect(result.items, isNotEmpty); + + final verification = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ); + final path = verification.captured[0] as String; + final query = verification.captured[1] as Map; + + expect(path, equals('/api/v1/customers')); + expect(query['page'], 2); + expect(query['page_size'], 30); + expect(query['q'], 'sup'); + expect(query['is_partner'], true); + expect(query['is_general'], false); + expect(query['is_active'], true); + }); + + test('fetchDetail은 include=zipcode 파라미터를 전달한다', () async { + when( + () => apiClient.get>( + any(), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'data': { + 'id': 10, + 'customer_code': 'C-010', + 'customer_name': '테스트 고객', + 'zipcode': {'zipcode': '06000'}, + }, + }, + requestOptions: RequestOptions(path: '/api/v1/customers/10'), + statusCode: 200, + ), + ); + + final customer = await repository.fetchDetail(10); + + expect(customer.customerCode, 'C-010'); + + final verification = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ); + final path = verification.captured[0] as String; + final query = verification.captured[1] as Map; + + expect(path, equals('/api/v1/customers/10')); + expect(query['include'], 'zipcode'); + }); +} diff --git a/test/features/masters/group/data/group_repository_remote_test.dart b/test/features/masters/group/data/group_repository_remote_test.dart new file mode 100644 index 0000000..4fa136e --- /dev/null +++ b/test/features/masters/group/data/group_repository_remote_test.dart @@ -0,0 +1,65 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/features/masters/group/data/repositories/group_repository_remote.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late GroupRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = GroupRepositoryRemote(apiClient: apiClient); + }); + + test('include 옵션이 쿼리 파라미터에 반영된다', () async { + when( + () => apiClient.get>( + any(), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'items': [ + { + 'id': 1, + 'group_name': '관리자', + 'is_default': true, + 'is_active': true, + }, + ], + }, + requestOptions: RequestOptions(path: '/api/v1/groups'), + statusCode: 200, + ), + ); + + await repository.list(includePermissions: true, includeEmployees: true); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + final path = captured[0] as String; + final query = captured[1] as Map; + + expect(path, equals('/api/v1/groups')); + expect(query['include'], 'permissions,employees'); + }); +} diff --git a/test/features/masters/group/presentation/controllers/group_controller_test.dart b/test/features/masters/group/presentation/controllers/group_controller_test.dart index 3a5ce2e..c4989d7 100644 --- a/test/features/masters/group/presentation/controllers/group_controller_test.dart +++ b/test/features/masters/group/presentation/controllers/group_controller_test.dart @@ -2,12 +2,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; import 'package:superport_v2/features/masters/group/presentation/controllers/group_controller.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; class _MockGroupRepository extends Mock implements GroupRepository {} +class _MockGroupPermissionRepository extends Mock + implements GroupPermissionRepository {} + class _FakeGroupInput extends Fake implements GroupInput {} void main() { @@ -126,6 +132,65 @@ void main() { expect(controller.statusFilter, GroupStatusFilter.activeOnly); }); + group('permission sync', () { + late _MockGroupPermissionRepository permissionRepository; + late PermissionManager permissionManager; + + setUp(() { + permissionRepository = _MockGroupPermissionRepository(); + permissionManager = PermissionManager(); + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 200, + total: 0, + ), + ); + }); + + test('그룹 생성 후 권한 동기화를 시도한다', () async { + when(() => repository.create(any())).thenAnswer((_) async => sampleGroup); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isDefault: any(named: 'isDefault'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + final controllerWithSync = GroupController( + repository: repository, + permissionRepository: permissionRepository, + permissionManager: permissionManager, + ); + + await controllerWithSync.create(GroupInput(groupName: '신규 그룹')); + + verify( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: sampleGroup.id, + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).called(greaterThanOrEqualTo(1)); + }); + }); + group('mutations', () { setUp(() { when( diff --git a/test/features/masters/group_permission/application/permission_synchronizer_test.dart b/test/features/masters/group_permission/application/permission_synchronizer_test.dart new file mode 100644 index 0000000..e74cc85 --- /dev/null +++ b/test/features/masters/group_permission/application/permission_synchronizer_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; +import 'package:superport_v2/features/masters/group_permission/application/permission_synchronizer.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; + +class _MockGroupPermissionRepository extends Mock + implements GroupPermissionRepository {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('그룹 권한을 모두 불러와 PermissionManager에 적용한다', () async { + final repository = _MockGroupPermissionRepository(); + final manager = PermissionManager(); + final synchronizer = PermissionSynchronizer( + repository: repository, + manager: manager, + pageSize: 1, + ); + + final permissionPage1 = GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'INBOUND', + menuName: '입고', + path: '/inventory/inbound', + ), + canCreate: true, + canRead: true, + canUpdate: false, + canDelete: false, + ); + + final permissionPage2 = GroupPermission( + id: 2, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 11, + menuCode: 'OUTBOUND', + menuName: '출고', + path: '/inventory/outbound', + ), + canCreate: false, + canRead: true, + canUpdate: true, + canDelete: false, + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((invocation) async { + final page = invocation.namedArguments[#page] as int; + if (page == 1) { + return PaginatedResult( + items: [permissionPage1], + page: 1, + pageSize: 1, + total: 2, + ); + } + return PaginatedResult( + items: [permissionPage2], + page: 2, + pageSize: 1, + total: 2, + ); + }); + + await synchronizer.syncForGroup(1); + + verify( + () => repository.list( + page: any(named: 'page'), + pageSize: 1, + groupId: 1, + menuId: null, + isActive: true, + includeDeleted: false, + ), + ).called(greaterThanOrEqualTo(1)); + + expect( + manager.can( + PermissionResources.stockTransactions, + PermissionAction.create, + ), + isTrue, + ); + expect( + manager.can(PermissionResources.stockTransactions, PermissionAction.edit), + isTrue, + ); + expect( + manager.can( + PermissionResources.stockTransactions, + PermissionAction.delete, + ), + isFalse, + ); + }); +} diff --git a/test/features/masters/group_permission/domain/group_permission_mapper_test.dart b/test/features/masters/group_permission/domain/group_permission_mapper_test.dart new file mode 100644 index 0000000..40148da --- /dev/null +++ b/test/features/masters/group_permission/domain/group_permission_mapper_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/mappers/group_permission_mapper.dart'; + +void main() { + test('메뉴 경로 기준으로 권한 맵을 생성한다', () { + final permissions = [ + GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'INBOUND', + menuName: '입고', + path: '/inventory/inbound', + ), + canCreate: true, + canRead: true, + canUpdate: false, + canDelete: false, + ), + GroupPermission( + id: 2, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 11, + menuCode: 'OUTBOUND', + menuName: '출고', + path: '/inventory/outbound', + ), + canCreate: false, + canRead: true, + canUpdate: true, + canDelete: true, + ), + GroupPermission( + id: 3, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 12, + menuCode: 'NO_PATH', + menuName: '경로없음', + path: null, + ), + canCreate: true, + canRead: true, + canUpdate: true, + canDelete: true, + ), + ]; + + final map = buildPermissionMap(permissions); + + expect(map.length, 1); + final stockPermissions = + map[PermissionResources.stockTransactions] ?? {}; + expect( + stockPermissions, + containsAll({ + PermissionAction.view, + PermissionAction.create, + PermissionAction.edit, + PermissionAction.delete, + }), + ); + expect(map.containsKey('NO_PATH'), isFalse); + }); +} diff --git a/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart b/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart index 6324e84..ce222ef 100644 --- a/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart +++ b/test/features/masters/group_permission/presentation/controllers/group_permission_controller_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; @@ -24,11 +25,17 @@ void main() { late _MockPermissionRepository permissionRepository; late _MockGroupRepository groupRepository; late _MockMenuRepository menuRepository; + late PermissionManager permissionManager; final samplePermission = GroupPermission( id: 1, group: GroupPermissionGroup(id: 1, groupName: '관리자'), - menu: GroupPermissionMenu(id: 10, menuName: '대시보드'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'DASHBOARD', + menuName: '대시보드', + path: '/dashboard', + ), canCreate: true, canRead: true, canUpdate: false, @@ -52,10 +59,22 @@ void main() { permissionRepository = _MockPermissionRepository(); groupRepository = _MockGroupRepository(); menuRepository = _MockMenuRepository(); + permissionManager = PermissionManager(); + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async => createResult([samplePermission])); controller = GroupPermissionController( permissionRepository: permissionRepository, groupRepository: groupRepository, menuRepository: menuRepository, + permissionManager: permissionManager, ); }); @@ -68,6 +87,8 @@ void main() { query: any(named: 'query'), isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), + includePermissions: any(named: 'includePermissions'), + includeEmployees: any(named: 'includeEmployees'), ), ).thenAnswer( (_) async => PaginatedResult( diff --git a/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart index d5a4a4a..e8ebc4d 100644 --- a/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart +++ b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart @@ -6,6 +6,7 @@ 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/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; @@ -24,13 +25,16 @@ class _MockMenuRepository extends Mock implements MenuRepository {} class _FakeGroupPermissionInput extends Fake implements GroupPermissionInput {} Widget _buildApp(Widget child) { - return MaterialApp( - home: ShadTheme( - data: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, + return PermissionScope( + manager: PermissionManager(), + child: MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), ), - child: Scaffold(body: child), ), ); } @@ -80,6 +84,8 @@ void main() { query: any(named: 'query'), isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), + includePermissions: any(named: 'includePermissions'), + includeEmployees: any(named: 'includeEmployees'), ), ).thenAnswer( (_) async => PaginatedResult( @@ -125,7 +131,12 @@ void main() { GroupPermission( id: 1, group: GroupPermissionGroup(id: 1, groupName: '관리자'), - menu: GroupPermissionMenu(id: 10, menuName: '대시보드'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'DASHBOARD', + menuName: '대시보드', + path: '/dashboard', + ), canCreate: true, canRead: true, ), @@ -201,7 +212,12 @@ void main() { GroupPermission( id: 5, group: GroupPermissionGroup(id: 1, groupName: '관리자'), - menu: GroupPermissionMenu(id: 10, menuName: '대시보드'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'DASHBOARD', + menuName: '대시보드', + path: '/dashboard', + ), canCreate: true, canRead: true, ), @@ -226,7 +242,9 @@ void main() { ), menu: GroupPermissionMenu( id: capturedInput!.menuId, + menuCode: 'DASHBOARD', menuName: '대시보드', + path: '/dashboard', ), canCreate: capturedInput!.canCreate, canRead: capturedInput!.canRead, diff --git a/test/features/masters/product/data/product_repository_remote_test.dart b/test/features/masters/product/data/product_repository_remote_test.dart new file mode 100644 index 0000000..6e4bdac --- /dev/null +++ b/test/features/masters/product/data/product_repository_remote_test.dart @@ -0,0 +1,86 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/features/masters/product/data/repositories/product_repository_remote.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late ProductRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = ProductRepositoryRemote(apiClient: apiClient); + }); + + test('list는 include 파라미터와 필터를 전달한다', () async { + when( + () => apiClient.get>( + any(), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'items': [ + { + 'id': 1, + 'product_code': 'P-001', + 'product_name': '샘플', + 'vendor': { + 'id': 10, + 'vendor_code': 'V-010', + 'vendor_name': '테스트 벤더', + }, + 'uom': {'id': 5, 'uom_name': 'EA'}, + }, + ], + 'page': 1, + 'page_size': 20, + 'total': 1, + }, + requestOptions: RequestOptions(path: '/api/v1/products'), + statusCode: 200, + ), + ); + + final result = await repository.list( + page: 3, + pageSize: 40, + query: 'gear', + vendorId: 10, + uomId: 5, + isActive: false, + ); + + expect(result.items, isNotEmpty); + + final verification = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ); + final query = verification.captured[1] as Map; + + expect(query['include'], 'vendor,uom'); + expect(query['vendor_id'], 10); + expect(query['uom_id'], 5); + expect(query['is_active'], false); + expect(query['page'], 3); + expect(query['page_size'], 40); + expect(query['q'], 'gear'); + }); +} diff --git a/test/features/masters/product/presentation/pages/product_page_test.dart b/test/features/masters/product/presentation/pages/product_page_test.dart index ec1f5ce..ba6a5b8 100644 --- a/test/features/masters/product/presentation/pages/product_page_test.dart +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -154,6 +154,33 @@ void main() { ).called(1); }); + testWidgets('목록이 비어 있으면 안내 문구를 표시한다', (tester) async { + when( + () => productRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + vendorId: any(named: 'vendorId'), + uomId: any(named: 'uomId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget( + _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + ); + await tester.pumpAndSettle(); + + expect(find.text('조건에 맞는 제품이 없습니다.'), findsOneWidget); + }); + testWidgets('폼 검증: 필수값 미입력 시 에러 메시지를 표시한다', (tester) async { when( () => productRepository.list( diff --git a/test/features/masters/user/data/user_repository_remote_test.dart b/test/features/masters/user/data/user_repository_remote_test.dart new file mode 100644 index 0000000..16a0488 --- /dev/null +++ b/test/features/masters/user/data/user_repository_remote_test.dart @@ -0,0 +1,65 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/features/masters/user/data/repositories/user_repository_remote.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late UserRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = UserRepositoryRemote(apiClient: apiClient); + }); + + test('목록 조회 시 include=group 파라미터를 전달한다', () async { + when( + () => apiClient.get>( + any(), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'items': [ + { + 'id': 1, + 'employee_no': 'E-001', + 'employee_name': '홍길동', + 'group': {'id': 2, 'group_name': '관리자'}, + }, + ], + }, + requestOptions: RequestOptions(path: '/api/v1/employees'), + statusCode: 200, + ), + ); + + await repository.list(); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + final path = captured[0] as String; + final query = captured[1] as Map; + + expect(path, equals('/api/v1/employees')); + expect(query['include'], 'group'); + }); +} diff --git a/test/features/masters/user/presentation/controllers/user_controller_test.dart b/test/features/masters/user/presentation/controllers/user_controller_test.dart index 07d27b5..eba28a1 100644 --- a/test/features/masters/user/presentation/controllers/user_controller_test.dart +++ b/test/features/masters/user/presentation/controllers/user_controller_test.dart @@ -2,8 +2,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; import 'package:superport_v2/features/masters/user/domain/entities/user.dart'; import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart'; import 'package:superport_v2/features/masters/user/presentation/controllers/user_controller.dart'; @@ -12,12 +15,17 @@ class _MockUserRepository extends Mock implements UserRepository {} class _MockGroupRepository extends Mock implements GroupRepository {} +class _MockGroupPermissionRepository extends Mock + implements GroupPermissionRepository {} + class _FakeUserInput extends Fake implements UserInput {} void main() { late UserController controller; late _MockUserRepository userRepository; late _MockGroupRepository groupRepository; + late _MockGroupPermissionRepository permissionRepository; + late PermissionManager permissionManager; final sampleUser = UserAccount( id: 1, @@ -44,9 +52,43 @@ void main() { setUp(() { userRepository = _MockUserRepository(); groupRepository = _MockGroupRepository(); + permissionRepository = _MockGroupPermissionRepository(); + permissionManager = PermissionManager(); + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'DASHBOARD', + menuName: '대시보드', + path: '/dashboard', + ), + canCreate: true, + canRead: true, + ), + ], + page: 1, + pageSize: 200, + total: 1, + ), + ); controller = UserController( userRepository: userRepository, groupRepository: groupRepository, + permissionRepository: permissionRepository, + permissionManager: permissionManager, ); }); @@ -58,6 +100,8 @@ void main() { query: any(named: 'query'), isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), + includePermissions: any(named: 'includePermissions'), + includeEmployees: any(named: 'includeEmployees'), ), ).thenAnswer( (_) async => PaginatedResult( @@ -82,6 +126,8 @@ void main() { query: any(named: 'query'), isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), + includePermissions: any(named: 'includePermissions'), + includeEmployees: any(named: 'includeEmployees'), ), ).thenAnswer( (_) async => PaginatedResult( @@ -195,6 +241,24 @@ void main() { verify(() => userRepository.delete(1)).called(1); }); + test('delete 이후 권한 동기화를 시도한다', () async { + when(() => userRepository.delete(any())).thenAnswer((_) async {}); + + await controller.fetch(); + await controller.delete(sampleUser.id!); + + verify( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: sampleUser.group!.id, + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).called(greaterThanOrEqualTo(1)); + }); + test('restore 성공', () async { when( () => userRepository.restore(any()), diff --git a/test/features/masters/user/presentation/pages/user_page_test.dart b/test/features/masters/user/presentation/pages/user_page_test.dart index 5d39fcd..53fc6ac 100644 --- a/test/features/masters/user/presentation/pages/user_page_test.dart +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -6,8 +6,11 @@ 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/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; import 'package:superport_v2/features/masters/user/domain/entities/user.dart'; import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart'; import 'package:superport_v2/features/masters/user/presentation/pages/user_page.dart'; @@ -16,16 +19,22 @@ class _MockUserRepository extends Mock implements UserRepository {} class _MockGroupRepository extends Mock implements GroupRepository {} +class _MockGroupPermissionRepository extends Mock + implements GroupPermissionRepository {} + class _FakeUserInput extends Fake implements UserInput {} Widget _buildApp(Widget child) { - return MaterialApp( - home: ShadTheme( - data: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, + return PermissionScope( + manager: PermissionManager(), + child: MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), ), - child: Scaffold(body: child), ), ); } @@ -55,12 +64,17 @@ void main() { group('플래그 On', () { late _MockUserRepository userRepository; late _MockGroupRepository groupRepository; + late _MockGroupPermissionRepository permissionRepository; setUp(() { dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=true\n'); userRepository = _MockUserRepository(); groupRepository = _MockGroupRepository(); + permissionRepository = _MockGroupPermissionRepository(); GetIt.I.registerLazySingleton(() => userRepository); GetIt.I.registerLazySingleton(() => groupRepository); + GetIt.I.registerLazySingleton( + () => permissionRepository, + ); when( () => groupRepository.list( @@ -69,6 +83,8 @@ void main() { query: any(named: 'query'), isDefault: any(named: 'isDefault'), isActive: any(named: 'isActive'), + includePermissions: any(named: 'includePermissions'), + includeEmployees: any(named: 'includeEmployees'), ), ).thenAnswer( (_) async => PaginatedResult( @@ -78,6 +94,37 @@ void main() { total: 1, ), ); + + when( + () => permissionRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuId: any(named: 'menuId'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'DASHBOARD', + menuName: '대시보드', + path: '/dashboard', + ), + canCreate: true, + canRead: true, + ), + ], + page: 1, + pageSize: 200, + total: 1, + ), + ); }); testWidgets('목록 조회 후 테이블 렌더', (tester) async { diff --git a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart index af5d647..c6b1def 100644 --- a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart +++ b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart @@ -84,6 +84,35 @@ void main() { ).called(1); }); + testWidgets('목록이 비어 있으면 안내 문구를 표시한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n'); + final repository = _MockVendorRepository(); + GetIt.I.registerLazySingleton(() => repository); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget( + _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + ); + await tester.pumpAndSettle(); + + expect(find.text('조건에 맞는 벤더가 없습니다.'), findsOneWidget); + }); + testWidgets('신규 등록 폼에서 필수값 미입력 시 검증 메시지를 보여준다', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n'); final repository = _MockVendorRepository(); diff --git a/test/features/masters/warehouse/data/warehouse_repository_remote_test.dart b/test/features/masters/warehouse/data/warehouse_repository_remote_test.dart new file mode 100644 index 0000000..ed2dcaf --- /dev/null +++ b/test/features/masters/warehouse/data/warehouse_repository_remote_test.dart @@ -0,0 +1,60 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late WarehouseRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = WarehouseRepositoryRemote(apiClient: apiClient); + }); + + test('include=zipcode 파라미터를 기본으로 전달한다', () async { + when( + () => apiClient.get>( + any(), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'items': [ + {'id': 1, 'warehouse_code': 'WH-001', 'warehouse_name': '1센터'}, + ], + }, + requestOptions: RequestOptions(path: '/api/v1/warehouses'), + statusCode: 200, + ), + ); + + await repository.list(); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + final path = captured[0] as String; + final query = captured[1] as Map; + + expect(path, equals('/api/v1/warehouses')); + expect(query['include'], 'zipcode'); + }); +} diff --git a/test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart b/test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart index eadbef7..4fd9a6c 100644 --- a/test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart +++ b/test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart @@ -48,6 +48,7 @@ void main() { pageSize: any(named: 'pageSize'), query: any(named: 'query'), isActive: any(named: 'isActive'), + includeZipcode: any(named: 'includeZipcode'), ), ).thenAnswer((_) async => createResult()); @@ -55,8 +56,13 @@ void main() { expect(controller.result?.items, isNotEmpty); verify( - () => - repository.list(page: 1, pageSize: 20, query: null, isActive: null), + () => repository.list( + page: 1, + pageSize: 20, + query: null, + isActive: null, + includeZipcode: true, + ), ).called(1); }); @@ -67,6 +73,7 @@ void main() { pageSize: any(named: 'pageSize'), query: any(named: 'query'), isActive: any(named: 'isActive'), + includeZipcode: any(named: 'includeZipcode'), ), ).thenThrow(Exception('fail')); @@ -92,6 +99,7 @@ void main() { pageSize: any(named: 'pageSize'), query: any(named: 'query'), isActive: any(named: 'isActive'), + includeZipcode: any(named: 'includeZipcode'), ), ).thenAnswer((_) async => createResult()); }); diff --git a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart index 281f31a..0a6d2f2 100644 --- a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart +++ b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart @@ -92,6 +92,7 @@ void main() { pageSize: any(named: 'pageSize'), query: any(named: 'query'), isActive: any(named: 'isActive'), + includeZipcode: any(named: 'includeZipcode'), ), ).thenAnswer( (_) async => PaginatedResult( @@ -116,8 +117,13 @@ void main() { expect(find.text('WH-001'), findsOneWidget); verify( - () => - repository.list(page: 1, pageSize: 20, query: null, isActive: null), + () => repository.list( + page: 1, + pageSize: 20, + query: null, + isActive: null, + includeZipcode: true, + ), ).called(1); }); @@ -128,6 +134,7 @@ void main() { pageSize: any(named: 'pageSize'), query: any(named: 'query'), isActive: any(named: 'isActive'), + includeZipcode: any(named: 'includeZipcode'), ), ).thenAnswer( (_) async => PaginatedResult( diff --git a/test/features/util/postal_search/data/postal_search_repository_remote_test.dart b/test/features/util/postal_search/data/postal_search_repository_remote_test.dart new file mode 100644 index 0000000..02bee58 --- /dev/null +++ b/test/features/util/postal_search/data/postal_search_repository_remote_test.dart @@ -0,0 +1,77 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; +import 'package:superport_v2/features/util/postal_search/data/repositories/postal_search_repository_remote.dart'; + +class _MockApiClient extends Mock implements ApiClient {} + +void main() { + late ApiClient apiClient; + late PostalSearchRepositoryRemote repository; + + setUpAll(() { + registerFallbackValue(Options()); + registerFallbackValue(CancelToken()); + }); + + setUp(() { + apiClient = _MockApiClient(); + repository = PostalSearchRepositoryRemote(apiClient: apiClient); + }); + + test('검색 키워드가 비어 있으면 빈 배열을 반환한다', () async { + final result = await repository.search(keyword: ' '); + expect(result, isEmpty); + verifyNever(() => apiClient.get(any())); + }); + + test('검색 요청 시 q/page/page_size 파라미터를 전달한다', () async { + when( + () => apiClient.get( + any(), + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response( + data: { + 'items': [ + { + 'zipcode': '06000', + 'sido': '서울특별시', + 'sigungu': '강남구', + 'road_name': '테헤란로', + }, + ], + }, + requestOptions: RequestOptions(path: '/api/v1/zipcodes'), + statusCode: 200, + ), + ); + + final result = await repository.search(keyword: '테헤란로', limit: 10, page: 2); + + expect(result, hasLength(1)); + + final verification = verify( + () => apiClient.get( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ); + final path = verification.captured[0] as String; + final query = verification.captured[1] as Map; + + expect(path, equals('/api/v1/zipcodes')); + expect(query['q'], '테헤란로'); + expect(query['page'], 2); + expect(query['page_size'], 10); + expect(query.containsKey('zipcode'), isFalse); + expect(query.containsKey('road_name'), isFalse); + }); +} diff --git a/test/features/util/postal_search/presentation/widgets/postal_search_dialog_test.dart b/test/features/util/postal_search/presentation/widgets/postal_search_dialog_test.dart index ad0b543..748e1a5 100644 --- a/test/features/util/postal_search/presentation/widgets/postal_search_dialog_test.dart +++ b/test/features/util/postal_search/presentation/widgets/postal_search_dialog_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/network/api_error.dart'; +import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; @@ -151,4 +153,32 @@ void main() { await tester.tap(find.text('닫기')); await tester.pumpAndSettle(); }); + + testWidgets('검색 실패 시 Failure 메시지를 표시한다', (tester) async { + final exception = ApiException( + code: ApiErrorCode.unprocessableEntity, + message: '우편번호 검색에 실패했습니다.', + details: { + 'errors': { + 'keyword': ['검색어를 다시 확인해 주세요.'], + }, + }, + ); + + await tester.pumpWidget( + _PostalSearchHarness( + fetcher: (_) => Future>.error(exception), + ), + ); + + await tester.pumpAndSettle(); + + final inputFinder = find.byType(EditableText); + await tester.enterText(inputFinder, '강남대로'); + await tester.tap(find.text('검색')); + await tester.pumpAndSettle(); + + final failure = Failure.from(exception); + expect(find.text(failure.describe()), findsOneWidget); + }); } diff --git a/test/navigation/navigation_flow_test.dart b/test/navigation/navigation_flow_test.dart index fac919d..c3e0154 100644 --- a/test/navigation/navigation_flow_test.dart +++ b/test/navigation/navigation_flow_test.dart @@ -1,12 +1,18 @@ 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: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/features/login/presentation/pages/login_page.dart'; -import 'package:superport_v2/core/theme/superport_shad_theme.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/theme/superport_shad_theme.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'; +import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart'; +import 'package:superport_v2/features/masters/group_permission/domain/repositories/group_permission_repository.dart'; GoRouter _createTestRouter() { return GoRouter( @@ -114,6 +120,108 @@ class _PlaceholderPage extends StatelessWidget { } } +class _StubGroupRepository implements GroupRepository { + @override + Future create(GroupInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isDefault, + bool? isActive, + bool includePermissions = false, + bool includeEmployees = false, + }) async { + return PaginatedResult( + items: [ + Group( + id: 1, + groupName: '기본 그룹', + description: '테스트', + isDefault: true, + isActive: true, + ), + ], + page: 1, + pageSize: 1, + total: 1, + ); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future update(int id, GroupInput input) { + throw UnimplementedError(); + } +} + +class _StubGroupPermissionRepository implements GroupPermissionRepository { + @override + Future create(GroupPermissionInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future> list({ + int page = 1, + int pageSize = 20, + int? groupId, + int? menuId, + bool? isActive, + bool includeDeleted = false, + }) async { + return PaginatedResult( + items: [ + GroupPermission( + id: 1, + group: GroupPermissionGroup(id: groupId ?? 1, groupName: '기본 그룹'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'DASHBOARD', + menuName: '대시보드', + path: dashboardRoutePath, + ), + canCreate: true, + canRead: true, + canUpdate: true, + canDelete: true, + ), + ], + page: 1, + pageSize: 1, + total: 1, + ); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future update(int id, GroupPermissionInput input) { + throw UnimplementedError(); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -132,6 +240,13 @@ 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)); await tester.pumpAndSettle();