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 ad00885..796c18e 100644 Binary files a/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png and b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_detail_sheet.png differ diff --git a/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png index ad00885..796c18e 100644 Binary files a/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png and b/test/features/inventory/summary/presentation/pages/goldens/inventory_summary_page_default.png differ diff --git a/test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart b/test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart index e502af7..1e99646 100644 --- a/test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart +++ b/test/features/inventory/summary/presentation/pages/inventory_summary_page_golden_test.dart @@ -20,6 +20,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 {} @@ -141,6 +143,7 @@ void _stubWarehouseList(_MockWarehouseRepository repository) { Future _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;