From 753f76e952bf8532c4356bff1ca364b988a19cd0 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 12 Nov 2025 18:29:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(menu-permissions):=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EA=B6=8C=ED=95=9C=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .env.development.example과 lib/core/config/environment.dart, lib/core/permissions/permission_manager.dart에서 PERMISSION__ 폴백을 view 전용으로 좁히고 기본 정책을 명시적으로 거부하도록 재정비했다 - lib/core/navigation/*, lib/core/routing/app_router.dart, lib/widgets/app_shell.dart, lib/main.dart에서 메뉴 매니페스트·카탈로그를 도입해 /menus 응답을 캐싱하고 라우터·사이드바·Breadcrumb가 동일 menu_code/route_path를 쓰도록 리팩터링했다 - lib/core/permissions/permission_resources.dart와 그룹 권한/메뉴 마스터 모듈을 menu_code 기반 CRUD 및 Catalog 경로 정합성 검사로 전환하고 PermissionSynchronizer·PermissionBootstrapper를 확장했다 - test/helpers/test_permissions.dart, test/widgets/app_shell_test.dart 등 신규 구조를 반영하는 테스트·골든과 doc/frontend_menu_permission_tasks.md 문서를 보강했다 --- .env.development.example | 22 ++ doc/frontend_menu_permission_tasks.md | 69 ++++ lib/core/config/environment.dart | 17 +- lib/core/constants/app_sections.dart | 196 ---------- lib/core/navigation/menu_catalog.dart | 130 +++++++ .../navigation/menu_route_definitions.dart | 263 +++++++++++++ lib/core/navigation/route_paths.dart | 24 ++ .../permissions/permission_bootstrapper.dart | 8 +- lib/core/permissions/permission_manager.dart | 8 +- .../permissions/permission_resources.dart | 14 + lib/core/routing/app_router.dart | 155 +------- lib/core/routing/auth_guard.dart | 37 +- .../pages/approval_history_page.dart | 2 +- .../presentation/pages/approval_page.dart | 2 +- .../pages/approval_step_page.dart | 2 +- .../pages/approval_template_page.dart | 2 +- .../presentation/pages/inbound_page.dart | 4 +- .../presentation/pages/outbound_page.dart | 4 +- .../presentation/pages/rental_page.dart | 4 +- .../pages/inventory_summary_page.dart | 20 +- .../login/presentation/pages/login_page.dart | 4 +- .../presentation/pages/customer_page.dart | 7 +- .../controllers/group_controller.dart | 8 +- .../group/presentation/pages/group_page.dart | 7 +- .../application/permission_synchronizer.dart | 91 ++++- .../group_permission_repository_remote.dart | 6 +- .../domain/entities/group_permission.dart | 7 +- .../group_permission_repository.dart | 2 +- .../group_permission_controller.dart | 73 +++- .../group_permission_detail_dialog.dart | 21 +- .../pages/group_permission_page.dart | 34 +- .../masters/menu/data/dtos/menu_dto.dart | 18 +- .../masters/menu/domain/entities/menu.dart | 9 +- .../controllers/menu_controller.dart | 48 ++- .../menu/presentation/pages/menu_page.dart | 27 +- .../presentation/pages/product_page.dart | 7 +- .../controllers/user_controller.dart | 7 +- .../user/presentation/pages/user_page.dart | 10 +- .../presentation/pages/vendor_page.dart | 7 +- .../presentation/pages/warehouse_page.dart | 7 +- .../presentation/pages/reporting_page.dart | 2 +- .../pages/postal_search_page.dart | 7 +- lib/main.dart | 69 +++- lib/widgets/app_shell.dart | 347 ++++++++++++++---- .../permissions/permission_manager_test.dart | 28 +- .../permission_resources_test.dart | 19 + .../features/inventory/inbound_page_test.dart | 40 +- .../inventory/inventory_pages_smoke_test.dart | 19 +- .../inventory/outbound_page_test.dart | 22 +- test/features/inventory/rental_page_test.dart | 9 +- .../inventory_summary_detail_sheet.png | Bin 29554 -> 24082 bytes .../inventory_summary_page_default.png | Bin 29554 -> 24082 bytes .../inventory_summary_page_golden_test.dart | 7 +- .../pages/inventory_summary_page_test.dart | 26 +- .../presentation/pages/login_page_test.dart | 27 +- .../pages/customer_page_test.dart | 13 +- .../controllers/group_controller_test.dart | 4 +- .../permission_synchronizer_test.dart | 173 ++++++++- .../domain/group_permission_mapper_test.dart | 9 +- .../group_permission_controller_test.dart | 22 +- .../pages/group_permission_page_test.dart | 33 +- .../presentation/pages/menu_page_test.dart | 19 +- .../presentation/pages/product_page_test.dart | 13 +- .../controllers/user_controller_test.dart | 4 +- .../presentation/pages/user_page_test.dart | 20 +- .../presentation/pages/vendor_page_test.dart | 13 +- .../pages/warehouse_page_test.dart | 11 +- test/helpers/inventory_test_stubs.dart | 3 + test/helpers/test_app.dart | 117 +++++- test/helpers/test_permissions.dart | 29 ++ test/navigation/navigation_flow_test.dart | 38 +- test/widgets/app_shell_test.dart | 92 ++++- 72 files changed, 1914 insertions(+), 704 deletions(-) create mode 100644 doc/frontend_menu_permission_tasks.md delete mode 100644 lib/core/constants/app_sections.dart create mode 100644 lib/core/navigation/menu_catalog.dart create mode 100644 lib/core/navigation/menu_route_definitions.dart create mode 100644 lib/core/navigation/route_paths.dart create mode 100644 test/helpers/test_permissions.dart diff --git a/.env.development.example b/.env.development.example index ae123c0..5bf441b 100644 --- a/.env.development.example +++ b/.env.development.example @@ -17,3 +17,25 @@ FEATURE_APPROVAL_FLOW_V2=false FEATURE_ZIPCODE_SEARCH_ENABLED=false # 재고 상태 전이 버튼 제어 (운영 기본값 false) FEATURE_STOCK_TRANSITIONS_ENABLED=true + +# 개발 기본 권한 (view 전용) +PERMISSION__/dashboard=view +PERMISSION__/inventory/summary=view +PERMISSION__/stock-transactions=view +PERMISSION__/vendors=view +PERMISSION__/products=view +PERMISSION__/warehouses=view +PERMISSION__/customers=view +PERMISSION__/users=view +PERMISSION__/groups=view +PERMISSION__/menus=view +PERMISSION__/group-menu-permissions=view +PERMISSION__/approvals=view +PERMISSION__/approval-steps=view +PERMISSION__/approval-histories=view +PERMISSION__/approval/templates=view +PERMISSION__/reports=view +PERMISSION__/reports/transactions=view +PERMISSION__/reports/approvals=view +PERMISSION__/zipcodes=view +PERMISSION__scope:inventory.view=view diff --git a/doc/frontend_menu_permission_tasks.md b/doc/frontend_menu_permission_tasks.md new file mode 100644 index 0000000..b2d5cb0 --- /dev/null +++ b/doc/frontend_menu_permission_tasks.md @@ -0,0 +1,69 @@ +# 프런트엔드 메뉴 권한 정합성 작업 리스트 + +백엔드가 menus/group_menu_permissions 구조를 정리하는 동안 프런트엔드에서 병행해야 하는 상세 작업을 아래와 같이 정리한다. 모든 작업은 `AGENTS.md` 가이드(주석 한국어, Clean Architecture, 테스트 필수)에 따라 진행하며, 완료 후 `flutter analyze`, `flutter test`를 실행한다. + +## 1. 권한 리소스/폴백 정비 + +1. **`PermissionResources` 별칭 테이블 보강** + - 파일: `lib/core/permissions/permission_resources.dart` + - 액션: `/inventory/receipts|issues|rentals`, `/inventory/vendors|products|warehouses|customers`, `/settings/*` 등 백엔드 `menus.route_path` 전부를 `_aliases`에 1:1 매핑. + - 기대효과: 서버가 내려주는 menu_code/route_path와 사이드바 권한 체크가 정확히 연결. + +2. **`PermissionManager` 기본 정책 수정** + - 파일: `lib/core/permissions/permission_manager.dart`, `.env.development`, `.env.example` + - 액션: + - 서버/오버라이드 권한이 없으면 `Environment.hasPermission` 결과를 사용하기 전에 기본 거부하도록 방어 로직 추가. + - 개발용 `.env`에 필요한 최소 권한만 `PERMISSION__/path=view` 형태로 명시해 무제한 허용을 차단. + - 기대효과: 권한 정보 미수신 시 UI가 안전하게 차단되고, 테스트/QA에서 화이트리스트가 그대로 재현. + +## 2. 메뉴 데이터 소스 일원화 + +3. **메뉴 라우트/사이드바 리팩터링** + - 파일: `lib/widgets/app_shell.dart`, `lib/core/routing/app_router.dart`, `lib/core/navigation/menu_route_definitions.dart`, 신규 헬퍼 모듈. + - 액션: + - `appSections` 상수를 폐지하고, `GET /menus` 결과(menu_code, parent_code, display_order, route_path)를 캐시해 사이드바를 구성. + - `menu_code → 위젯 builder` 매핑 테이블을 별도 파일로 만들고 GoRouter 정의에서 사용. + - 사이드바, 권한 드롭다운, 라우터가 모두 동일 menu_code/route_path를 참조하도록 구조화. + - 기대효과: menus 테이블 변경이 자동으로 UI에 반영되고 경로 불일치 문제 제거. + +4. **MenuCatalog(캐시) 도입 및 의존성 주입** + - 파일: `lib/main.dart`, `lib/core/navigation/menu_catalog.dart`(신규), `lib/features/masters/menu/*` + - 액션: + - 앱 시작 시 메뉴 목록을 한 번만 불러 캐시하는 Catalog를 만들고 `PermissionScope` 옆에서 주입. + - 메뉴 관리 화면/권한 드롭다운이 Catalog 데이터를 재사용하도록 controller 로직 정리. + +## 3. 그룹 권한 UI 연동 + +5. **메뉴 선택 로직을 menu_code 기반으로 변경** + - 파일: `lib/features/masters/group_permission/presentation/...` + - 액션: + - 컨트롤러와 다이얼로그가 `menu_id` 대신 `menu_code`를 사용해 CRUD 요청을 보냄. + - 메뉴 드롭다운 옵션을 Catalog(or `/menus`)에서 가져와 라벨/정렬 통일. + +6. **권한 동기화 시 menu_code 매칭 검증** + - 파일: `lib/features/masters/group_permission/application/permission_synchronizer.dart` + - 액션: `MenuRouteDefinition` 또는 Catalog를 참조해 `PermissionManager`에 적용되는 리소스 키가 서버 route_path와 일치하는지 확인하는 테스트 추가. + +## 4. 테스트 & CI + +7. **사이드바 권한 테스트 보강** + - 파일: `test/widgets/app_shell_test.dart`, `test/navigation/navigation_flow_test.dart` + - 액션: + - 서버에서 받은 `menu_code`/`route_path`에 따라 노출되는 메뉴가 달라지는 위젯 테스트 작성. + - 개발 env 권한 없이도 테스트가 실패하지 않도록 PermissionManager 오버라이드 유틸 추가. + +8. **CI 스크립트 추가** + - 파일: `tool/check_menu_manifest.dart`(신규), `ci/scripts/*` + - 액션: menus 테이블 표(`doc/stock_approval_system_spec_v4.md`), 라우트 정의, 앱 메뉴 캐시 간 코드/경로 일관성을 검사하는 Dart 스크립트를 추가하고 CI 단계에서 실행. + +## 5. 문서/QA + +9. **문서 업데이트** + - 파일: `doc/frontend_backend_alignment_report.md`, 신규 QA 체크리스트. + - 액션: “사이드바=menus 테이블 1:1” 규칙, 권한 폴백 정책, 테스트 플로우를 문서화. + +10. **QA 시나리오** + - 항목: 화이트리스트 그룹별 계정으로 로그인 → 사이드바 캡처 → 권한 드롭다운 메뉴 제한 확인 → 메뉴 관리에서 route_path 일치 여부 확인. + - 산출물: `doc/qa/menu_permission_alignment_checklist.md` 등으로 공유. + +각 작업은 백로그 티켓으로 분할해 순차 진행하며, 메뉴 API 변경 시점과 맞춰 릴리즈 플래그로 토글할 수 있도록 구성한다. diff --git a/lib/core/config/environment.dart b/lib/core/config/environment.dart index c07bb75..562b8ef 100644 --- a/lib/core/config/environment.dart +++ b/lib/core/config/environment.dart @@ -106,7 +106,7 @@ class Environment { static bool hasPermission(String resource, String action) { final actions = _permissions[resource.toLowerCase()]; if (actions == null || actions.isEmpty) { - return true; + return false; } if (actions.contains('all')) { // all 키워드는 모든 액션 허용을 의미한다. @@ -114,4 +114,19 @@ class Environment { } return actions.contains(action.toLowerCase()); } + + /// 테스트에서 환경 권한 맵을 직접 오버라이드하기 위한 헬퍼. + @visibleForTesting + static void setTestPermissions(Map> permissions) { + _permissions + ..clear() + ..addAll( + permissions.map( + (key, value) => MapEntry( + key.toLowerCase(), + value.map((action) => action.toLowerCase()).toSet(), + ), + ), + ); + } } diff --git a/lib/core/constants/app_sections.dart b/lib/core/constants/app_sections.dart deleted file mode 100644 index 9428f03..0000000 --- a/lib/core/constants/app_sections.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; - -import '../permissions/permission_resources.dart'; - -/// 사이드바/내비게이션용 페이지 정보. -class AppPageDescriptor { - const AppPageDescriptor({ - required this.path, - required this.label, - required this.icon, - required this.summary, - this.extraRequiredResources = const [], - }); - - final String path; - final String label; - final IconData icon; - final String summary; - final List extraRequiredResources; -} - -/// 메뉴 섹션을 나타내는 데이터 클래스. -class AppSectionDescriptor { - const AppSectionDescriptor({required this.label, required this.pages}); - - final String label; - final List pages; -} - -/// 로그인 라우트 경로. -const loginRoutePath = '/login'; - -/// 대시보드 라우트 경로. -const dashboardRoutePath = '/dashboard'; - -/// 재고 현황 라우트 경로. -const inventorySummaryRoutePath = '/inventory/summary'; - -/// 네비게이션 구성을 정의한 섹션 목록. -const appSections = [ - AppSectionDescriptor( - label: '대시보드', - pages: [ - AppPageDescriptor( - path: dashboardRoutePath, - label: '대시보드', - icon: lucide.LucideIcons.layoutDashboard, - summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.', - ), - ], - ), - AppSectionDescriptor( - label: '재고', - pages: [ - AppPageDescriptor( - path: inventorySummaryRoutePath, - label: '재고 현황', - icon: lucide.LucideIcons.chartNoAxesColumnIncreasing, - summary: '제품별 총 재고, 창고 잔량, 최근 이벤트를 한 화면에서 확인합니다.', - extraRequiredResources: [PermissionResources.inventoryScope], - ), - ], - ), - AppSectionDescriptor( - label: '입·출고', - pages: [ - AppPageDescriptor( - path: '/inventory/inbound', - label: '입고', - icon: lucide.LucideIcons.packagePlus, - summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.', - ), - AppPageDescriptor( - path: '/inventory/outbound', - label: '출고', - icon: lucide.LucideIcons.packageMinus, - summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.', - ), - AppPageDescriptor( - path: '/inventory/rental', - label: '대여', - icon: lucide.LucideIcons.handshake, - summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.', - ), - ], - ), - AppSectionDescriptor( - label: '마스터', - pages: [ - AppPageDescriptor( - path: '/masters/vendors', - label: '제조사 관리', - icon: lucide.LucideIcons.factory, - summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.', - ), - AppPageDescriptor( - path: '/masters/products', - label: '장비 모델 관리', - icon: lucide.LucideIcons.box, - summary: '제품코드, 제조사, 단위 정보를 관리합니다.', - ), - AppPageDescriptor( - path: '/masters/warehouses', - label: '입고지 관리', - icon: lucide.LucideIcons.warehouse, - summary: '창고 주소와 사용여부를 설정합니다.', - ), - AppPageDescriptor( - path: '/masters/customers', - label: '회사 관리', - icon: lucide.LucideIcons.building, - summary: '고객사 연락처와 주소 정보를 관리합니다.', - ), - AppPageDescriptor( - path: '/masters/users', - label: '사용자 관리', - icon: lucide.LucideIcons.users, - summary: '사번, 그룹, 사용여부를 관리합니다.', - ), - AppPageDescriptor( - path: '/masters/groups', - label: '그룹 관리', - icon: lucide.LucideIcons.layers, - summary: '권한 그룹과 설명, 기본여부를 정의합니다.', - ), - AppPageDescriptor( - path: '/masters/menus', - label: '메뉴 관리', - icon: lucide.LucideIcons.listTree, - summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.', - ), - AppPageDescriptor( - path: '/masters/group-permissions', - label: '그룹 메뉴 권한', - icon: lucide.LucideIcons.shieldCheck, - summary: '그룹별 메뉴 CRUD 권한을 설정합니다.', - ), - ], - ), - AppSectionDescriptor( - label: '결재', - pages: [ - AppPageDescriptor( - path: '/approvals/requests', - label: '결재 관리', - icon: lucide.LucideIcons.fileCheck, - summary: '결재 번호, 상태, 상신자를 관리합니다.', - ), - AppPageDescriptor( - path: '/approvals/steps', - label: '결재 단계', - icon: lucide.LucideIcons.workflow, - summary: '단계 순서와 승인자 할당을 설정합니다.', - ), - AppPageDescriptor( - path: '/approvals/history', - label: '결재 이력', - icon: lucide.LucideIcons.history, - summary: '결재 단계별 변경 이력을 조회합니다.', - ), - AppPageDescriptor( - path: '/approvals/templates', - label: '결재 템플릿', - icon: lucide.LucideIcons.fileSpreadsheet, - summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.', - ), - ], - ), - AppSectionDescriptor( - label: '도구', - pages: [ - AppPageDescriptor( - path: '/utilities/postal-search', - label: '우편번호 검색', - icon: lucide.LucideIcons.search, - summary: '모달 기반 우편번호 검색 도구입니다.', - ), - ], - ), - AppSectionDescriptor( - label: '보고', - pages: [ - AppPageDescriptor( - path: '/reports', - label: '보고서', - icon: lucide.LucideIcons.fileDown, - summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.', - ), - ], - ), -]; - -List get allAppPages => [ - for (final section in appSections) ...section.pages, -]; diff --git a/lib/core/navigation/menu_catalog.dart b/lib/core/navigation/menu_catalog.dart new file mode 100644 index 0000000..f46b301 --- /dev/null +++ b/lib/core/navigation/menu_catalog.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../common/utils/pagination_utils.dart'; +import '../network/failure.dart'; +import '../../features/masters/menu/domain/entities/menu.dart'; +import '../../features/masters/menu/domain/repositories/menu_repository.dart'; + +/// 메뉴 목록을 한 번만 로드해 전역에서 재사용하도록 제공하는 카탈로그. +/// +/// - 메뉴 테이블 전체를 페이지네이션 API로 받아 캐시에 저장한다. +/// - AppShell/권한 설정 화면 등에서 공통 데이터를 구독해 재렌더링 비용을 줄인다. +class MenuCatalog extends ChangeNotifier { + MenuCatalog({required MenuRepository repository}) : _repository = repository; + + final MenuRepository _repository; + + List _menus = const []; + bool _isLoading = false; + bool _initialized = false; + String? _errorMessage; + Completer? _pendingLoad; + + /// 최신 메뉴 목록. + List get menus => _menus; + + /// 현재 로딩 상태. + bool get isLoading => _isLoading; + + /// 최초 로드 여부. + bool get isInitialized => _initialized; + + /// 마지막 로드 실패 메시지. + String? get errorMessage => _errorMessage; + + /// 캐시된 메뉴를 반환하며 필요 시 자동으로 로딩한다. + Future> ensureLoaded({bool forceRefresh = false}) async { + if (_initialized && !forceRefresh) { + return _menus; + } + await refresh(); + return _menus; + } + + /// 백엔드에서 메뉴를 다시 불러와 캐시를 갱신한다. + Future refresh() async { + if (_pendingLoad != null) { + await _pendingLoad!.future; + if (_errorMessage != null) { + throw StateError(_errorMessage!); + } + return; + } + + final completer = Completer(); + _pendingLoad = completer; + _isLoading = true; + notifyListeners(); + + try { + final menus = await fetchAllPaginatedItems( + request: (page, pageSize) => _repository.list( + page: page, + pageSize: pageSize, + includeDeleted: false, + ), + ); + _menus = List.unmodifiable(menus); + _initialized = true; + _errorMessage = null; + completer.complete(); + } catch (error, stackTrace) { + final failure = Failure.from(error); + _errorMessage = failure.describe(); + completer.completeError(error, stackTrace); + rethrow; + } finally { + _isLoading = false; + _pendingLoad = null; + notifyListeners(); + } + } + + /// 외부에서 최신 메뉴 셋을 주입해 테스트/동기화를 돕는다. + void replaceAll(List menus) { + _menus = List.unmodifiable(menus); + _initialized = true; + _errorMessage = null; + notifyListeners(); + } + + /// 특정 코드에 해당하는 메뉴를 조회한다. + MenuItem? findByCode(String menuCode) { + for (final menu in _menus) { + if (menu.menuCode == menuCode) { + return menu; + } + } + return null; + } + + /// 캐시를 초기 상태로 되돌린다. + void reset() { + _menus = const []; + _initialized = false; + _errorMessage = null; + notifyListeners(); + } +} + +/// [MenuCatalog]를 위젯 트리에 노출하는 Inherited 래퍼. +class MenuCatalogScope extends InheritedNotifier { + const MenuCatalogScope({ + super.key, + required MenuCatalog catalog, + required super.child, + }) : super(notifier: catalog); + + /// 현재 컨텍스트에서 [MenuCatalog]를 조회한다. + static MenuCatalog of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + assert( + scope != null, + 'MenuCatalogScope.of() called with no MenuCatalogScope ancestor.', + ); + return scope!.notifier!; + } +} diff --git a/lib/core/navigation/menu_route_definitions.dart b/lib/core/navigation/menu_route_definitions.dart new file mode 100644 index 0000000..2d3d6d0 --- /dev/null +++ b/lib/core/navigation/menu_route_definitions.dart @@ -0,0 +1,263 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; + +import '../../features/approvals/history/presentation/pages/approval_history_page.dart'; +import '../../features/approvals/request/presentation/pages/approval_request_page.dart'; +import '../../features/approvals/step/presentation/pages/approval_step_page.dart'; +import '../../features/approvals/template/presentation/pages/approval_template_page.dart'; +import '../../features/dashboard/presentation/pages/dashboard_page.dart'; +import '../../features/inventory/inbound/presentation/pages/inbound_page.dart'; +import '../../features/inventory/outbound/presentation/pages/outbound_page.dart'; +import '../../features/inventory/rental/presentation/pages/rental_page.dart'; +import '../../features/inventory/summary/presentation/pages/inventory_summary_page.dart'; +import '../../features/masters/customer/presentation/pages/customer_page.dart'; +import '../../features/masters/group/presentation/pages/group_page.dart'; +import '../../features/masters/group_permission/presentation/pages/group_permission_page.dart'; +import '../../features/masters/menu/presentation/pages/menu_page.dart'; +import '../../features/masters/product/presentation/pages/product_page.dart'; +import '../../features/masters/user/presentation/pages/user_page.dart'; +import '../../features/masters/vendor/presentation/pages/vendor_page.dart'; +import '../../features/masters/warehouse/presentation/pages/warehouse_page.dart'; +import '../../features/reporting/presentation/pages/reporting_page.dart'; +import '../../features/util/postal_search/presentation/pages/postal_search_page.dart'; +import '../permissions/permission_manager.dart'; +import '../permissions/permission_resources.dart'; +import '../routing/auth_guard.dart'; +import 'route_paths.dart'; + +typedef MenuPageBuilder = Widget Function(BuildContext context, GoRouterState state); + +/// 메뉴 코드 ↔ 라우트 정의를 연결하는 매니페스트. +class MenuRouteDefinition { + const MenuRouteDefinition({ + required this.menuCode, + this.aliases = const {}, + required this.routePath, + required this.defaultLabel, + required this.icon, + required this.builder, + this.defaultOrder = 0, + this.extraRequirements = const [], + this.showInNavigation = true, + }); + + final String menuCode; + final Set aliases; + final String routePath; + final String defaultLabel; + final IconData icon; + final MenuPageBuilder builder; + final int defaultOrder; + final List extraRequirements; + final bool showInNavigation; + + Iterable get codes sync* { + yield menuCode; + for (final alias in aliases) { + yield alias; + } + } + + Iterable get requirements sync* { + yield PermissionRequirement(resource: routePath); + for (final requirement in extraRequirements) { + yield requirement; + } + } + + bool canAccess(PermissionManager manager) { + for (final requirement in requirements) { + if (!manager.can(requirement.resource, requirement.action)) { + return false; + } + } + return true; + } + + RouteGuard? buildGuard({String fallback = dashboardRoutePath}) { + final guards = requirements.toList(growable: false); + if (guards.isEmpty) { + return null; + } + return AuthGuard.requireAll(requirements: guards, fallback: fallback); + } +} + +final List menuRouteDefinitions = [ + MenuRouteDefinition( + menuCode: 'dashboard', + routePath: dashboardRoutePath, + defaultLabel: '대시보드', + icon: lucide.LucideIcons.layoutDashboard, + builder: (context, state) => const DashboardPage(), + defaultOrder: 10, + ), + MenuRouteDefinition( + menuCode: 'inventory.summary', + aliases: {'inventory'}, + routePath: inventorySummaryRoutePath, + defaultLabel: '재고 현황', + icon: lucide.LucideIcons.chartNoAxesColumnIncreasing, + builder: (context, state) => InventorySummaryPage(routeUri: state.uri), + defaultOrder: 20, + extraRequirements: const [ + PermissionRequirement(resource: PermissionResources.inventoryScope), + ], + ), + MenuRouteDefinition( + menuCode: 'inventory.receipts', + aliases: {'inventory.inbound'}, + routePath: inventoryReceiptsRoutePath, + defaultLabel: '입고', + icon: lucide.LucideIcons.packagePlus, + builder: (context, state) => InboundPage(routeUri: state.uri), + defaultOrder: 21, + ), + MenuRouteDefinition( + menuCode: 'inventory.issues', + aliases: {'inventory.outbound'}, + routePath: inventoryIssuesRoutePath, + defaultLabel: '출고', + icon: lucide.LucideIcons.packageMinus, + builder: (context, state) => OutboundPage(routeUri: state.uri), + defaultOrder: 22, + ), + MenuRouteDefinition( + menuCode: 'inventory.rentals', + routePath: inventoryRentalsRoutePath, + defaultLabel: '대여', + icon: lucide.LucideIcons.handshake, + builder: (context, state) => RentalPage(routeUri: state.uri), + defaultOrder: 23, + ), + MenuRouteDefinition( + menuCode: 'inventory.vendors', + aliases: {'inventory.manufacturers', 'masters.vendors'}, + routePath: inventoryVendorsRoutePath, + defaultLabel: '제조사 관리', + icon: lucide.LucideIcons.factory, + builder: (context, state) => VendorPage(routeUri: state.uri), + defaultOrder: 30, + ), + MenuRouteDefinition( + menuCode: 'inventory.products', + aliases: {'inventory.models', 'masters.products'}, + routePath: inventoryProductsRoutePath, + defaultLabel: '제품 관리', + icon: lucide.LucideIcons.box, + builder: (context, state) => ProductPage(routeUri: state.uri), + defaultOrder: 31, + ), + MenuRouteDefinition( + menuCode: 'inventory.warehouses', + aliases: {'masters.warehouses'}, + routePath: inventoryWarehousesRoutePath, + defaultLabel: '입고지 관리', + icon: lucide.LucideIcons.warehouse, + builder: (context, state) => WarehousePage(routeUri: state.uri), + defaultOrder: 32, + ), + MenuRouteDefinition( + menuCode: 'inventory.customers', + aliases: {'masters.customers'}, + routePath: inventoryCustomersRoutePath, + defaultLabel: '회사 관리', + icon: lucide.LucideIcons.building, + builder: (context, state) => CustomerPage(routeUri: state.uri), + defaultOrder: 33, + ), + MenuRouteDefinition( + menuCode: 'settings.users', + aliases: {'masters.users'}, + routePath: settingsUsersRoutePath, + defaultLabel: '사용자 관리', + icon: lucide.LucideIcons.users, + builder: (context, state) => const UserPage(), + defaultOrder: 40, + ), + MenuRouteDefinition( + menuCode: 'settings.groups', + aliases: {'masters.groups'}, + routePath: settingsGroupsRoutePath, + defaultLabel: '그룹 관리', + icon: lucide.LucideIcons.layers, + builder: (context, state) => const GroupPage(), + defaultOrder: 41, + ), + MenuRouteDefinition( + menuCode: 'settings.menus', + aliases: {'masters.menus'}, + routePath: settingsMenusRoutePath, + defaultLabel: '메뉴 관리', + icon: lucide.LucideIcons.listTree, + builder: (context, state) => const MenuPage(), + defaultOrder: 42, + ), + MenuRouteDefinition( + menuCode: 'settings.group_permissions', + aliases: {'settings.group-permissions', 'masters.group-permissions'}, + routePath: settingsGroupPermissionsRoutePath, + defaultLabel: '그룹 메뉴 권한', + icon: lucide.LucideIcons.shieldCheck, + builder: (context, state) => const GroupPermissionPage(), + defaultOrder: 43, + ), + MenuRouteDefinition( + menuCode: 'approvals.requests', + aliases: {'approvals'}, + routePath: approvalsRequestsRoutePath, + defaultLabel: '결재 관리', + icon: lucide.LucideIcons.fileCheck, + builder: (context, state) => ApprovalRequestPage(routeUri: state.uri), + defaultOrder: 50, + ), + MenuRouteDefinition( + menuCode: 'approvals.steps', + routePath: approvalsStepsRoutePath, + defaultLabel: '결재 단계', + icon: lucide.LucideIcons.workflow, + builder: (context, state) => const ApprovalStepPage(), + defaultOrder: 51, + ), + MenuRouteDefinition( + menuCode: 'approvals.history', + routePath: approvalsHistoryRoutePath, + defaultLabel: '결재 이력', + icon: lucide.LucideIcons.history, + builder: (context, state) => const ApprovalHistoryPage(), + defaultOrder: 52, + ), + MenuRouteDefinition( + menuCode: 'approvals.templates', + routePath: approvalsTemplatesRoutePath, + defaultLabel: '결재 템플릿', + icon: lucide.LucideIcons.fileSpreadsheet, + builder: (context, state) => const ApprovalTemplatePage(), + defaultOrder: 53, + ), + MenuRouteDefinition( + menuCode: 'utilities.zipcodes', + aliases: {'utilities.postal-search'}, + routePath: utilitiesPostalSearchRoutePath, + defaultLabel: '우편번호 검색', + icon: lucide.LucideIcons.search, + builder: (context, state) => const PostalSearchPage(), + defaultOrder: 60, + ), + MenuRouteDefinition( + menuCode: 'reports.overview', + aliases: {'reports'}, + routePath: reportsOverviewRoutePath, + defaultLabel: '보고서', + icon: lucide.LucideIcons.fileDown, + builder: (context, state) => const ReportingPage(), + defaultOrder: 70, + ), +]; + +/// menu_code → 정의를 빠르게 조회하기 위한 맵. +final Map menuRouteDefinitionByCode = { + for (final definition in menuRouteDefinitions) + for (final code in definition.codes) code: definition, +}; diff --git a/lib/core/navigation/route_paths.dart b/lib/core/navigation/route_paths.dart new file mode 100644 index 0000000..a338ae9 --- /dev/null +++ b/lib/core/navigation/route_paths.dart @@ -0,0 +1,24 @@ +/// 라우트 경로 상수를 한 곳에서 관리한다. +/// +/// - 메뉴/권한/딥링크에서 동일한 경로 문자열을 참조해 불일치를 줄인다. +/// - 로그인/대시보드 등 빈번히 참조되는 경로는 별도의 상수로 노출한다. +const loginRoutePath = '/login'; +const dashboardRoutePath = '/dashboard'; +const inventorySummaryRoutePath = '/inventory/summary'; +const inventoryReceiptsRoutePath = '/inventory/receipts'; +const inventoryIssuesRoutePath = '/inventory/issues'; +const inventoryRentalsRoutePath = '/inventory/rentals'; +const inventoryVendorsRoutePath = '/inventory/vendors'; +const inventoryProductsRoutePath = '/inventory/products'; +const inventoryWarehousesRoutePath = '/inventory/warehouses'; +const inventoryCustomersRoutePath = '/inventory/customers'; +const settingsUsersRoutePath = '/settings/users'; +const settingsGroupsRoutePath = '/settings/groups'; +const settingsMenusRoutePath = '/settings/menus'; +const settingsGroupPermissionsRoutePath = '/settings/group-permissions'; +const approvalsRequestsRoutePath = '/approvals/requests'; +const approvalsStepsRoutePath = '/approvals/steps'; +const approvalsHistoryRoutePath = '/approvals/history'; +const approvalsTemplatesRoutePath = '/approvals/templates'; +const utilitiesPostalSearchRoutePath = '/utilities/zipcodes'; +const reportsOverviewRoutePath = '/reports'; diff --git a/lib/core/permissions/permission_bootstrapper.dart b/lib/core/permissions/permission_bootstrapper.dart index 1700691..b1727c4 100644 --- a/lib/core/permissions/permission_bootstrapper.dart +++ b/lib/core/permissions/permission_bootstrapper.dart @@ -1,3 +1,4 @@ +import 'package:superport_v2/core/navigation/menu_catalog.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import '../../features/auth/domain/entities/auth_session.dart'; @@ -12,13 +13,16 @@ class PermissionBootstrapper { required PermissionManager manager, required GroupRepository groupRepository, required GroupPermissionRepository groupPermissionRepository, + MenuCatalog? menuCatalog, }) : _manager = manager, _groupRepository = groupRepository, - _groupPermissionRepository = groupPermissionRepository; + _groupPermissionRepository = groupPermissionRepository, + _menuCatalog = menuCatalog; final PermissionManager _manager; final GroupRepository _groupRepository; final GroupPermissionRepository _groupPermissionRepository; + final MenuCatalog? _menuCatalog; /// 세션의 권한 목록과 그룹 권한을 적용한다. Future apply(AuthSession session) async { @@ -71,6 +75,7 @@ class PermissionBootstrapper { final synchronizer = PermissionSynchronizer( repository: _groupPermissionRepository, manager: _manager, + menuCatalog: _menuCatalog, ); await synchronizer.syncForGroup(targetGroupId); } @@ -85,6 +90,7 @@ class PermissionBootstrapper { final synchronizer = PermissionSynchronizer( repository: _groupPermissionRepository, manager: _manager, + menuCatalog: _menuCatalog, ); return synchronizer.fetchPermissionMap(targetGroupId); } diff --git a/lib/core/permissions/permission_manager.dart b/lib/core/permissions/permission_manager.dart index a2d1b1a..2df5857 100644 --- a/lib/core/permissions/permission_manager.dart +++ b/lib/core/permissions/permission_manager.dart @@ -45,7 +45,13 @@ class PermissionManager extends ChangeNotifier { return false; } - return Environment.hasPermission(key, action.name); + // 서버/오버라이드 권한이 없으면 기본적으로 거부하고, + // .env에 명시된 PERMISSION__ 항목만 허용한다. + final fallbackAllowed = Environment.hasPermission(key, action.name); + if (!fallbackAllowed) { + return false; + } + return true; } /// 개발/테스트 환경에서 사용할 임시 오버라이드 값을 설정한다. diff --git a/lib/core/permissions/permission_resources.dart b/lib/core/permissions/permission_resources.dart index 6a5a2f5..eaf671b 100644 --- a/lib/core/permissions/permission_resources.dart +++ b/lib/core/permissions/permission_resources.dart @@ -33,6 +33,9 @@ class PermissionResources { '/inventory/inbound': stockTransactions, '/inventory/outbound': stockTransactions, '/inventory/rental': stockTransactions, + '/inventory/rentals': stockTransactions, + '/inventory/receipts': stockTransactions, + '/inventory/issues': stockTransactions, '/approvals/requests': approvals, '/approvals': approvals, '/approvals/steps': approvalSteps, @@ -45,22 +48,33 @@ class PermissionResources { '/approval-templates': approvalTemplates, '/inventory/summary': inventorySummary, '/masters/group-permissions': groupMenuPermissions, + '/settings/group-permissions': groupMenuPermissions, '/group-menu-permissions': groupMenuPermissions, '/masters/vendors': vendors, + '/inventory/vendors': vendors, + '/inventory/manufacturers': vendors, '/vendors': vendors, '/masters/products': products, + '/inventory/products': products, + '/inventory/models': products, '/products': products, '/masters/warehouses': warehouses, + '/inventory/warehouses': warehouses, '/warehouses': warehouses, '/masters/customers': customers, + '/inventory/customers': customers, '/customers': customers, '/masters/users': users, + '/settings/users': users, '/users': users, '/masters/groups': groups, + '/settings/groups': groups, '/groups': groups, '/masters/menus': menus, + '/settings/menus': menus, '/menus': menus, '/utilities/postal-search': postalSearch, + '/utilities/zipcodes': postalSearch, '/zipcodes': postalSearch, '/reports': reports, '/reports/transactions': reportsTransactions, diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 26f7850..ca7bc76 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -2,32 +2,11 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; -import '../../features/approvals/history/presentation/pages/approval_history_page.dart'; import '../../features/auth/application/auth_service.dart'; -import '../../features/approvals/request/presentation/pages/approval_request_page.dart'; -import '../../features/approvals/step/presentation/pages/approval_step_page.dart'; -import '../../features/approvals/template/presentation/pages/approval_template_page.dart'; -import '../../features/dashboard/presentation/pages/dashboard_page.dart'; -import '../../features/inventory/inbound/presentation/pages/inbound_page.dart'; -import '../../features/inventory/outbound/presentation/pages/outbound_page.dart'; -import '../../features/inventory/rental/presentation/pages/rental_page.dart'; -import '../../features/inventory/summary/presentation/pages/inventory_summary_page.dart'; import '../../features/login/presentation/pages/login_page.dart'; -import '../../features/masters/customer/presentation/pages/customer_page.dart'; -import '../../features/masters/group/presentation/pages/group_page.dart'; -import '../../features/masters/group_permission/presentation/pages/group_permission_page.dart'; -import '../../features/masters/menu/presentation/pages/menu_page.dart'; -import '../../features/masters/product/presentation/pages/product_page.dart'; -import '../../features/masters/user/presentation/pages/user_page.dart'; -import '../../features/masters/vendor/presentation/pages/vendor_page.dart'; -import '../../features/masters/warehouse/presentation/pages/warehouse_page.dart'; -import '../../features/reporting/presentation/pages/reporting_page.dart'; -import '../../features/util/postal_search/presentation/pages/postal_search_page.dart'; import '../../widgets/app_shell.dart'; -import '../constants/app_sections.dart'; -import '../permissions/permission_manager.dart'; -import '../permissions/permission_resources.dart'; -import 'auth_guard.dart'; +import '../navigation/menu_route_definitions.dart'; +import '../navigation/route_paths.dart'; /// 전역 네비게이터 키(로그인/셸 라우터 공용). final _rootNavigatorKey = GlobalKey(debugLabel: 'root'); @@ -63,131 +42,13 @@ final appRouter = GoRouter( builder: (context, state, child) => AppShell(currentLocation: state.uri.toString(), child: child), routes: [ - GoRoute( - path: dashboardRoutePath, - name: 'dashboard', - builder: (context, state) => const DashboardPage(), - ), - GoRoute( - path: inventorySummaryRoutePath, - name: 'inventory-summary', - redirect: (context, state) { - if (!AuthGuard.can(inventorySummaryRoutePath)) { - return dashboardRoutePath; - } - if (!AuthGuard.can(PermissionResources.inventoryScope)) { - return dashboardRoutePath; - } - return null; - }, - builder: (context, state) => - InventorySummaryPage(routeUri: state.uri), - ), - GoRoute( - path: '/inventory/inbound', - name: 'inventory-inbound', - builder: (context, state) => InboundPage(routeUri: state.uri), - ), - GoRoute( - path: '/inventory/outbound', - name: 'inventory-outbound', - builder: (context, state) => OutboundPage(routeUri: state.uri), - ), - GoRoute( - path: '/inventory/rental', - name: 'inventory-rental', - builder: (context, state) => RentalPage(routeUri: state.uri), - ), - GoRoute( - path: '/masters/vendors', - name: 'masters-vendors', - builder: (context, state) => VendorPage(routeUri: state.uri), - ), - GoRoute( - path: '/masters/products', - name: 'masters-products', - builder: (context, state) => ProductPage(routeUri: state.uri), - ), - GoRoute( - path: '/masters/warehouses', - name: 'masters-warehouses', - builder: (context, state) => WarehousePage(routeUri: state.uri), - ), - GoRoute( - path: '/masters/customers', - name: 'masters-customers', - builder: (context, state) => CustomerPage(routeUri: state.uri), - ), - GoRoute( - path: '/masters/users', - name: 'masters-users', - builder: (context, state) => const UserPage(), - ), - GoRoute( - path: '/masters/groups', - name: 'masters-groups', - builder: (context, state) => const GroupPage(), - ), - GoRoute( - path: '/masters/menus', - name: 'masters-menus', - builder: (context, state) => const MenuPage(), - ), - GoRoute( - path: '/masters/group-permissions', - name: 'masters-group-permissions', - builder: (context, state) => const GroupPermissionPage(), - ), - GoRoute( - path: '/approvals/requests', - name: 'approvals-requests', - redirect: AuthGuard.require( - resource: '/approvals/requests', - action: PermissionAction.view, - fallback: dashboardRoutePath, + for (final definition in menuRouteDefinitions) + GoRoute( + path: definition.routePath, + name: definition.menuCode, + redirect: definition.buildGuard(), + builder: definition.builder, ), - builder: (context, state) => ApprovalRequestPage(routeUri: state.uri), - ), - GoRoute( - path: '/approvals/steps', - name: 'approvals-steps', - redirect: AuthGuard.require( - resource: '/approvals/steps', - action: PermissionAction.view, - fallback: dashboardRoutePath, - ), - builder: (context, state) => const ApprovalStepPage(), - ), - GoRoute( - path: '/approvals/history', - name: 'approvals-history', - redirect: AuthGuard.require( - resource: '/approvals/history', - action: PermissionAction.view, - fallback: dashboardRoutePath, - ), - builder: (context, state) => const ApprovalHistoryPage(), - ), - GoRoute( - path: '/approvals/templates', - name: 'approvals-templates', - redirect: AuthGuard.require( - resource: '/approvals/templates', - action: PermissionAction.view, - fallback: dashboardRoutePath, - ), - builder: (context, state) => const ApprovalTemplatePage(), - ), - GoRoute( - path: '/utilities/postal-search', - name: 'utilities-postal-search', - builder: (context, state) => const PostalSearchPage(), - ), - GoRoute( - path: '/reports', - name: 'reports', - builder: (context, state) => const ReportingPage(), - ), ], ), ], diff --git a/lib/core/routing/auth_guard.dart b/lib/core/routing/auth_guard.dart index e7601f7..91b1cd9 100644 --- a/lib/core/routing/auth_guard.dart +++ b/lib/core/routing/auth_guard.dart @@ -27,13 +27,46 @@ class AuthGuard { PermissionAction action = PermissionAction.view, required String fallback, }) { + return requireAll( + requirements: [ + PermissionRequirement(resource: resource, action: action), + ], + fallback: fallback, + ); + } + + /// 여러 권한 요구사항을 모두 만족해야 통과시키는 가드를 생성한다. + static RouteGuard requireAll({ + required Iterable requirements, + required String fallback, + }) { + final guards = requirements.toList(growable: false); + if (guards.isEmpty) { + return (context, state) => null; + } return (context, state) { if (!GetIt.I.isRegistered()) { return null; } final manager = GetIt.I(); - final allowed = manager.can(resource, action); - return allowed ? null : fallback; + for (final requirement in guards) { + final allowed = manager.can(requirement.resource, requirement.action); + if (!allowed) { + return fallback; + } + } + return null; }; } } + +/// 라우트 접근 시 필요한 권한 정보를 표현한다. +class PermissionRequirement { + const PermissionRequirement({ + required this.resource, + this.action = PermissionAction.view, + }); + + final String resource; + final PermissionAction action; +} diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart index bfd8c82..ae17092 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; -import '../../../../../core/constants/app_sections.dart'; +import '../../../../../core/navigation/route_paths.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_date_picker.dart'; diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 54ae3a7..3758cc9 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -8,7 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../core/config/environment.dart'; -import '../../../../core/constants/app_sections.dart'; +import '../../../../core/navigation/route_paths.dart'; import '../../../../core/permissions/permission_manager.dart'; import '../../../../core/permissions/permission_resources.dart'; import '../../../../widgets/app_layout.dart'; 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 75435ab..c05776d 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; -import '../../../../../core/constants/app_sections.dart'; +import '../../../../../core/navigation/route_paths.dart'; import '../../../../../core/permissions/permission_manager.dart'; import '../../../../../core/permissions/permission_resources.dart'; import '../../../../../widgets/app_layout.dart'; diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index bc34375..7866691 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -5,7 +5,7 @@ import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; -import '../../../../../core/constants/app_sections.dart'; +import '../../../../../core/navigation/route_paths.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_table.dart'; diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 8778985..2c67809 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; @@ -323,7 +323,7 @@ class _InboundPageState extends State { subtitle: '입고 처리, 라인 품목, 상태를 한 화면에서 확인하고 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '입·출고', path: '/inventory/inbound'), + AppBreadcrumbItem(label: '입·출고', path: inventoryReceiptsRoutePath), AppBreadcrumbItem(label: '입고'), ], actions: [ diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 99eee04..ceebde9 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; @@ -410,7 +410,7 @@ class _OutboundPageState extends State { subtitle: '출고 처리, 고객사 연결, 품목 라인을 실시간으로 확인합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '입·출고', path: '/inventory/outbound'), + AppBreadcrumbItem(label: '입·출고', path: inventoryIssuesRoutePath), AppBreadcrumbItem(label: '출고'), ], actions: [ diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 0957ea4..fa1b5e8 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_date_picker.dart'; @@ -356,7 +356,7 @@ class _RentalPageState extends State { subtitle: '대여/반납 구분, 반납 예정일, 고객사 현황을 확인합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '입·출고', path: '/inventory/rental'), + AppBreadcrumbItem(label: '입·출고', path: inventoryRentalsRoutePath), AppBreadcrumbItem(label: '대여'), ], actions: [ diff --git a/lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart b/lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart index b9e8332..b59aefd 100644 --- a/lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart +++ b/lib/features/inventory/summary/presentation/pages/inventory_summary_page.dart @@ -7,7 +7,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/common/models/paginated_result.dart'; -import '../../../../../core/constants/app_sections.dart'; +import '../../../../../core/navigation/route_paths.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/form_field.dart'; @@ -434,17 +434,17 @@ class _InventorySummaryPageState extends State { padding: const EdgeInsets.all(0), child: SizedBox( width: double.infinity, - child: SuperportTable.fromCells( + child: SuperportTable( rowHeight: widget.debugRowHeight ?? 72, - header: const [ - ShadTableCell.header(child: Text('#')), - ShadTableCell.header(child: Text('제품명 / 코드')), - ShadTableCell.header(child: Text('벤더')), - ShadTableCell.header(child: Text('총 수량')), - ShadTableCell.header(child: Text('최근 변동')), - ShadTableCell.header(child: Text('업데이트')), + columns: const [ + Text('#'), + Text('제품명 / 코드'), + Text('벤더'), + Text('총 수량'), + Text('최근 변동'), + Text('업데이트'), ], - rows: rows, + rows: rows.map((cells) => cells.cast()).toList(), columnSpanExtent: _columnSpanForIndex, sortableColumns: _columnSortKeys.keys.toSet(), sortState: _sortState, diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 550d558..4312465 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -6,7 +6,8 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import '../../../../core/constants/app_sections.dart'; +import '../../../../core/navigation/menu_catalog.dart'; +import '../../../../core/navigation/route_paths.dart'; import '../../../../core/network/api_error.dart'; import '../../../../core/network/failure.dart'; import '../../../../core/permissions/permission_bootstrapper.dart'; @@ -359,6 +360,7 @@ class _LoginPageState extends State { manager: PermissionScope.of(context), groupRepository: GetIt.I(), groupPermissionRepository: GetIt.I(), + menuCatalog: MenuCatalogScope.of(context), ); await bootstrapper.apply(session); } diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index 0f17fbb..c8f6e4c 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; @@ -282,7 +282,10 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { subtitle: '고객사 기본 정보와 연락처, 주소를 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/customers'), + AppBreadcrumbItem( + label: '마스터', + path: inventoryCustomersRoutePath, + ), AppBreadcrumbItem(label: '고객사'), ], actions: [ diff --git a/lib/features/masters/group/presentation/controllers/group_controller.dart b/lib/features/masters/group/presentation/controllers/group_controller.dart index 9460fec..dd8028b 100644 --- a/lib/features/masters/group/presentation/controllers/group_controller.dart +++ b/lib/features/masters/group/presentation/controllers/group_controller.dart @@ -1,7 +1,7 @@ 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/navigation/menu_catalog.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import '../../domain/entities/group.dart'; @@ -24,13 +24,16 @@ class GroupController extends ChangeNotifier { required GroupRepository repository, GroupPermissionRepository? permissionRepository, PermissionManager? permissionManager, + MenuCatalog? menuCatalog, }) : _repository = repository, _permissionRepository = permissionRepository, - _permissionManager = permissionManager; + _permissionManager = permissionManager, + _menuCatalog = menuCatalog; final GroupRepository _repository; final GroupPermissionRepository? _permissionRepository; final PermissionManager? _permissionManager; + final MenuCatalog? _menuCatalog; PaginatedResult? _result; bool _isLoading = false; @@ -211,6 +214,7 @@ class GroupController extends ChangeNotifier { final synchronizer = PermissionSynchronizer( repository: repository, manager: manager, + menuCatalog: _menuCatalog, ); await synchronizer.syncForGroup(groupId); } catch (_) { diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart index 32415ae..460af85 100644 --- a/lib/features/masters/group/presentation/pages/group_page.dart +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -2,7 +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/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; @@ -144,7 +144,10 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { subtitle: '권한 그룹 정의와 기본 여부, 사용 상태를 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/groups'), + AppBreadcrumbItem( + label: '마스터', + path: settingsGroupsRoutePath, + ), AppBreadcrumbItem(label: '그룹'), ], actions: [ diff --git a/lib/features/masters/group_permission/application/permission_synchronizer.dart b/lib/features/masters/group_permission/application/permission_synchronizer.dart index f66e2bf..a5dd453 100644 --- a/lib/features/masters/group_permission/application/permission_synchronizer.dart +++ b/lib/features/masters/group_permission/application/permission_synchronizer.dart @@ -1,4 +1,6 @@ +import 'package:superport_v2/core/navigation/menu_catalog.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; import '../domain/entities/group_permission.dart'; import '../domain/mappers/group_permission_mapper.dart'; @@ -9,12 +11,15 @@ class PermissionSynchronizer { PermissionSynchronizer({ required GroupPermissionRepository repository, required PermissionManager manager, + MenuCatalog? menuCatalog, this.pageSize = 200, }) : _repository = repository, - _manager = manager; + _manager = manager, + _menuCatalog = menuCatalog; final GroupPermissionRepository _repository; final PermissionManager _manager; + final MenuCatalog? _menuCatalog; final int pageSize; /// 지정한 [groupId]의 메뉴 권한을 조회해 [PermissionManager]에 적용한다. @@ -31,7 +36,8 @@ class PermissionSynchronizer { if (collected.isEmpty) { return const {}; } - return buildPermissionMap(collected); + final synchronized = await _alignMenuRoutes(collected); + return buildPermissionMap(synchronized); } Future> _collectPermissions(int groupId) async { @@ -63,4 +69,85 @@ class PermissionSynchronizer { return collected; } + + Future> _alignMenuRoutes( + List permissions, + ) async { + final catalog = _menuCatalog; + if (catalog == null || permissions.isEmpty) { + return permissions; + } + final codeToRoute = await _buildMenuRouteMap(catalog); + if (codeToRoute.isEmpty) { + return permissions; + } + var mutated = false; + final adjusted = []; + for (final permission in permissions) { + final resolvedPath = _resolveRouteForPermission( + permission.menu.menuCode, + permission.menu.path, + codeToRoute, + ); + if (resolvedPath == null) { + adjusted.add(permission); + continue; + } + final normalizedCurrent = PermissionResources.normalize( + permission.menu.path ?? '', + ); + final normalizedResolved = PermissionResources.normalize(resolvedPath); + if (normalizedCurrent == normalizedResolved) { + adjusted.add(permission); + continue; + } + final updatedMenu = GroupPermissionMenu( + id: permission.menu.id, + menuCode: permission.menu.menuCode, + menuName: permission.menu.menuName, + path: resolvedPath, + ); + adjusted.add(permission.copyWith(menu: updatedMenu)); + mutated = true; + } + return mutated ? adjusted : permissions; + } + + Future> _buildMenuRouteMap(MenuCatalog catalog) async { + try { + final menus = await catalog.ensureLoaded(); + final map = {}; + for (final menu in menus) { + final code = menu.menuCode; + final path = menu.path; + if (code.isEmpty || path == null || path.isEmpty) { + continue; + } + map[code] = path; + } + return map; + } catch (_) { + return const {}; + } + } + + String? _resolveRouteForPermission( + String menuCode, + String? currentPath, + Map codeToRoute, + ) { + final expectedRaw = codeToRoute[menuCode]; + final normalizedCurrent = PermissionResources.normalize(currentPath ?? ''); + if (normalizedCurrent.isNotEmpty) { + if (expectedRaw == null || expectedRaw.isEmpty) { + return null; + } + final normalizedExpected = PermissionResources.normalize(expectedRaw); + return normalizedCurrent == normalizedExpected ? null : expectedRaw; + } + if (expectedRaw == null || expectedRaw.isEmpty) { + return null; + } + return expectedRaw; + } } 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 b746baf..1722130 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 @@ -22,17 +22,19 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { int page = 1, int pageSize = 20, int? groupId, - int? menuId, + String? menuCode, bool? isActive, bool includeDeleted = false, }) async { + final normalizedMenuCode = menuCode?.trim(); final response = await _api.get>( _basePath, query: { 'page': page, 'page_size': pageSize, if (groupId != null) 'group_id': groupId, - if (menuId != null) 'menu_id': menuId, + if (normalizedMenuCode != null && normalizedMenuCode.isNotEmpty) + 'menu_code': normalizedMenuCode, if (isActive != null) 'active': isActive, if (includeDeleted) 'include_deleted': true, 'include': 'group,menu', 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 6886947..a000f14 100644 --- a/lib/features/masters/group_permission/domain/entities/group_permission.dart +++ b/lib/features/masters/group_permission/domain/entities/group_permission.dart @@ -87,7 +87,7 @@ class GroupPermissionMenu { class GroupPermissionInput { GroupPermissionInput({ required this.groupId, - required this.menuId, + required this.menuCode, this.canCreate = false, this.canRead = true, this.canUpdate = false, @@ -97,7 +97,7 @@ class GroupPermissionInput { }); final int groupId; - final int menuId; + final String menuCode; final bool canCreate; final bool canRead; final bool canUpdate; @@ -106,9 +106,10 @@ class GroupPermissionInput { final String? note; Map toPayload() { + final normalizedCode = menuCode.trim(); return { 'group_id': groupId, - 'menu_id': menuId, + 'menu_code': normalizedCode, 'can_create': canCreate, 'can_read': canRead, 'can_update': canUpdate, diff --git a/lib/features/masters/group_permission/domain/repositories/group_permission_repository.dart b/lib/features/masters/group_permission/domain/repositories/group_permission_repository.dart index d044f4b..d42e034 100644 --- a/lib/features/masters/group_permission/domain/repositories/group_permission_repository.dart +++ b/lib/features/masters/group_permission/domain/repositories/group_permission_repository.dart @@ -9,7 +9,7 @@ abstract class GroupPermissionRepository { int page = 1, int pageSize = 20, int? groupId, - int? menuId, + String? menuCode, bool? isActive, bool includeDeleted = false, }); 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 59617fa..81e408d 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,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/pagination_utils.dart'; @@ -24,15 +25,18 @@ class GroupPermissionController extends ChangeNotifier { required GroupPermissionRepository permissionRepository, required GroupRepository groupRepository, required MenuRepository menuRepository, + MenuCatalog? menuCatalog, PermissionManager? permissionManager, }) : _permissionRepository = permissionRepository, _groupRepository = groupRepository, _menuRepository = menuRepository, + _menuCatalog = menuCatalog, _permissionManager = permissionManager; final GroupPermissionRepository _permissionRepository; final GroupRepository _groupRepository; final MenuRepository _menuRepository; + final MenuCatalog? _menuCatalog; final PermissionManager? _permissionManager; PaginatedResult? _result; @@ -43,7 +47,7 @@ class GroupPermissionController extends ChangeNotifier { String? _errorMessage; GroupPermissionStatusFilter _statusFilter = GroupPermissionStatusFilter.all; int? _groupFilter; - int? _menuFilter; + String? _menuFilter; bool _includeDeleted = false; final List _groups = []; final List _menus = []; @@ -56,7 +60,7 @@ class GroupPermissionController extends ChangeNotifier { String? get errorMessage => _errorMessage; GroupPermissionStatusFilter get statusFilter => _statusFilter; int? get groupFilter => _groupFilter; - int? get menuFilter => _menuFilter; + String? get menuFilter => _menuFilter; bool get includeDeleted => _includeDeleted; List get groups => List.unmodifiable(_groups); List get menus => List.unmodifiable(_menus); @@ -87,13 +91,7 @@ class GroupPermissionController extends ChangeNotifier { _isLoadingMenus = true; notifyListeners(); try { - final menus = await fetchAllPaginatedItems( - request: (page, pageSize) => _menuRepository.list( - page: page, - pageSize: pageSize, - includeDeleted: false, - ), - ); + final menus = _sortMenus(await _resolveMenus()); _menus ..clear() ..addAll(menus); @@ -132,7 +130,7 @@ class GroupPermissionController extends ChangeNotifier { page: resolvedPage, pageSize: _result?.pageSize ?? 20, groupId: _groupFilter, - menuId: _menuFilter, + menuCode: _menuFilter, isActive: isActive, includeDeleted: _includeDeleted, ); @@ -153,8 +151,9 @@ class GroupPermissionController extends ChangeNotifier { } /// 메뉴 필터를 변경한다. - void updateMenuFilter(int? menuId) { - _menuFilter = menuId; + void updateMenuFilter(String? menuCode) { + final trimmed = menuCode?.trim(); + _menuFilter = trimmed?.isEmpty ?? true ? null : trimmed; notifyListeners(); } @@ -266,6 +265,7 @@ class GroupPermissionController extends ChangeNotifier { final synchronizer = PermissionSynchronizer( repository: _permissionRepository, manager: manager, + menuCatalog: _menuCatalog, ); await synchronizer.syncForGroup(groupId); } catch (_) { @@ -285,4 +285,53 @@ class GroupPermissionController extends ChangeNotifier { } return null; } + + List _sortMenus(List menus) { + // 백엔드 menus 테이블 순서를 재현하기 위해 display_order → 메뉴명 순으로 정렬한다. + final visibleMenus = menus.where((menu) => !menu.isDeleted).toList(); + visibleMenus.sort(_compareMenuItems); + return visibleMenus; + } + + int _compareMenuItems(MenuItem a, MenuItem b) { + final parentCompare = (a.parent?.menuName ?? '').compareTo( + b.parent?.menuName ?? '', + ); + if (parentCompare != 0) { + return parentCompare; + } + final orderCompare = _compareDisplayOrder(a.displayOrder, b.displayOrder); + if (orderCompare != 0) { + return orderCompare; + } + return a.menuName.compareTo(b.menuName); + } + + int _compareDisplayOrder(int? a, int? b) { + if (a == null && b == null) { + return 0; + } + if (a == null) { + return 1; + } + if (b == null) { + return -1; + } + return a.compareTo(b); + } + + Future> _resolveMenus() async { + final catalog = _menuCatalog; + if (catalog != null) { + final menus = await catalog.ensureLoaded(); + return List.from(menus); + } + return fetchAllPaginatedItems( + request: (page, pageSize) => _menuRepository.list( + page: page, + pageSize: pageSize, + includeDeleted: false, + ), + ); + } } diff --git a/lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart b/lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart index 64e4ac4..49a60be 100644 --- a/lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart +++ b/lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart @@ -366,7 +366,7 @@ class _GroupPermissionForm extends StatefulWidget { class _GroupPermissionFormState extends State<_GroupPermissionForm> { int? _selectedGroup; - int? _selectedMenu; + String? _selectedMenu; late bool _canCreate; late bool _canRead; late bool _canUpdate; @@ -385,7 +385,7 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> { super.initState(); final permission = widget.permission; _selectedGroup = permission?.group.id; - _selectedMenu = permission?.menu.id; + _selectedMenu = permission?.menu.menuCode; _canCreate = permission?.canCreate ?? false; _canRead = permission?.canRead ?? true; _canUpdate = permission?.canUpdate ?? false; @@ -457,7 +457,7 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ShadSelect( + ShadSelect( initialValue: _selectedMenu, placeholder: Text( widget.isLoadingMenus ? '메뉴 로딩중...' : '메뉴 선택', @@ -475,9 +475,10 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> { }); }, options: widget.menus + .where((menu) => menu.menuCode.isNotEmpty) .map( - (menu) => ShadOption( - value: menu.id!, + (menu) => ShadOption( + value: menu.menuCode, child: Text(menu.menuName), ), ) @@ -612,7 +613,7 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> { final note = _noteController.text.trim(); final input = GroupPermissionInput( groupId: _selectedGroup!, - menuId: _selectedMenu!, + menuCode: _selectedMenu!, canCreate: _canCreate, canRead: _canRead, canUpdate: _canUpdate, @@ -648,13 +649,13 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> { return group.groupName; } - String _resolveMenuLabel(int? id) { - if (id == null) { + String _resolveMenuLabel(String? code) { + if (code == null) { return widget.isLoadingMenus ? '메뉴 로딩중...' : '메뉴 선택'; } final menu = widget.menus.firstWhere( - (item) => item.id == id, - orElse: () => MenuItem(id: id, menuCode: '', menuName: '알 수 없음'), + (item) => item.menuCode == code, + orElse: () => MenuItem(menuCode: code, menuName: '알 수 없음'), ); return menu.menuName; } 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 a31afe2..5548033 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 @@ -3,7 +3,9 @@ import 'package:get_it/get_it.dart'; import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; +import 'package:superport_v2/core/navigation/menu_route_definitions.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; @@ -24,9 +26,9 @@ import '../dialogs/group_permission_detail_dialog.dart'; String _menuDisplayLabelFromPath(String? path, String fallback) { if (path != null && path.isNotEmpty) { final normalized = path.toLowerCase(); - for (final page in allAppPages) { - if (page.path.toLowerCase() == normalized) { - return page.label; + for (final definition in menuRouteDefinitions) { + if (definition.routePath.toLowerCase() == normalized) { + return definition.defaultLabel; } } } @@ -147,10 +149,12 @@ class _GroupPermissionEnabledPageState return; } final permissionManager = PermissionScope.of(context); + final menuCatalog = MenuCatalogScope.of(context); _controller = GroupPermissionController( permissionRepository: GetIt.I(), groupRepository: GetIt.I(), menuRepository: GetIt.I(), + menuCatalog: menuCatalog, permissionManager: permissionManager, )..addListener(_handleControllerUpdate); _initialized = true; @@ -209,7 +213,10 @@ class _GroupPermissionEnabledPageState subtitle: '그룹별 메뉴 CRUD 권한을 체크박스로 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/group-permissions'), + AppBreadcrumbItem( + label: '마스터', + path: settingsGroupPermissionsRoutePath, + ), AppBreadcrumbItem(label: '그룹 권한'), ], actions: [ @@ -292,8 +299,8 @@ class _GroupPermissionEnabledPageState ), SizedBox( width: 220, - child: ShadSelect( - key: ValueKey(_controller.menuFilter), + child: ShadSelect( + key: ValueKey(_controller.menuFilter), initialValue: _controller.menuFilter, placeholder: Text( _controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체', @@ -305,9 +312,9 @@ class _GroupPermissionEnabledPageState ); } final menuItem = _controller.menus.firstWhere( - (m) => m.id == value, + (m) => m.menuCode == value, orElse: () => - MenuItem(id: value, menuCode: '', menuName: ''), + MenuItem(menuCode: value, menuName: '알 수 없음'), ); return Text(_menuDisplayLabel(menuItem)); }, @@ -315,10 +322,13 @@ class _GroupPermissionEnabledPageState _controller.updateMenuFilter(value); }, options: [ - const ShadOption(value: null, child: Text('메뉴 전체')), + const ShadOption( + value: null, + child: Text('메뉴 전체'), + ), ..._controller.menus.map( - (menuItem) => ShadOption( - value: menuItem.id, + (menuItem) => ShadOption( + value: menuItem.menuCode, child: Text(_menuDisplayLabel(menuItem)), ), ), diff --git a/lib/features/masters/menu/data/dtos/menu_dto.dart b/lib/features/masters/menu/data/dtos/menu_dto.dart index 97aefda..f9546b6 100644 --- a/lib/features/masters/menu/data/dtos/menu_dto.dart +++ b/lib/features/masters/menu/data/dtos/menu_dto.dart @@ -85,21 +85,35 @@ class MenuDto { /// 하위 메뉴 요약 정보를 담는 DTO. class MenuSummaryDto { - MenuSummaryDto({required this.id, required this.menuName}); + MenuSummaryDto({ + required this.id, + required this.menuName, + this.menuCode, + this.path, + }); final int id; final String menuName; + final String? menuCode; + final String? path; /// JSON에서 요약 정보를 파싱한다. factory MenuSummaryDto.fromJson(Map json) { return MenuSummaryDto( id: json['id'] as int, menuName: json['menu_name'] as String, + menuCode: json['menu_code'] as String?, + path: json['path'] as String? ?? json['route_path'] as String?, ); } /// DTO를 [MenuSummary] 엔티티로 변환한다. - MenuSummary toEntity() => MenuSummary(id: id, menuName: menuName); + MenuSummary toEntity() => MenuSummary( + id: id, + menuName: menuName, + menuCode: menuCode, + path: path, + ); } /// 문자열/DateTime을 파싱해 [DateTime]으로 변환한다. diff --git a/lib/features/masters/menu/domain/entities/menu.dart b/lib/features/masters/menu/domain/entities/menu.dart index ee0d8ea..1297828 100644 --- a/lib/features/masters/menu/domain/entities/menu.dart +++ b/lib/features/masters/menu/domain/entities/menu.dart @@ -79,10 +79,17 @@ class MenuItem { /// 상위 메뉴 요약 정보 class MenuSummary { - MenuSummary({required this.id, required this.menuName}); + MenuSummary({ + required this.id, + required this.menuName, + this.menuCode, + this.path, + }); final int id; final String menuName; + final String? menuCode; + final String? path; } /// 메뉴 생성/수정 입력 모델 diff --git a/lib/features/masters/menu/presentation/controllers/menu_controller.dart b/lib/features/masters/menu/presentation/controllers/menu_controller.dart index a7958a1..b59d14c 100644 --- a/lib/features/masters/menu/presentation/controllers/menu_controller.dart +++ b/lib/features/masters/menu/presentation/controllers/menu_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/pagination_utils.dart'; @@ -14,10 +15,12 @@ enum MenuStatusFilter { all, activeOnly, inactiveOnly } /// - 목록, 필터, 페이지 상태를 관리한다. /// - CRUD 및 복구 요청을 처리한다. class MenuController extends ChangeNotifier { - MenuController({required MenuRepository repository}) - : _repository = repository; + MenuController({required MenuRepository repository, MenuCatalog? catalog}) + : _repository = repository, + _catalog = catalog; final MenuRepository _repository; + final MenuCatalog? _catalog; PaginatedResult? _result; bool _isLoading = false; @@ -42,17 +45,11 @@ class MenuController extends ChangeNotifier { List get parents => _parents; /// 상위 메뉴 목록을 로드해 드롭다운에 표시한다. - Future loadParents() async { + Future loadParents({bool forceRefresh = false}) async { _isLoadingParents = true; notifyListeners(); try { - final parents = await fetchAllPaginatedItems( - request: (page, pageSize) => _repository.list( - page: page, - pageSize: pageSize, - includeDeleted: false, - ), - ); + final parents = await _resolveParents(forceRefresh: forceRefresh); _parents = parents; } catch (error) { final failure = Failure.from(error); @@ -133,6 +130,7 @@ class MenuController extends ChangeNotifier { try { final created = await _repository.create(input); await fetch(page: 1); + await _refreshCatalog(); await loadParents(); return created; } catch (error) { @@ -151,6 +149,7 @@ class MenuController extends ChangeNotifier { try { final updated = await _repository.update(id, input); await fetch(page: _result?.page ?? 1); + await _refreshCatalog(); await loadParents(); return updated; } catch (error) { @@ -169,6 +168,7 @@ class MenuController extends ChangeNotifier { try { await _repository.delete(id); await fetch(page: _result?.page ?? 1); + await _refreshCatalog(); await loadParents(); return true; } catch (error) { @@ -187,6 +187,7 @@ class MenuController extends ChangeNotifier { try { final restored = await _repository.restore(id); await fetch(page: _result?.page ?? 1); + await _refreshCatalog(); await loadParents(); return restored; } catch (error) { @@ -210,4 +211,31 @@ class MenuController extends ChangeNotifier { _isSubmitting = value; notifyListeners(); } + + Future> _resolveParents({bool forceRefresh = false}) async { + final catalog = _catalog; + if (catalog != null) { + final menus = await catalog.ensureLoaded(forceRefresh: forceRefresh); + return menus.where((menu) => !menu.isDeleted).toList(growable: false); + } + return fetchAllPaginatedItems( + request: (page, pageSize) => _repository.list( + page: page, + pageSize: pageSize, + includeDeleted: false, + ), + ); + } + + Future _refreshCatalog() async { + final catalog = _catalog; + if (catalog == null) { + return; + } + try { + await catalog.refresh(); + } catch (_) { + // 카탈로그 동기화 실패는 UI 재시도 버튼으로 처리하므로 무시한다. + } + } } diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart index 40ac785..e3fefbc 100644 --- a/lib/features/masters/menu/presentation/pages/menu_page.dart +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -2,13 +2,14 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../../../../core/config/environment.dart'; +import '../../../../../core/navigation/menu_catalog.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/menu.dart'; import '../../domain/repositories/menu_repository.dart'; @@ -107,12 +108,20 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { final FocusNode _searchFocus = FocusNode(); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; + bool _controllerInitialized = false; @override - void initState() { - super.initState(); - _controller = menu.MenuController(repository: GetIt.I()) - ..addListener(_handleControllerUpdate); + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controllerInitialized) { + return; + } + final catalog = MenuCatalogScope.of(context); + _controller = menu.MenuController( + repository: GetIt.I(), + catalog: catalog, + )..addListener(_handleControllerUpdate); + _controllerInitialized = true; WidgetsBinding.instance.addPostFrameCallback((_) async { await _controller.loadParents(); await _controller.fetch(); @@ -133,8 +142,10 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { @override void dispose() { - _controller.removeListener(_handleControllerUpdate); - _controller.dispose(); + if (_controllerInitialized) { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + } _searchController.dispose(); _searchFocus.dispose(); super.dispose(); @@ -166,7 +177,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { subtitle: '메뉴 트리와 경로, 사용 상태를 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/menus'), + AppBreadcrumbItem(label: '마스터', path: settingsMenusRoutePath), AppBreadcrumbItem(label: '메뉴'), ], actions: [ diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index e98a654..1b90a80 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; @@ -171,7 +171,10 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { subtitle: '제품코드, 제조사, 단위 정보를 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/products'), + AppBreadcrumbItem( + label: '마스터', + path: inventoryProductsRoutePath, + ), AppBreadcrumbItem(label: '제품'), ], actions: [ diff --git a/lib/features/masters/user/presentation/controllers/user_controller.dart b/lib/features/masters/user/presentation/controllers/user_controller.dart index 0c8f74d..dc396d9 100644 --- a/lib/features/masters/user/presentation/controllers/user_controller.dart +++ b/lib/features/masters/user/presentation/controllers/user_controller.dart @@ -3,6 +3,7 @@ import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/common/utils/pagination_utils.dart'; +import '../../../../../core/navigation/menu_catalog.dart'; import '../../../../../core/permissions/permission_manager.dart'; import '../../../group/domain/entities/group.dart'; import '../../../group/domain/repositories/group_repository.dart'; @@ -21,15 +22,18 @@ class UserController extends ChangeNotifier { required GroupRepository groupRepository, GroupPermissionRepository? permissionRepository, PermissionManager? permissionManager, + MenuCatalog? menuCatalog, }) : _userRepository = userRepository, _groupRepository = groupRepository, _permissionRepository = permissionRepository, - _permissionManager = permissionManager; + _permissionManager = permissionManager, + _menuCatalog = menuCatalog; final UserRepository _userRepository; final GroupRepository _groupRepository; final GroupPermissionRepository? _permissionRepository; final PermissionManager? _permissionManager; + final MenuCatalog? _menuCatalog; PaginatedResult? _result; bool _isLoading = false; @@ -244,6 +248,7 @@ class UserController extends ChangeNotifier { final synchronizer = PermissionSynchronizer( repository: repository, manager: manager, + menuCatalog: _menuCatalog, ); await synchronizer.syncForGroup(groupId); } catch (_) { diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index 2f24610..ff46ee6 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -3,7 +3,8 @@ import 'package:get_it/get_it.dart'; import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; @@ -116,11 +117,13 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { return; } final permissionManager = PermissionScope.of(context); + final menuCatalog = MenuCatalogScope.of(context); _controller = UserController( userRepository: GetIt.I(), groupRepository: GetIt.I(), permissionRepository: GetIt.I(), permissionManager: permissionManager, + menuCatalog: menuCatalog, )..addListener(_handleControllerUpdate); _initialized = true; WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -179,7 +182,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { subtitle: '사번 기반 계정과 그룹, 사용 상태를 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/users'), + AppBreadcrumbItem( + label: '마스터', + path: settingsUsersRoutePath, + ), AppBreadcrumbItem(label: '사용자'), ], actions: [ diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index d4acf95..b59af75 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; @@ -158,7 +158,10 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { subtitle: '벤더코드, 명칭, 사용여부, 삭제 상태를 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/vendors'), + AppBreadcrumbItem( + label: '마스터', + path: inventoryVendorsRoutePath, + ), AppBreadcrumbItem(label: '벤더'), ], actions: [ diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 72768f5..cfa7f25 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; @@ -171,7 +171,10 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { subtitle: '창고 코드, 주소, 사용여부를 관리합니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '마스터', path: '/masters/warehouses'), + AppBreadcrumbItem( + label: '마스터', + path: inventoryWarehousesRoutePath, + ), AppBreadcrumbItem(label: '창고'), ], actions: [ diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart index 2e62108..8f1106e 100644 --- a/lib/features/reporting/presentation/pages/reporting_page.dart +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -8,7 +8,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/core/common/utils/pagination_utils.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/core/services/file_saver.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'; diff --git a/lib/features/util/postal_search/presentation/pages/postal_search_page.dart b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart index c4f7922..3d1076f 100644 --- a/lib/features/util/postal_search/presentation/pages/postal_search_page.dart +++ b/lib/features/util/postal_search/presentation/pages/postal_search_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import '../../../../../core/constants/app_sections.dart'; +import '../../../../../core/navigation/route_paths.dart'; import '../../../../../widgets/app_layout.dart'; import '../models/postal_search_result.dart'; import '../widgets/postal_search_dialog.dart'; @@ -27,7 +27,10 @@ class _PostalSearchPageState extends State { subtitle: '창고/고객사 등 주소 입력 폼에서 재사용되는 검색 모달입니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '유틸리티', path: '/utilities/postal-search'), + AppBreadcrumbItem( + label: '유틸리티', + path: utilitiesPostalSearchRoutePath, + ), AppBreadcrumbItem(label: '우편번호 검색'), ], child: Center( diff --git a/lib/main.dart b/lib/main.dart index 9bedb6c..255cffb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'core/config/environment.dart'; +import 'core/navigation/menu_catalog.dart'; import 'core/permissions/permission_bootstrapper.dart'; import 'core/permissions/permission_manager.dart'; import 'core/routing/app_router.dart'; @@ -14,6 +15,7 @@ import 'core/theme/theme_controller.dart'; import 'features/auth/application/auth_service.dart'; import 'features/masters/group/domain/repositories/group_repository.dart'; import 'features/masters/group_permission/domain/repositories/group_permission_repository.dart'; +import 'features/masters/menu/domain/repositories/menu_repository.dart'; import 'injection_container.dart'; /// Superport 애플리케이션 진입점. 환경 초기화 후 앱 위젯을 실행한다. @@ -45,17 +47,24 @@ class SuperportApp extends StatefulWidget { class _SuperportAppState extends State { late final ThemeController _themeController; late final PermissionManager _permissionManager; + late final MenuCatalog _menuCatalog; @override void initState() { super.initState(); _themeController = ThemeController(); _permissionManager = PermissionManager(); + _menuCatalog = MenuCatalog(repository: GetIt.I()); if (GetIt.I.isRegistered()) { GetIt.I.unregister(); } GetIt.I.registerSingleton(_permissionManager); + if (GetIt.I.isRegistered()) { + GetIt.I.unregister(); + } + GetIt.I.registerSingleton(_menuCatalog); unawaited(_restorePermissions()); + unawaited(_preloadMenus()); } @override @@ -63,8 +72,12 @@ class _SuperportAppState extends State { if (GetIt.I.isRegistered()) { GetIt.I.unregister(); } + if (GetIt.I.isRegistered()) { + GetIt.I.unregister(); + } _themeController.dispose(); _permissionManager.dispose(); + _menuCatalog.dispose(); super.dispose(); } @@ -72,26 +85,32 @@ class _SuperportAppState extends State { Widget build(BuildContext context) { return PermissionScope( manager: _permissionManager, - child: ThemeControllerScope( - controller: _themeController, - child: AnimatedBuilder( - animation: _themeController, - builder: (context, _) { - return ShadApp.router( - title: 'Superport v2', - routerConfig: appRouter, - debugShowCheckedModeBanner: false, - supportedLocales: const [Locale('ko', 'KR'), Locale('en', 'US')], - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - theme: SuperportShadTheme.light(), - darkTheme: SuperportShadTheme.dark(), - themeMode: _themeController.mode, - ); - }, + child: MenuCatalogScope( + catalog: _menuCatalog, + child: ThemeControllerScope( + controller: _themeController, + child: AnimatedBuilder( + animation: _themeController, + builder: (context, _) { + return ShadApp.router( + title: 'Superport v2', + routerConfig: appRouter, + debugShowCheckedModeBanner: false, + supportedLocales: const [ + Locale('ko', 'KR'), + Locale('en', 'US'), + ], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: SuperportShadTheme.light(), + darkTheme: SuperportShadTheme.dark(), + themeMode: _themeController.mode, + ); + }, + ), ), ), ); @@ -107,7 +126,17 @@ class _SuperportAppState extends State { manager: _permissionManager, groupRepository: GetIt.I(), groupPermissionRepository: GetIt.I(), + menuCatalog: _menuCatalog, ); await bootstrapper.apply(session); } + + Future _preloadMenus() async { + try { + await _menuCatalog.refresh(); + } catch (error, stackTrace) { + debugPrint('메뉴 목록 초기화 실패: $error'); + debugPrintStack(stackTrace: stackTrace); + } + } } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index a49547c..5b8ce70 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,22 +1,27 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; -import '../core/constants/app_sections.dart'; +import '../core/navigation/menu_route_definitions.dart'; +import '../core/navigation/route_paths.dart'; +import '../core/navigation/menu_catalog.dart'; import '../core/permissions/permission_manager.dart'; import '../core/network/failure.dart'; import '../core/theme/theme_controller.dart'; import '../core/validation/password_rules.dart'; import '../features/auth/application/auth_service.dart'; import '../features/auth/domain/entities/auth_session.dart'; +import '../features/masters/menu/domain/entities/menu.dart'; import '../features/masters/user/domain/entities/user.dart'; import '../features/masters/user/domain/repositories/user_repository.dart'; import 'components/superport_dialog.dart'; /// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다. -class AppShell extends StatelessWidget { +class AppShell extends StatefulWidget { const AppShell({ super.key, required this.child, @@ -26,72 +31,211 @@ class AppShell extends StatelessWidget { final Widget child; final String currentLocation; + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth >= 960; - final manager = PermissionScope.of(context); - final filteredPages = [ - for (final section in appSections) - for (final page in section.pages) - if (_hasPageAccess(manager, page)) page, - ]; - final pages = filteredPages.isEmpty ? allAppPages : filteredPages; - final themeController = ThemeControllerScope.of(context); - final authService = GetIt.I(); - final appBar = _GradientAppBar( - title: const _BrandTitle(), - actions: [ - _ThemeMenuButton( - mode: themeController.mode, - onChanged: themeController.update, - ), - const SizedBox(width: 8), - _AccountMenuButton(service: authService), - const SizedBox(width: 8), - ], - ); - - if (isWide) { - return Scaffold( - appBar: appBar, - body: Row( - children: [ - _NavigationRail(currentLocation: currentLocation, pages: pages), - const VerticalDivider(width: 1), - Expanded(child: child), + final catalog = MenuCatalogScope.of(context); + return AnimatedBuilder( + animation: catalog, + builder: (context, _) { + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 960; + final manager = PermissionScope.of(context); + final pages = _resolveNavigationItems(manager, catalog.menus); + final themeController = ThemeControllerScope.of(context); + final authService = GetIt.I(); + final appBar = _GradientAppBar( + title: const _BrandTitle(), + actions: [ + _ThemeMenuButton( + mode: themeController.mode, + onChanged: themeController.update, + ), + const SizedBox(width: 8), + _AccountMenuButton(service: authService), + const SizedBox(width: 8), ], - ), - ); - } + ); - return Scaffold( - appBar: appBar, - drawer: Drawer( - child: SafeArea( - child: _NavigationList( - currentLocation: currentLocation, - onTap: (path) { - Navigator.of(context).pop(); - context.go(path); - }, - pages: pages, + if (isWide) { + return Scaffold( + appBar: appBar, + body: Row( + children: [ + _NavigationRail( + currentLocation: widget.currentLocation, + pages: pages, + isLoading: catalog.isLoading, + errorMessage: catalog.errorMessage, + onRetry: () => _handleMenuRefresh(catalog), + ), + const VerticalDivider(width: 1), + Expanded(child: widget.child), + ], + ), + ); + } + + return Scaffold( + appBar: appBar, + drawer: Drawer( + child: SafeArea( + child: _NavigationList( + currentLocation: widget.currentLocation, + onTap: (path) { + Navigator.of(context).pop(); + context.go(path); + }, + pages: pages, + isLoading: catalog.isLoading, + errorMessage: catalog.errorMessage, + onRetry: () => _handleMenuRefresh(catalog), + ), + ), ), - ), - ), - body: child, + body: widget.child, + ); + }, ); }, ); } + + List<_NavigationMenuItem> _resolveNavigationItems( + PermissionManager manager, + List menus, + ) { + final addedCodes = {}; + if (menus.isNotEmpty) { + final codeToMenu = {for (final menu in menus) menu.menuCode: menu}; + final sortedMenus = [...menus] + ..sort((a, b) { + final defA = menuRouteDefinitionByCode[a.menuCode]; + final defB = menuRouteDefinitionByCode[b.menuCode]; + final aliasFlagA = _aliasPriority(a, defA); + final aliasFlagB = _aliasPriority(b, defB); + if (aliasFlagA != aliasFlagB) { + return aliasFlagA - aliasFlagB; + } + final orderA = a.displayOrder ?? defA?.defaultOrder ?? 0; + final orderB = b.displayOrder ?? defB?.defaultOrder ?? 0; + return orderA.compareTo(orderB); + }); + final items = <_NavigationMenuItem>[]; + for (final menu in sortedMenus) { + if (!menu.isActive || menu.isDeleted) { + continue; + } + final definition = menuRouteDefinitionByCode[menu.menuCode]; + if (definition == null || !definition.showInNavigation) { + continue; + } + final canonicalCode = definition.menuCode; + if (addedCodes.contains(canonicalCode)) { + continue; + } + if (!definition.canAccess(manager)) { + continue; + } + addedCodes.add(canonicalCode); + final parentCode = menu.parent?.menuCode ?? _parentCode(menu.menuCode); + final parentOrder = parentCode != null + ? codeToMenu[parentCode]?.displayOrder ?? definition.defaultOrder + : definition.defaultOrder; + final displayOrder = menu.displayOrder ?? definition.defaultOrder; + items.add( + _NavigationMenuItem( + menuCode: canonicalCode, + label: menu.menuName, + path: definition.routePath, + icon: definition.icon, + sortOrder: parentOrder * 1000 + displayOrder, + ), + ); + } + if (items.isNotEmpty) { + items.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + return items; + } + } + + final fallbackItems = menuRouteDefinitions + .where((definition) => definition.showInNavigation) + .where((definition) => definition.canAccess(manager)) + .map( + (definition) => _NavigationMenuItem( + menuCode: definition.menuCode, + label: definition.defaultLabel, + path: definition.routePath, + icon: definition.icon, + sortOrder: definition.defaultOrder, + ), + ) + .toList(); + fallbackItems.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + return fallbackItems; + } + + void _handleMenuRefresh(MenuCatalog catalog) { + unawaited( + catalog.refresh().catchError((error, stackTrace) { + final failure = Failure.from(error); + debugPrint('메뉴 갱신 실패: ${failure.describe()}'); + }), + ); + } + + int _aliasPriority(MenuItem menu, MenuRouteDefinition? definition) { + if (definition == null) { + return 2; + } + return definition.menuCode == menu.menuCode ? 0 : 1; + } + + String? _parentCode(String code) { + final separatorIndex = code.lastIndexOf('.'); + if (separatorIndex == -1) { + return null; + } + return code.substring(0, separatorIndex); + } +} + +class _NavigationMenuItem { + const _NavigationMenuItem({ + required this.menuCode, + required this.label, + required this.path, + required this.icon, + required this.sortOrder, + }); + + final String menuCode; + final String label; + final String path; + final IconData icon; + final int sortOrder; } class _NavigationRail extends StatelessWidget { - const _NavigationRail({required this.currentLocation, required this.pages}); + const _NavigationRail({ + required this.currentLocation, + required this.pages, + required this.isLoading, + required this.errorMessage, + required this.onRetry, + }); final String currentLocation; - final List pages; + final List<_NavigationMenuItem> pages; + final bool isLoading; + final String? errorMessage; + final VoidCallback onRetry; @override Widget build(BuildContext context) { @@ -106,6 +250,9 @@ class _NavigationRail extends StatelessWidget { ), child: Column( children: [ + if (isLoading) const LinearProgressIndicator(minHeight: 2), + if (errorMessage != null) + _NavigationErrorBanner(message: errorMessage!, onRetry: onRetry), const SizedBox(height: 24), Expanded( child: ListView.builder( @@ -177,11 +324,17 @@ class _NavigationList extends StatelessWidget { required this.currentLocation, required this.onTap, required this.pages, + required this.isLoading, + required this.errorMessage, + required this.onRetry, }); final String currentLocation; final ValueChanged onTap; - final List pages; + final List<_NavigationMenuItem> pages; + final bool isLoading; + final String? errorMessage; + final VoidCallback onRetry; @override Widget build(BuildContext context) { @@ -189,9 +342,21 @@ class _NavigationList extends StatelessWidget { final themeController = ThemeControllerScope.of(context); return ListView.builder( - itemCount: pages.length + 1, + itemCount: pages.length + 2, itemBuilder: (context, index) { - if (index == pages.length) { + if (index == 0) { + return Column( + children: [ + if (isLoading) const LinearProgressIndicator(minHeight: 2), + if (errorMessage != null) + _NavigationErrorBanner( + message: errorMessage!, + onRetry: onRetry, + ), + ], + ); + } + if (index == pages.length + 1) { return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: _ThemeMenuButton( @@ -204,16 +369,11 @@ class _NavigationList extends StatelessWidget { ); } - final page = pages[index]; - final selected = index == selectedIndex; + final page = pages[index - 1]; + final selected = (index - 1) == selectedIndex; return ListTile( leading: Icon(page.icon), title: Text(page.label), - subtitle: Text( - page.summary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), selected: selected, selectedColor: Theme.of(context).colorScheme.primary, onTap: () => onTap(page.path), @@ -223,6 +383,50 @@ class _NavigationList extends StatelessWidget { } } +class _NavigationErrorBanner extends StatelessWidget { + const _NavigationErrorBanner({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(lucide.LucideIcons.info, size: 18, color: colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onErrorContainer, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton(onPressed: onRetry, child: const Text('재시도')), + ], + ), + ), + ), + ); + } +} + class _BrandTitle extends StatelessWidget { const _BrandTitle(); @@ -389,7 +593,7 @@ class _ThemeMenuButton extends StatelessWidget { } } -int _selectedIndex(String location, List pages) { +int _selectedIndex(String location, List<_NavigationMenuItem> pages) { final normalized = location.toLowerCase(); final exact = pages.indexWhere( (page) => normalized == page.path.toLowerCase(), @@ -404,19 +608,6 @@ int _selectedIndex(String location, List pages) { return prefix == -1 ? 0 : prefix; } -bool _hasPageAccess(PermissionManager manager, AppPageDescriptor page) { - final requirements = {page.path, ...page.extraRequiredResources}; - for (final resource in requirements) { - if (resource.isEmpty) { - continue; - } - if (!manager.can(resource, PermissionAction.view)) { - return false; - } - } - return true; -} - /// 계정 정보를 확인하고 로그아웃을 수행하는 상단바 버튼. class _AccountMenuButton extends StatelessWidget { const _AccountMenuButton({required this.service}); diff --git a/test/core/permissions/permission_manager_test.dart b/test/core/permissions/permission_manager_test.dart index f2e1a60..4d3fc42 100644 --- a/test/core/permissions/permission_manager_test.dart +++ b/test/core/permissions/permission_manager_test.dart @@ -1,10 +1,16 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:superport_v2/core/config/environment.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/permissions/permission_resources.dart'; void main() { group('PermissionManager', () { + tearDown(() { + Environment.setTestPermissions({}); + }); + test('서버 권한을 적용하면 해당 리소스 권한이 설정된다', () { final manager = PermissionManager(); @@ -58,7 +64,7 @@ void main() { ); }); - test('서버 권한을 초기화하면 환경 설정으로 되돌아간다', () { + test('서버 권한을 초기화하면 기본 거부로 되돌아간다', () { final manager = PermissionManager(); manager.applyServerPermissions({ PermissionResources.vendors: {PermissionAction.view}, @@ -70,11 +76,27 @@ void main() { manager.clearServerPermissions(); - // 환경 설정에 권한이 명시되지 않으면 기본적으로 허용한다. + // 환경 설정에 권한이 없으면 기본적으로 거부한다. expect( manager.can(PermissionResources.vendors, PermissionAction.edit), + isFalse, + ); + }); + + test('환경 권한을 명시하면 폴백을 허용한다', () { + Environment.setTestPermissions({ + PermissionResources.vendors: {'view'}, + }); + final manager = PermissionManager(); + + expect( + manager.can(PermissionResources.vendors, PermissionAction.view), isTrue, ); + expect( + manager.can(PermissionResources.vendors, PermissionAction.edit), + isFalse, + ); }); test('별칭 경로도 normalize되어 권한을 확인한다', () { @@ -88,7 +110,7 @@ void main() { isTrue, ); expect( - manager.can('/inventory/outbound', PermissionAction.edit), + manager.can(inventoryIssuesRoutePath, PermissionAction.edit), isFalse, ); }); diff --git a/test/core/permissions/permission_resources_test.dart b/test/core/permissions/permission_resources_test.dart index c0502f0..fe918ec 100644 --- a/test/core/permissions/permission_resources_test.dart +++ b/test/core/permissions/permission_resources_test.dart @@ -15,6 +15,25 @@ void main() { ); }); + test('백엔드 menus.route_path 전부를 인식한다', () { + expect( + PermissionResources.normalize('/inventory/receipts'), + PermissionResources.stockTransactions, + ); + expect( + PermissionResources.normalize('/inventory/vendors'), + PermissionResources.vendors, + ); + expect( + PermissionResources.normalize('/settings/group-permissions'), + PermissionResources.groupMenuPermissions, + ); + expect( + PermissionResources.normalize('/utilities/zipcodes'), + PermissionResources.postalSearch, + ); + }); + test('대소문자/공백/슬래시를 정리한다', () { expect( PermissionResources.normalize(' inventory/inbound/ '), diff --git a/test/features/inventory/inbound_page_test.dart b/test/features/inventory/inbound_page_test.dart index fabc69e..ef2055b 100644 --- a/test/features/inventory/inbound_page_test.dart +++ b/test/features/inventory/inbound_page_test.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/config/environment.dart'; +import 'package:superport_v2/core/navigation/route_paths.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/inventory/inbound/presentation/pages/inbound_page.dart'; @@ -13,6 +14,11 @@ import 'package:superport_v2/widgets/components/form_field.dart'; import '../../helpers/inventory_test_stubs.dart'; +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.tap(finder); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -40,10 +46,10 @@ void main() { }); final router = GoRouter( - initialLocation: '/inventory/inbound', + initialLocation: inventoryReceiptsRoutePath, routes: [ GoRoute( - path: '/inventory/inbound', + path: inventoryReceiptsRoutePath, builder: (context, state) => Scaffold(body: InboundPage(routeUri: state.uri)), ), @@ -68,13 +74,13 @@ void main() { await tester.enterText(find.byType(EditableText).first, 'TX-20240305-010'); await tester.pump(); - await tester.tap(find.widgetWithText(ShadButton, '검색 적용')); + await _tapVisible(tester, find.widgetWithText(ShadButton, '검색 적용')); await tester.pumpAndSettle(); expect(find.text('TX-20240305-010'), findsWidgets); expect(find.text('TX-20240301-001'), findsNothing); - await tester.tap(find.widgetWithText(ShadButton, '초기화')); + await _tapVisible(tester, find.widgetWithText(ShadButton, '초기화')); await tester.pumpAndSettle(); expect(find.text('TX-20240301-001'), findsWidgets); @@ -97,7 +103,7 @@ void main() { child: ShadTheme( data: SuperportShadTheme.light(), child: Scaffold( - body: InboundPage(routeUri: Uri.parse('/inventory/inbound')), + body: InboundPage(routeUri: Uri.parse(inventoryReceiptsRoutePath)), ), ), ), @@ -106,7 +112,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ShadButton, '입고 등록')); + await _tapVisible(tester, find.widgetWithText(ShadButton, '입고 등록')); await tester.pumpAndSettle(); final transactionField = find.byWidgetPredicate( @@ -140,12 +146,11 @@ void main() { ); await tester.enterText(firstProductInput, 'XR-5000'); await tester.pumpAndSettle(); - await tester.tap(find.text('XR-5000').last); + await _tapVisible(tester, find.text('XR-5000').last); await tester.pumpAndSettle(); final addLineButton = find.widgetWithText(ShadButton, '품목 추가'); - await tester.ensureVisible(addLineButton); - await tester.tap(addLineButton); + await _tapVisible(tester, addLineButton); await tester.pumpAndSettle(); final updatedProductFields = find.byType(InventoryProductAutocompleteField); @@ -157,12 +162,11 @@ void main() { ); await tester.enterText(secondProductInput, 'XR-5000'); await tester.pumpAndSettle(); - await tester.tap(find.text('XR-5000').last); + await _tapVisible(tester, find.text('XR-5000').last); await tester.pumpAndSettle(); final saveButton = find.widgetWithText(ShadButton, '저장'); - await tester.ensureVisible(saveButton); - await tester.tap(saveButton); + await _tapVisible(tester, saveButton); await tester.pumpAndSettle(); expect(find.text('동일 제품이 중복되었습니다.'), findsOneWidget); @@ -185,7 +189,7 @@ void main() { child: ShadTheme( data: SuperportShadTheme.light(), child: Scaffold( - body: InboundPage(routeUri: Uri.parse('/inventory/inbound')), + body: InboundPage(routeUri: Uri.parse(inventoryReceiptsRoutePath)), ), ), ), @@ -194,12 +198,12 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ShadButton, '입고 등록')); + await _tapVisible(tester, find.widgetWithText(ShadButton, '입고 등록')); await tester.pumpAndSettle(); expect(find.text('저장 시 자동 생성'), findsAtLeastNWidgets(2)); - await tester.tap(find.widgetWithText(ShadButton, '저장')); + await _tapVisible(tester, find.widgetWithText(ShadButton, '저장')); await tester.pump(); expect(find.text('거래번호를 입력하세요.'), findsNothing); @@ -225,7 +229,7 @@ void main() { child: ShadTheme( data: SuperportShadTheme.light(), child: Scaffold( - body: InboundPage(routeUri: Uri.parse('/inventory/inbound')), + body: InboundPage(routeUri: Uri.parse(inventoryReceiptsRoutePath)), ), ), ), @@ -234,14 +238,14 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.text('TX-20240301-001').first); + await _tapVisible(tester, find.text('TX-20240301-001').first); await tester.pumpAndSettle(); expect(find.text('입고 상세'), findsOneWidget); final editButton = find.widgetWithText(ShadButton, '수정').last; await tester.ensureVisible(editButton); - await tester.tap(editButton); + await _tapVisible(tester, editButton); await tester.pump(); await tester.pumpAndSettle(const Duration(milliseconds: 500)); diff --git a/test/features/inventory/inventory_pages_smoke_test.dart b/test/features/inventory/inventory_pages_smoke_test.dart index 7c28e16..4861d92 100644 --- a/test/features/inventory/inventory_pages_smoke_test.dart +++ b/test/features/inventory/inventory_pages_smoke_test.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/navigation/route_paths.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/inventory/inbound/presentation/pages/inbound_page.dart'; import 'package:superport_v2/features/inventory/outbound/presentation/pages/outbound_page.dart'; import 'package:superport_v2/features/inventory/rental/presentation/pages/rental_page.dart'; +import '../../helpers/test_permissions.dart'; + Widget _wrapInventoryPage(Widget child) { return PermissionScope( manager: PermissionManager(), @@ -20,6 +23,10 @@ Widget _wrapInventoryPage(Widget child) { } void main() { + setUp(() { + grantTestPermissions(); + }); + testWidgets( 'Inbound page reflects include state from route and closes dialog with Esc', (tester) async { @@ -33,7 +40,9 @@ void main() { await tester.pumpWidget( _wrapInventoryPage( - InboundPage(routeUri: Uri.parse('/inventory/inbound?include=lines')), + InboundPage( + routeUri: Uri.parse('$inventoryReceiptsRoutePath?include=lines'), + ), ), ); await tester.pumpAndSettle(); @@ -63,7 +72,9 @@ void main() { await tester.pumpWidget( _wrapInventoryPage( OutboundPage( - routeUri: Uri.parse('/inventory/outbound?include=lines,customers'), + routeUri: Uri.parse( + '$inventoryIssuesRoutePath?include=lines,customers', + ), ), ), ); @@ -85,7 +96,9 @@ void main() { await tester.pumpWidget( _wrapInventoryPage( - RentalPage(routeUri: Uri.parse('/inventory/rental?include=lines')), + RentalPage( + routeUri: Uri.parse('$inventoryRentalsRoutePath?include=lines'), + ), ), ); await tester.pumpAndSettle(); diff --git a/test/features/inventory/outbound_page_test.dart b/test/features/inventory/outbound_page_test.dart index fb21374..312717b 100644 --- a/test/features/inventory/outbound_page_test.dart +++ b/test/features/inventory/outbound_page_test.dart @@ -4,12 +4,18 @@ import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/config/environment.dart'; +import 'package:superport_v2/core/navigation/route_paths.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/inventory/outbound/presentation/pages/outbound_page.dart'; import '../../helpers/inventory_test_stubs.dart'; +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.tap(finder); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -44,7 +50,9 @@ void main() { child: ShadTheme( data: SuperportShadTheme.light(), child: Scaffold( - body: OutboundPage(routeUri: Uri.parse('/inventory/outbound')), + body: OutboundPage( + routeUri: Uri.parse(inventoryIssuesRoutePath), + ), ), ), ), @@ -53,12 +61,12 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ShadButton, '출고 등록')); + await _tapVisible(tester, find.widgetWithText(ShadButton, '출고 등록')); await tester.pumpAndSettle(); expect(find.text('저장 시 자동 생성'), findsAtLeastNWidgets(2)); - await tester.tap(find.widgetWithText(ShadButton, '저장')); + await _tapVisible(tester, find.widgetWithText(ShadButton, '저장')); await tester.pump(); expect(find.text('거래번호를 입력하세요.'), findsNothing); @@ -84,7 +92,9 @@ void main() { child: ShadTheme( data: SuperportShadTheme.light(), child: Scaffold( - body: OutboundPage(routeUri: Uri.parse('/inventory/outbound')), + body: OutboundPage( + routeUri: Uri.parse(inventoryIssuesRoutePath), + ), ), ), ), @@ -93,14 +103,14 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.text('TX-20240302-010').first); + await _tapVisible(tester, find.text('TX-20240302-010').first); await tester.pumpAndSettle(); expect(find.text('출고 상세'), findsOneWidget); final editButton = find.widgetWithText(ShadButton, '수정').last; await tester.ensureVisible(editButton); - await tester.tap(editButton); + await _tapVisible(tester, editButton); await tester.pump(); await tester.pumpAndSettle(const Duration(milliseconds: 500)); diff --git a/test/features/inventory/rental_page_test.dart b/test/features/inventory/rental_page_test.dart index c50fb3e..3e1a8c7 100644 --- a/test/features/inventory/rental_page_test.dart +++ b/test/features/inventory/rental_page_test.dart @@ -4,6 +4,7 @@ import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/config/environment.dart'; +import 'package:superport_v2/core/navigation/route_paths.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/inventory/rental/presentation/pages/rental_page.dart'; @@ -44,7 +45,9 @@ void main() { child: ShadTheme( data: SuperportShadTheme.light(), child: Scaffold( - body: RentalPage(routeUri: Uri.parse('/inventory/rental')), + body: RentalPage( + routeUri: Uri.parse(inventoryRentalsRoutePath), + ), ), ), ), @@ -84,7 +87,9 @@ void main() { child: ShadTheme( data: SuperportShadTheme.light(), child: Scaffold( - body: RentalPage(routeUri: Uri.parse('/inventory/rental')), + body: RentalPage( + routeUri: Uri.parse(inventoryRentalsRoutePath), + ), ), ), ), diff --git a/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png index ad00885d7ec6758746dcf93c358b8471faf6f9c8..796c18e27fd23e54bf1eb3293bf36d35cc5d5759 100644 GIT binary patch delta 11097 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 v3~)Zka}|B#IJlQ3v _pumpInventoryPage(WidgetTester tester) async { await tester.binding.setSurfaceSize(const Size(1600, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( _buildApp( InventorySummaryPage( @@ -153,7 +156,7 @@ Future _pumpInventoryPage(WidgetTester tester) async { } void main() { - final binding = TestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() { registerFallbackValue(const InventorySummaryFilter()); @@ -161,6 +164,7 @@ void main() { }); setUp(() async { + grantTestPermissions(includeWrites: false); final inventoryRepository = _MockInventoryRepository(); final warehouseRepository = _MockWarehouseRepository(); _registerDependencies( @@ -178,7 +182,6 @@ void main() { }); tearDown(() async { - await binding.setSurfaceSize(null); await GetIt.I.reset(); }); diff --git a/test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart b/test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart index e07e226..674de3f 100644 --- a/test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart +++ b/test/features/inventory/summary/presentation/pages/inventory_summary_page_test.dart @@ -21,6 +21,8 @@ import 'package:superport_v2/features/inventory/summary/presentation/pages/inven import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; +import '../../../../../helpers/test_permissions.dart'; + class _MockInventoryRepository extends Mock implements InventoryRepository {} class _MockWarehouseRepository extends Mock implements WarehouseRepository {} @@ -149,6 +151,11 @@ InventoryDetail _buildDetail() { ); } +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.tap(finder); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -157,6 +164,10 @@ void main() { registerFallbackValue(const InventoryDetailFilter()); }); + setUp(() { + grantTestPermissions(); + }); + tearDown(() async { await GetIt.I.reset(); }); @@ -201,7 +212,7 @@ void main() { expect(listCallCount, 2); - await tester.tap(find.bySemanticsLabel('자동 새로고침 전환')); + await _tapVisible(tester, find.bySemanticsLabel('자동 새로고침 전환')); await tester.pumpAndSettle(); await tester.pump(const Duration(seconds: 31)); @@ -236,13 +247,16 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.text('테스트 장비')); + await _tapVisible(tester, find.text('테스트 장비')); await tester.pumpAndSettle(); expect(find.text('창고 잔량'), findsOneWidget); expect(find.byType(LinearProgressIndicator), findsWidgets); expect(find.text('최근 이벤트'), findsOneWidget); - expect(find.textContaining('거래처: QA 파트너'), findsOneWidget); + expect( + find.textContaining('거래처: QA 파트너'), + findsAtLeastNWidgets(1), + ); }); testWidgets('권한 오류가 발생하면 경고 배너를 노출한다', (tester) async { @@ -311,7 +325,7 @@ void main() { ); await tester.pump(); - await tester.tap(find.byKey(const Key('inventory_filter_apply'))); + await _tapVisible(tester, find.byKey(const Key('inventory_filter_apply'))); await tester.pumpAndSettle(); expect(capturedFilters.length, greaterThanOrEqualTo(2)); @@ -393,14 +407,14 @@ void main() { expect(recordedFilters, isNotEmpty); // 첫 정렬: 총 수량 헤더 탭 → 오름차순 - await tester.tap(find.text('총 수량').first); + await _tapVisible(tester, find.text('총 수량').first); await tester.pumpAndSettle(); final ascFilter = recordedFilters.last; expect(ascFilter.sort, 'total_quantity'); expect(ascFilter.order, 'asc'); // 두 번째 탭 → 내림차순 - await tester.tap(find.text('총 수량').first); + await _tapVisible(tester, find.text('총 수량').first); await tester.pumpAndSettle(); final descFilter = recordedFilters.last; expect(descFilter.sort, 'total_quantity'); diff --git a/test/features/login/presentation/pages/login_page_test.dart b/test/features/login/presentation/pages/login_page_test.dart index b4925ee..0f9740b 100644 --- a/test/features/login/presentation/pages/login_page_test.dart +++ b/test/features/login/presentation/pages/login_page_test.dart @@ -8,7 +8,8 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/services/token_storage.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/permissions/permission_resources.dart'; import 'package:superport_v2/features/auth/application/auth_service.dart'; @@ -22,6 +23,9 @@ import 'package:superport_v2/features/masters/group/domain/repositories/group_re 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 '../../../../helpers/test_app.dart'; +import '../../../../helpers/test_permissions.dart'; + class _MockGroupRepository extends Mock implements GroupRepository {} class _MockGroupPermissionRepository extends Mock @@ -70,6 +74,7 @@ void main() { late AuthService authService; setUp(() { + grantTestPermissions(); authRepository = _MockAuthRepository(); tokenStorage = _FakeTokenStorage(); authService = AuthService( @@ -127,7 +132,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -139,9 +144,9 @@ void main() { group: GroupPermissionGroup(id: 1, groupName: '관리자'), menu: GroupPermissionMenu( id: 10, - menuCode: 'INBOUND', + menuCode: 'inventory.receipts', menuName: '입고', - path: '/inventory/inbound', + path: inventoryReceiptsRoutePath, ), canCreate: true, canRead: true, @@ -170,10 +175,15 @@ void main() { ], ); + final catalog = createTestMenuCatalog(); + await tester.pumpWidget( PermissionScope( manager: manager, - child: ShadApp.router(routerConfig: router), + child: MenuCatalogScope( + catalog: catalog, + child: ShadApp.router(routerConfig: router), + ), ), ); @@ -233,10 +243,15 @@ void main() { ], ); + final catalog = createTestMenuCatalog(); + await tester.pumpWidget( PermissionScope( manager: manager, - child: ShadApp.router(routerConfig: router), + child: MenuCatalogScope( + catalog: catalog, + child: ShadApp.router(routerConfig: router), + ), ), ); diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart index 85c2244..387be92 100644 --- a/test/features/masters/customer/presentation/pages/customer_page_test.dart +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart'; import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart'; import 'package:superport_v2/features/masters/customer/presentation/pages/customer_page.dart'; @@ -43,7 +44,7 @@ void main() { dotenv.testLoad(fileInput: 'FEATURE_CUSTOMERS_ENABLED=false\n'); await tester.pumpWidget( - _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + _buildApp(CustomerPage(routeUri: Uri(path: inventoryCustomersRoutePath))), ); await tester.pump(); @@ -89,7 +90,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + _buildApp(CustomerPage(routeUri: Uri(path: inventoryCustomersRoutePath))), ); await tester.pumpAndSettle(); @@ -126,7 +127,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + _buildApp(CustomerPage(routeUri: Uri(path: inventoryCustomersRoutePath))), ); await tester.pumpAndSettle(); @@ -159,7 +160,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + _buildApp(CustomerPage(routeUri: Uri(path: inventoryCustomersRoutePath))), ); await tester.pumpAndSettle(); @@ -232,7 +233,7 @@ void main() { }); await tester.pumpWidget( - _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + _buildApp(CustomerPage(routeUri: Uri(path: inventoryCustomersRoutePath))), ); await tester.pumpAndSettle(); @@ -297,7 +298,7 @@ void main() { Center( child: SizedBox( width: 320, - child: CustomerPage(routeUri: Uri(path: '/masters/customers')), + child: CustomerPage(routeUri: Uri(path: inventoryCustomersRoutePath)), ), ), ), 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 c4989d7..75c869f 100644 --- a/test/features/masters/group/presentation/controllers/group_controller_test.dart +++ b/test/features/masters/group/presentation/controllers/group_controller_test.dart @@ -144,7 +144,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -183,7 +183,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: sampleGroup.id, - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), diff --git a/test/features/masters/group_permission/application/permission_synchronizer_test.dart b/test/features/masters/group_permission/application/permission_synchronizer_test.dart index 640cb75..cba4e95 100644 --- a/test/features/masters/group_permission/application/permission_synchronizer_test.dart +++ b/test/features/masters/group_permission/application/permission_synchronizer_test.dart @@ -2,11 +2,15 @@ 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/navigation/menu_catalog.dart'; +import 'package:superport_v2/core/navigation/route_paths.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'; +import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; +import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; class _MockGroupPermissionRepository extends Mock implements GroupPermissionRepository {} @@ -29,9 +33,9 @@ void main() { group: GroupPermissionGroup(id: 1, groupName: '관리자'), menu: GroupPermissionMenu( id: 10, - menuCode: 'INBOUND', + menuCode: 'inventory.receipts', menuName: '입고', - path: '/inventory/inbound', + path: inventoryReceiptsRoutePath, ), canCreate: true, canRead: true, @@ -44,9 +48,9 @@ void main() { group: GroupPermissionGroup(id: 1, groupName: '관리자'), menu: GroupPermissionMenu( id: 11, - menuCode: 'OUTBOUND', + menuCode: 'inventory.issues', menuName: '출고', - path: '/inventory/outbound', + path: inventoryIssuesRoutePath, ), canCreate: false, canRead: true, @@ -59,7 +63,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -88,7 +92,7 @@ void main() { page: any(named: 'page'), pageSize: 1, groupId: 1, - menuId: null, + menuCode: null, isActive: true, includeDeleted: false, ), @@ -131,7 +135,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -166,5 +170,160 @@ void main() { contains(PermissionAction.view), ); }); + + test('menu_code 경로 불일치는 Catalog 기준으로 보정한다', () async { + const legacyPath = '/legacy/inbound'; + final repository = _MockGroupPermissionRepository(); + final manager = PermissionManager(); + final catalog = MenuCatalog(repository: _DummyMenuRepository()); + catalog.replaceAll([ + MenuItem( + id: 10, + menuCode: 'inventory.receipts', + menuName: '입고', + path: inventoryReceiptsRoutePath, + ), + ]); + final synchronizer = PermissionSynchronizer( + repository: repository, + manager: manager, + menuCatalog: catalog, + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuCode: any(named: 'menuCode'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async { + return PaginatedResult( + items: [ + GroupPermission( + id: 1, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 10, + menuCode: 'inventory.receipts', + menuName: '입고', + path: legacyPath, + ), + canRead: true, + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + await synchronizer.syncForGroup(1); + + expect( + manager.can(inventoryReceiptsRoutePath, PermissionAction.view), + isTrue, + ); + expect( + manager.can(legacyPath, PermissionAction.view), + isFalse, + ); + }); + + test('menu path가 비어도 Catalog 경로를 적용한다', () async { + final repository = _MockGroupPermissionRepository(); + final manager = PermissionManager(); + final catalog = MenuCatalog(repository: _DummyMenuRepository()); + catalog.replaceAll([ + MenuItem( + id: 20, + menuCode: 'inventory.issues', + menuName: '출고', + path: inventoryIssuesRoutePath, + ), + ]); + final synchronizer = PermissionSynchronizer( + repository: repository, + manager: manager, + menuCatalog: catalog, + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + groupId: any(named: 'groupId'), + menuCode: any(named: 'menuCode'), + isActive: any(named: 'isActive'), + includeDeleted: any(named: 'includeDeleted'), + ), + ).thenAnswer((_) async { + return PaginatedResult( + items: [ + GroupPermission( + id: 2, + group: GroupPermissionGroup(id: 1, groupName: '관리자'), + menu: GroupPermissionMenu( + id: 20, + menuCode: 'inventory.issues', + menuName: '출고', + ), + canRead: true, + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + await synchronizer.syncForGroup(1); + + expect( + manager.can(inventoryIssuesRoutePath, PermissionAction.view), + isTrue, + ); + }); }); } + +class _DummyMenuRepository implements MenuRepository { + @override + Future create(MenuInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? parentId, + bool? isActive, + bool includeDeleted = false, + }) async { + return PaginatedResult( + items: const [], + page: page, + pageSize: pageSize, + total: 0, + ); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future update(int id, MenuInput input) { + throw UnimplementedError(); + } +} 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 index 40148da..4faa0af 100644 --- a/test/features/masters/group_permission/domain/group_permission_mapper_test.dart +++ b/test/features/masters/group_permission/domain/group_permission_mapper_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:superport_v2/core/navigation/route_paths.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'; @@ -13,9 +14,9 @@ void main() { group: GroupPermissionGroup(id: 1, groupName: '관리자'), menu: GroupPermissionMenu( id: 10, - menuCode: 'INBOUND', + menuCode: 'inventory.receipts', menuName: '입고', - path: '/inventory/inbound', + path: inventoryReceiptsRoutePath, ), canCreate: true, canRead: true, @@ -27,9 +28,9 @@ void main() { group: GroupPermissionGroup(id: 1, groupName: '관리자'), menu: GroupPermissionMenu( id: 11, - menuCode: 'OUTBOUND', + menuCode: 'inventory.issues', menuName: '출고', - path: '/inventory/outbound', + path: inventoryIssuesRoutePath, ), canCreate: false, canRead: true, 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 1211845..8fa5dbf 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 @@ -65,7 +65,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -136,7 +136,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -152,7 +152,7 @@ void main() { test('필터 값을 전달한다', () async { controller.updateGroupFilter(2); - controller.updateMenuFilter(5); + controller.updateMenuFilter('MENU005'); controller.updateStatusFilter(GroupPermissionStatusFilter.inactiveOnly); controller.updateIncludeDeleted(true); @@ -163,7 +163,7 @@ void main() { page: 3, pageSize: 20, groupId: 2, - menuId: 5, + menuCode: 'MENU005', isActive: false, includeDeleted: true, ), @@ -176,7 +176,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -190,12 +190,12 @@ void main() { test('필터 업데이트 메서드', () { controller.updateGroupFilter(3); - controller.updateMenuFilter(7); + controller.updateMenuFilter('MENU007'); controller.updateStatusFilter(GroupPermissionStatusFilter.activeOnly); controller.updateIncludeDeleted(true); expect(controller.groupFilter, 3); - expect(controller.menuFilter, 7); + expect(controller.menuFilter, 'MENU007'); expect(controller.statusFilter, GroupPermissionStatusFilter.activeOnly); expect(controller.includeDeleted, isTrue); }); @@ -207,14 +207,14 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), ).thenAnswer((_) async => createResult([samplePermission])); }); - final input = GroupPermissionInput(groupId: 1, menuId: 2); + final input = GroupPermissionInput(groupId: 1, menuCode: 'MENU002'); test('create 성공', () async { when( @@ -265,7 +265,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -282,7 +282,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: true, ), 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 420d2fd..0926d4c 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 @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.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'; @@ -26,15 +27,23 @@ class _MockMenuRepository extends Mock implements MenuRepository {} class _FakeGroupPermissionInput extends Fake implements GroupPermissionInput {} Widget _buildApp(Widget child) { + final menuRepository = GetIt.I.isRegistered() + ? GetIt.I() + : _MockMenuRepository(); + final catalog = MenuCatalog(repository: menuRepository); + addTearDown(catalog.dispose); return PermissionScope( manager: PermissionManager(), - child: MaterialApp( - home: ShadTheme( - data: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, + child: MenuCatalogScope( + catalog: catalog, + child: MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), ), - child: Scaffold(body: child), ), ), ); @@ -122,7 +131,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -161,7 +170,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -194,7 +203,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -242,8 +251,8 @@ void main() { groupName: '관리자', ), menu: GroupPermissionMenu( - id: capturedInput!.menuId, - menuCode: 'DASHBOARD', + id: 10, + menuCode: capturedInput!.menuCode, menuName: '대시보드', path: '/dashboard', ), @@ -292,7 +301,7 @@ void main() { expect(capturedInput, isNotNull); expect(capturedInput?.groupId, 1); - expect(capturedInput?.menuId, 10); + expect(capturedInput?.menuCode, 'MENU001'); expect(capturedInput?.canCreate, isTrue); expect(capturedInput?.canUpdate, isTrue); expect(find.byType(Dialog), findsNothing); diff --git a/test/features/masters/menu/presentation/pages/menu_page_test.dart b/test/features/masters/menu/presentation/pages/menu_page_test.dart index 558fbfc..96426eb 100644 --- a/test/features/masters/menu/presentation/pages/menu_page_test.dart +++ b/test/features/masters/menu/presentation/pages/menu_page_test.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; import 'package:superport_v2/features/masters/menu/presentation/pages/menu_page.dart'; @@ -16,13 +17,21 @@ class _MockMenuRepository extends Mock implements MenuRepository {} class _FakeMenuInput extends Fake implements MenuInput {} Widget _buildApp(Widget child) { + final menuRepository = GetIt.I.isRegistered() + ? GetIt.I() + : _MockMenuRepository(); + final catalog = MenuCatalog(repository: menuRepository); + addTearDown(catalog.dispose); return MaterialApp( - home: ShadTheme( - data: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, + home: MenuCatalogScope( + catalog: catalog, + child: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), ), - child: Scaffold(body: child), ), ); } 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 e61ddf6..fe9bbf9 100644 --- a/test/features/masters/product/presentation/pages/product_page_test.dart +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/features/masters/product/domain/entities/product.dart'; import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart'; import 'package:superport_v2/features/masters/product/presentation/pages/product_page.dart'; @@ -51,7 +52,7 @@ void main() { dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=false\n'); await tester.pumpWidget( - _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + _buildApp(ProductPage(routeUri: Uri(path: inventoryProductsRoutePath))), ); await tester.pump(); @@ -138,7 +139,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + _buildApp(ProductPage(routeUri: Uri(path: inventoryProductsRoutePath))), ); await tester.pumpAndSettle(); @@ -175,7 +176,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + _buildApp(ProductPage(routeUri: Uri(path: inventoryProductsRoutePath))), ); await tester.pumpAndSettle(); @@ -202,7 +203,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + _buildApp(ProductPage(routeUri: Uri(path: inventoryProductsRoutePath))), ); await tester.pumpAndSettle(); @@ -278,7 +279,7 @@ void main() { }); await tester.pumpWidget( - _buildApp(ProductPage(routeUri: Uri(path: '/masters/products'))), + _buildApp(ProductPage(routeUri: Uri(path: inventoryProductsRoutePath))), ); await tester.pumpAndSettle(); @@ -349,7 +350,7 @@ void main() { Center( child: SizedBox( width: 320, - child: ProductPage(routeUri: Uri(path: '/masters/products')), + child: ProductPage(routeUri: Uri(path: inventoryProductsRoutePath)), ), ), ), 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 76ca33c..138c6c5 100644 --- a/test/features/masters/user/presentation/controllers/user_controller_test.dart +++ b/test/features/masters/user/presentation/controllers/user_controller_test.dart @@ -59,7 +59,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), @@ -252,7 +252,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: sampleUser.group!.id, - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), 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 9764e58..0cdcdd6 100644 --- a/test/features/masters/user/presentation/pages/user_page_test.dart +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/test_app.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; @@ -28,20 +29,7 @@ class _MockGroupPermissionRepository extends Mock class _FakeUserInput extends Fake implements UserInput {} -Widget _buildApp(Widget child) { - return PermissionScope( - manager: PermissionManager(), - child: MaterialApp( - home: ShadTheme( - data: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, - ), - child: Scaffold(body: child), - ), - ), - ); -} +Widget _buildApp(Widget child) => buildTestApp(child); void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -59,7 +47,7 @@ void main() { dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=false\n'); await tester.pumpWidget(_buildApp(const UserPage())); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('사용자(사원) 관리'), findsOneWidget); expect(find.text('테이블 리스트'), findsOneWidget); @@ -104,7 +92,7 @@ void main() { page: any(named: 'page'), pageSize: any(named: 'pageSize'), groupId: any(named: 'groupId'), - menuId: any(named: 'menuId'), + menuCode: any(named: 'menuCode'), isActive: any(named: 'isActive'), includeDeleted: any(named: 'includeDeleted'), ), 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 0bc68a8..52e5875 100644 --- a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart +++ b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart'; import 'package:superport_v2/features/masters/vendor/presentation/pages/vendor_page.dart'; @@ -43,7 +44,7 @@ void main() { dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=false\n'); await tester.pumpWidget( - _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + _buildApp(VendorPage(routeUri: Uri(path: inventoryVendorsRoutePath))), ); await tester.pump(); @@ -75,7 +76,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + _buildApp(VendorPage(routeUri: Uri(path: inventoryVendorsRoutePath))), ); await tester.pumpAndSettle(); @@ -107,7 +108,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + _buildApp(VendorPage(routeUri: Uri(path: inventoryVendorsRoutePath))), ); await tester.pumpAndSettle(); @@ -136,7 +137,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + _buildApp(VendorPage(routeUri: Uri(path: inventoryVendorsRoutePath))), ); await tester.pumpAndSettle(); @@ -192,7 +193,7 @@ void main() { }); await tester.pumpWidget( - _buildApp(VendorPage(routeUri: Uri(path: '/masters/vendors'))), + _buildApp(VendorPage(routeUri: Uri(path: inventoryVendorsRoutePath))), ); await tester.pumpAndSettle(); @@ -244,7 +245,7 @@ void main() { Center( child: SizedBox( width: 320, - child: VendorPage(routeUri: Uri(path: '/masters/vendors')), + child: VendorPage(routeUri: Uri(path: inventoryVendorsRoutePath)), ), ), ), 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 6155330..896501d 100644 --- a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart +++ b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; import 'package:superport_v2/features/masters/warehouse/presentation/pages/warehouse_page.dart'; @@ -48,7 +49,7 @@ void main() { dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=false\n'); await tester.pumpWidget( - _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + _buildApp(WarehousePage(routeUri: Uri(path: inventoryWarehousesRoutePath))), ); await tester.pump(); @@ -112,7 +113,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + _buildApp(WarehousePage(routeUri: Uri(path: inventoryWarehousesRoutePath))), ); await tester.pumpAndSettle(); @@ -147,7 +148,7 @@ void main() { ); await tester.pumpWidget( - _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + _buildApp(WarehousePage(routeUri: Uri(path: inventoryWarehousesRoutePath))), ); await tester.pumpAndSettle(); @@ -205,7 +206,7 @@ void main() { }); await tester.pumpWidget( - _buildApp(WarehousePage(routeUri: Uri(path: '/masters/warehouses'))), + _buildApp(WarehousePage(routeUri: Uri(path: inventoryWarehousesRoutePath))), ); await tester.pumpAndSettle(); @@ -278,7 +279,7 @@ void main() { Center( child: SizedBox( width: 320, - child: WarehousePage(routeUri: Uri(path: '/masters/warehouses')), + child: WarehousePage(routeUri: Uri(path: inventoryWarehousesRoutePath)), ), ), ), diff --git a/test/helpers/inventory_test_stubs.dart b/test/helpers/inventory_test_stubs.dart index b9ca3e0..6356079 100644 --- a/test/helpers/inventory_test_stubs.dart +++ b/test/helpers/inventory_test_stubs.dart @@ -11,6 +11,8 @@ import 'package:superport_v2/features/masters/product/domain/repositories/produc import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; +import 'test_permissions.dart'; + const int _inboundTypeId = 100; const int _outboundTypeId = 200; const int _rentalRentTypeId = 300; @@ -42,6 +44,7 @@ InventoryTestStubConfig _stubConfig = const InventoryTestStubConfig(); void registerInventoryTestStubs([ InventoryTestStubConfig config = const InventoryTestStubConfig(), ]) { + grantTestPermissions(); _stubConfig = config; lastTransactionListFilter = null; final lookup = _StubInventoryLookupRepository( diff --git a/test/helpers/test_app.dart b/test/helpers/test_app.dart index 8c7efa3..d8b357e 100644 --- a/test/helpers/test_app.dart +++ b/test/helpers/test_app.dart @@ -1,17 +1,124 @@ import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; +import 'package:superport_v2/core/navigation/route_paths.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/masters/menu/domain/entities/menu.dart'; +import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; Widget buildTestApp(Widget child, {PermissionManager? permissionManager}) { + final catalog = createTestMenuCatalog(); return PermissionScope( manager: permissionManager ?? PermissionManager(), - child: ShadApp( - debugShowCheckedModeBanner: false, - theme: SuperportShadTheme.light(), - darkTheme: SuperportShadTheme.dark(), - home: ScaffoldMessenger(child: Scaffold(body: child)), + child: MenuCatalogScope( + catalog: catalog, + child: ShadApp( + debugShowCheckedModeBanner: false, + theme: SuperportShadTheme.light(), + darkTheme: SuperportShadTheme.dark(), + home: ScaffoldMessenger(child: Scaffold(body: child)), + ), ), ); } + +MenuCatalog createTestMenuCatalog({ + List? menus, + MenuRepository? repository, +}) { + final catalog = MenuCatalog( + repository: repository ?? _TestMenuRepository(), + ); + final seedMenus = menus ?? _defaultMenuItems; + catalog.replaceAll(List.from(seedMenus)); + addTearDown(catalog.dispose); + return catalog; +} + +final List _defaultMenuItems = List.unmodifiable([ + MenuItem( + id: 1, + menuCode: 'dashboard', + menuName: '대시보드', + path: dashboardRoutePath, + displayOrder: 10, + ), + MenuItem( + id: 2, + menuCode: 'inventory.summary', + menuName: '재고 현황', + path: inventorySummaryRoutePath, + displayOrder: 20, + ), + MenuItem( + id: 3, + menuCode: 'inventory.receipts', + menuName: '입고', + path: inventoryReceiptsRoutePath, + displayOrder: 21, + ), + MenuItem( + id: 4, + menuCode: 'inventory.issues', + menuName: '출고', + path: inventoryIssuesRoutePath, + displayOrder: 22, + ), + MenuItem( + id: 5, + menuCode: 'settings.users', + menuName: '사용자 관리', + path: settingsUsersRoutePath, + displayOrder: 40, + ), + MenuItem( + id: 6, + menuCode: 'settings.group_permissions', + menuName: '그룹 메뉴 권한', + path: settingsGroupPermissionsRoutePath, + displayOrder: 43, + ), +]); + +class _TestMenuRepository implements MenuRepository { + @override + Future create(MenuInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? parentId, + bool? isActive, + bool includeDeleted = false, + }) async { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 0, + total: 0, + ); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future update(int id, MenuInput input) { + throw UnimplementedError(); + } +} diff --git a/test/helpers/test_permissions.dart b/test/helpers/test_permissions.dart new file mode 100644 index 0000000..038de8c --- /dev/null +++ b/test/helpers/test_permissions.dart @@ -0,0 +1,29 @@ +import 'package:superport_v2/core/config/environment.dart'; +import 'package:superport_v2/core/permissions/permission_resources.dart'; + +/// 통합 테스트에서 버튼/액션이 숨겨지지 않도록 기본 권한을 허용한다. +void grantTestPermissions({bool includeWrites = true}) { + final commonActions = includeWrites ? {'all'} : {'view'}; + Environment.setTestPermissions({ + PermissionResources.dashboard: commonActions, + PermissionResources.stockTransactions: commonActions, + PermissionResources.approvals: commonActions, + PermissionResources.approvalSteps: commonActions, + PermissionResources.approvalHistories: commonActions, + PermissionResources.approvalTemplates: commonActions, + PermissionResources.inventorySummary: commonActions, + PermissionResources.groupMenuPermissions: commonActions, + PermissionResources.vendors: commonActions, + PermissionResources.products: commonActions, + PermissionResources.warehouses: commonActions, + PermissionResources.customers: commonActions, + PermissionResources.users: commonActions, + PermissionResources.groups: commonActions, + PermissionResources.menus: commonActions, + PermissionResources.postalSearch: commonActions, + PermissionResources.reports: commonActions, + PermissionResources.reportsTransactions: commonActions, + PermissionResources.reportsApprovals: commonActions, + PermissionResources.inventoryScope: {'view'}, + }); +} diff --git a/test/navigation/navigation_flow_test.dart b/test/navigation/navigation_flow_test.dart index dc59885..79e4b92 100644 --- a/test/navigation/navigation_flow_test.dart +++ b/test/navigation/navigation_flow_test.dart @@ -6,7 +6,8 @@ import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/config/environment.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/theme/superport_shad_theme.dart'; import 'package:superport_v2/core/services/token_storage.dart'; @@ -21,6 +22,8 @@ import 'package:superport_v2/features/masters/group/domain/repositories/group_re 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 '../helpers/test_app.dart'; + GoRouter _createTestRouter() { return GoRouter( initialLocation: loginRoutePath, @@ -34,15 +37,15 @@ GoRouter _createTestRouter() { builder: (context, state) => const _TestDashboardPage(), ), GoRoute( - path: '/inventory/inbound', + path: inventoryReceiptsRoutePath, builder: (context, state) => const _PlaceholderPage(title: '입고 화면'), ), GoRoute( - path: '/inventory/outbound', + path: inventoryIssuesRoutePath, builder: (context, state) => const _PlaceholderPage(title: '출고 화면'), ), GoRoute( - path: '/inventory/rental', + path: inventoryRentalsRoutePath, builder: (context, state) => const _PlaceholderPage(title: '대여 화면'), ), ], @@ -50,19 +53,23 @@ GoRouter _createTestRouter() { } class _TestApp extends StatelessWidget { - const _TestApp({required this.router}); + const _TestApp({required this.router, required this.catalog}); final GoRouter router; + final MenuCatalog catalog; @override Widget build(BuildContext context) { return PermissionScope( manager: PermissionManager(), - child: ShadApp.router( - routerConfig: router, - debugShowCheckedModeBanner: false, - theme: SuperportShadTheme.light(), - darkTheme: SuperportShadTheme.dark(), + child: MenuCatalogScope( + catalog: catalog, + child: ShadApp.router( + routerConfig: router, + debugShowCheckedModeBanner: false, + theme: SuperportShadTheme.light(), + darkTheme: SuperportShadTheme.dark(), + ), ), ); } @@ -90,15 +97,15 @@ class _TestDashboardPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ TextButton( - onPressed: () => context.go('/inventory/inbound'), + onPressed: () => context.go(inventoryReceiptsRoutePath), child: const Text('입고로 이동'), ), TextButton( - onPressed: () => context.go('/inventory/outbound'), + onPressed: () => context.go(inventoryIssuesRoutePath), child: const Text('출고로 이동'), ), TextButton( - onPressed: () => context.go('/inventory/rental'), + onPressed: () => context.go(inventoryRentalsRoutePath), child: const Text('대여로 이동'), ), ], @@ -191,7 +198,7 @@ class _StubGroupPermissionRepository implements GroupPermissionRepository { int page = 1, int pageSize = 20, int? groupId, - int? menuId, + String? menuCode, bool? isActive, bool includeDeleted = false, }) async { @@ -320,7 +327,8 @@ void main() { ); final router = _createTestRouter(); - await tester.pumpWidget(_TestApp(router: router)); + final catalog = createTestMenuCatalog(); + await tester.pumpWidget(_TestApp(router: router, catalog: catalog)); await tester.pumpAndSettle(); expect(find.text('Superport v2 로그인'), findsOneWidget); diff --git a/test/widgets/app_shell_test.dart b/test/widgets/app_shell_test.dart index 6b382b6..1c55f44 100644 --- a/test/widgets/app_shell_test.dart +++ b/test/widgets/app_shell_test.dart @@ -6,7 +6,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; -import 'package:superport_v2/core/constants/app_sections.dart'; +import 'package:superport_v2/core/navigation/menu_catalog.dart'; +import 'package:superport_v2/core/navigation/route_paths.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/theme/superport_shad_theme.dart'; import 'package:superport_v2/core/theme/theme_controller.dart'; @@ -17,6 +18,8 @@ import 'package:superport_v2/features/auth/domain/entities/auth_session.dart'; import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; import 'package:superport_v2/features/auth/domain/entities/login_request.dart'; import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart'; +import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; +import 'package:superport_v2/features/masters/menu/domain/repositories/menu_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/widgets/app_shell.dart'; @@ -209,6 +212,15 @@ void main() { Future _pumpAppShell(WidgetTester tester) async { final themeController = ThemeController(); final permissionManager = PermissionManager(); + if (!GetIt.I.isRegistered()) { + GetIt.I.registerSingleton(_StubMenuRepository()); + } + final menuCatalog = MenuCatalog(repository: GetIt.I()); + await menuCatalog.refresh(); + if (GetIt.I.isRegistered()) { + GetIt.I.unregister(); + } + GetIt.I.registerSingleton(menuCatalog); final router = GoRouter( initialLocation: dashboardRoutePath, routes: [ @@ -231,18 +243,22 @@ Future _pumpAppShell(WidgetTester tester) async { addTearDown(themeController.dispose); addTearDown(permissionManager.dispose); + addTearDown(menuCatalog.dispose); addTearDown(router.dispose); await tester.pumpWidget( PermissionScope( manager: permissionManager, - child: ThemeControllerScope( - controller: themeController, - child: ShadApp.router( - routerConfig: router, - theme: SuperportShadTheme.light(), - darkTheme: SuperportShadTheme.dark(), - debugShowCheckedModeBanner: false, + child: MenuCatalogScope( + catalog: menuCatalog, + child: ThemeControllerScope( + controller: themeController, + child: ShadApp.router( + routerConfig: router, + theme: SuperportShadTheme.light(), + darkTheme: SuperportShadTheme.dark(), + debugShowCheckedModeBanner: false, + ), ), ), ), @@ -289,6 +305,66 @@ class _FakeAuthRepository implements AuthRepository { Future refresh(String refreshToken) async => session; } +class _StubMenuRepository implements MenuRepository { + @override + Future create(MenuInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? parentId, + bool? isActive, + bool includeDeleted = false, + }) async { + return PaginatedResult( + items: [ + MenuItem( + id: 1, + menuCode: 'dashboard', + menuName: '대시보드', + path: dashboardRoutePath, + displayOrder: 10, + ), + MenuItem( + id: 2, + menuCode: 'inventory.receipts', + menuName: '입고', + path: inventoryReceiptsRoutePath, + displayOrder: 20, + parent: MenuSummary( + id: 10, + menuName: '재고', + menuCode: 'inventory', + path: inventorySummaryRoutePath, + ), + ), + ], + page: 1, + pageSize: 2, + total: 2, + ); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } + + @override + Future update(int id, MenuInput input) { + throw UnimplementedError(); + } +} + class _MemoryTokenStorage implements TokenStorage { String? _access; String? _refresh;