feat(menu-permissions): 메뉴 API 연동으로 사이드바 권한 정비
- .env.development.example과 lib/core/config/environment.dart, lib/core/permissions/permission_manager.dart에서 PERMISSION__ 폴백을 view 전용으로 좁히고 기본 정책을 명시적으로 거부하도록 재정비했다 - lib/core/navigation/*, lib/core/routing/app_router.dart, lib/widgets/app_shell.dart, lib/main.dart에서 메뉴 매니페스트·카탈로그를 도입해 /menus 응답을 캐싱하고 라우터·사이드바·Breadcrumb가 동일 menu_code/route_path를 쓰도록 리팩터링했다 - lib/core/permissions/permission_resources.dart와 그룹 권한/메뉴 마스터 모듈을 menu_code 기반 CRUD 및 Catalog 경로 정합성 검사로 전환하고 PermissionSynchronizer·PermissionBootstrapper를 확장했다 - test/helpers/test_permissions.dart, test/widgets/app_shell_test.dart 등 신규 구조를 반영하는 테스트·골든과 doc/frontend_menu_permission_tasks.md 문서를 보강했다
This commit is contained in:
@@ -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<String, Set<String>> permissions) {
|
||||
_permissions
|
||||
..clear()
|
||||
..addAll(
|
||||
permissions.map(
|
||||
(key, value) => MapEntry(
|
||||
key.toLowerCase(),
|
||||
value.map((action) => action.toLowerCase()).toSet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> extraRequiredResources;
|
||||
}
|
||||
|
||||
/// 메뉴 섹션을 나타내는 데이터 클래스.
|
||||
class AppSectionDescriptor {
|
||||
const AppSectionDescriptor({required this.label, required this.pages});
|
||||
|
||||
final String label;
|
||||
final List<AppPageDescriptor> pages;
|
||||
}
|
||||
|
||||
/// 로그인 라우트 경로.
|
||||
const loginRoutePath = '/login';
|
||||
|
||||
/// 대시보드 라우트 경로.
|
||||
const dashboardRoutePath = '/dashboard';
|
||||
|
||||
/// 재고 현황 라우트 경로.
|
||||
const inventorySummaryRoutePath = '/inventory/summary';
|
||||
|
||||
/// 네비게이션 구성을 정의한 섹션 목록.
|
||||
const appSections = <AppSectionDescriptor>[
|
||||
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<AppPageDescriptor> get allAppPages => [
|
||||
for (final section in appSections) ...section.pages,
|
||||
];
|
||||
130
lib/core/navigation/menu_catalog.dart
Normal file
130
lib/core/navigation/menu_catalog.dart
Normal file
@@ -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<MenuItem> _menus = const [];
|
||||
bool _isLoading = false;
|
||||
bool _initialized = false;
|
||||
String? _errorMessage;
|
||||
Completer<void>? _pendingLoad;
|
||||
|
||||
/// 최신 메뉴 목록.
|
||||
List<MenuItem> get menus => _menus;
|
||||
|
||||
/// 현재 로딩 상태.
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// 최초 로드 여부.
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// 마지막 로드 실패 메시지.
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
/// 캐시된 메뉴를 반환하며 필요 시 자동으로 로딩한다.
|
||||
Future<List<MenuItem>> ensureLoaded({bool forceRefresh = false}) async {
|
||||
if (_initialized && !forceRefresh) {
|
||||
return _menus;
|
||||
}
|
||||
await refresh();
|
||||
return _menus;
|
||||
}
|
||||
|
||||
/// 백엔드에서 메뉴를 다시 불러와 캐시를 갱신한다.
|
||||
Future<void> refresh() async {
|
||||
if (_pendingLoad != null) {
|
||||
await _pendingLoad!.future;
|
||||
if (_errorMessage != null) {
|
||||
throw StateError(_errorMessage!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final completer = Completer<void>();
|
||||
_pendingLoad = completer;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final menus = await fetchAllPaginatedItems<MenuItem>(
|
||||
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<MenuItem> 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<MenuCatalog> {
|
||||
const MenuCatalogScope({
|
||||
super.key,
|
||||
required MenuCatalog catalog,
|
||||
required super.child,
|
||||
}) : super(notifier: catalog);
|
||||
|
||||
/// 현재 컨텍스트에서 [MenuCatalog]를 조회한다.
|
||||
static MenuCatalog of(BuildContext context) {
|
||||
final scope = context
|
||||
.dependOnInheritedWidgetOfExactType<MenuCatalogScope>();
|
||||
assert(
|
||||
scope != null,
|
||||
'MenuCatalogScope.of() called with no MenuCatalogScope ancestor.',
|
||||
);
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
263
lib/core/navigation/menu_route_definitions.dart
Normal file
263
lib/core/navigation/menu_route_definitions.dart
Normal file
@@ -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<String> aliases;
|
||||
final String routePath;
|
||||
final String defaultLabel;
|
||||
final IconData icon;
|
||||
final MenuPageBuilder builder;
|
||||
final int defaultOrder;
|
||||
final List<PermissionRequirement> extraRequirements;
|
||||
final bool showInNavigation;
|
||||
|
||||
Iterable<String> get codes sync* {
|
||||
yield menuCode;
|
||||
for (final alias in aliases) {
|
||||
yield alias;
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<PermissionRequirement> 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<MenuRouteDefinition> 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<String, MenuRouteDefinition> menuRouteDefinitionByCode = {
|
||||
for (final definition in menuRouteDefinitions)
|
||||
for (final code in definition.codes) code: definition,
|
||||
};
|
||||
24
lib/core/navigation/route_paths.dart
Normal file
24
lib/core/navigation/route_paths.dart
Normal file
@@ -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';
|
||||
@@ -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<void> 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// 개발/테스트 환경에서 사용할 임시 오버라이드 값을 설정한다.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NavigatorState>(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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<PermissionRequirement> requirements,
|
||||
required String fallback,
|
||||
}) {
|
||||
final guards = requirements.toList(growable: false);
|
||||
if (guards.isEmpty) {
|
||||
return (context, state) => null;
|
||||
}
|
||||
return (context, state) {
|
||||
if (!GetIt.I.isRegistered<PermissionManager>()) {
|
||||
return null;
|
||||
}
|
||||
final manager = GetIt.I<PermissionManager>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<InboundPage> {
|
||||
subtitle: '입고 처리, 라인 품목, 상태를 한 화면에서 확인하고 관리합니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '입·출고', path: '/inventory/inbound'),
|
||||
AppBreadcrumbItem(label: '입·출고', path: inventoryReceiptsRoutePath),
|
||||
AppBreadcrumbItem(label: '입고'),
|
||||
],
|
||||
actions: [
|
||||
|
||||
@@ -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<OutboundPage> {
|
||||
subtitle: '출고 처리, 고객사 연결, 품목 라인을 실시간으로 확인합니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '입·출고', path: '/inventory/outbound'),
|
||||
AppBreadcrumbItem(label: '입·출고', path: inventoryIssuesRoutePath),
|
||||
AppBreadcrumbItem(label: '출고'),
|
||||
],
|
||||
actions: [
|
||||
|
||||
@@ -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<RentalPage> {
|
||||
subtitle: '대여/반납 구분, 반납 예정일, 고객사 현황을 확인합니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '입·출고', path: '/inventory/rental'),
|
||||
AppBreadcrumbItem(label: '입·출고', path: inventoryRentalsRoutePath),
|
||||
AppBreadcrumbItem(label: '대여'),
|
||||
],
|
||||
actions: [
|
||||
|
||||
@@ -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<InventorySummaryPage> {
|
||||
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<Widget>()).toList(),
|
||||
columnSpanExtent: _columnSpanForIndex,
|
||||
sortableColumns: _columnSortKeys.keys.toSet(),
|
||||
sortState: _sortState,
|
||||
|
||||
@@ -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<LoginPage> {
|
||||
manager: PermissionScope.of(context),
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
groupPermissionRepository: GetIt.I<GroupPermissionRepository>(),
|
||||
menuCatalog: MenuCatalogScope.of(context),
|
||||
);
|
||||
await bootstrapper.apply(session);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<Group>? _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 (_) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<List<GroupPermission>> _collectPermissions(int groupId) async {
|
||||
@@ -63,4 +69,85 @@ class PermissionSynchronizer {
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
Future<List<GroupPermission>> _alignMenuRoutes(
|
||||
List<GroupPermission> 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 = <GroupPermission>[];
|
||||
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<Map<String, String>> _buildMenuRouteMap(MenuCatalog catalog) async {
|
||||
try {
|
||||
final menus = await catalog.ensureLoaded();
|
||||
final map = <String, String>{};
|
||||
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<String, String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Map<String, dynamic>>(
|
||||
_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',
|
||||
|
||||
@@ -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<String, dynamic> toPayload() {
|
||||
final normalizedCode = menuCode.trim();
|
||||
return {
|
||||
'group_id': groupId,
|
||||
'menu_id': menuId,
|
||||
'menu_code': normalizedCode,
|
||||
'can_create': canCreate,
|
||||
'can_read': canRead,
|
||||
'can_update': canUpdate,
|
||||
|
||||
@@ -9,7 +9,7 @@ abstract class GroupPermissionRepository {
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
int? groupId,
|
||||
int? menuId,
|
||||
String? menuCode,
|
||||
bool? isActive,
|
||||
bool includeDeleted = false,
|
||||
});
|
||||
|
||||
@@ -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<GroupPermission>? _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<Group> _groups = [];
|
||||
final List<MenuItem> _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<Group> get groups => List.unmodifiable(_groups);
|
||||
List<MenuItem> get menus => List.unmodifiable(_menus);
|
||||
@@ -87,13 +91,7 @@ class GroupPermissionController extends ChangeNotifier {
|
||||
_isLoadingMenus = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final menus = await fetchAllPaginatedItems<MenuItem>(
|
||||
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<MenuItem> _sortMenus(List<MenuItem> 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<List<MenuItem>> _resolveMenus() async {
|
||||
final catalog = _menuCatalog;
|
||||
if (catalog != null) {
|
||||
final menus = await catalog.ensureLoaded();
|
||||
return List<MenuItem>.from(menus);
|
||||
}
|
||||
return fetchAllPaginatedItems<MenuItem>(
|
||||
request: (page, pageSize) => _menuRepository.list(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
includeDeleted: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int>(
|
||||
ShadSelect<String>(
|
||||
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<int>(
|
||||
value: menu.id!,
|
||||
(menu) => ShadOption<String>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<GroupPermissionRepository>(),
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
menuRepository: GetIt.I<MenuRepository>(),
|
||||
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<int?>(
|
||||
key: ValueKey(_controller.menuFilter),
|
||||
child: ShadSelect<String?>(
|
||||
key: ValueKey<String?>(_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<int?>(value: null, child: Text('메뉴 전체')),
|
||||
const ShadOption<String?>(
|
||||
value: null,
|
||||
child: Text('메뉴 전체'),
|
||||
),
|
||||
..._controller.menus.map(
|
||||
(menuItem) => ShadOption<int?>(
|
||||
value: menuItem.id,
|
||||
(menuItem) => ShadOption<String?>(
|
||||
value: menuItem.menuCode,
|
||||
child: Text(_menuDisplayLabel(menuItem)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<String, dynamic> 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]으로 변환한다.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// 메뉴 생성/수정 입력 모델
|
||||
|
||||
@@ -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<MenuItem>? _result;
|
||||
bool _isLoading = false;
|
||||
@@ -42,17 +45,11 @@ class MenuController extends ChangeNotifier {
|
||||
List<MenuItem> get parents => _parents;
|
||||
|
||||
/// 상위 메뉴 목록을 로드해 드롭다운에 표시한다.
|
||||
Future<void> loadParents() async {
|
||||
Future<void> loadParents({bool forceRefresh = false}) async {
|
||||
_isLoadingParents = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final parents = await fetchAllPaginatedItems<MenuItem>(
|
||||
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<List<MenuItem>> _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<MenuItem>(
|
||||
request: (page, pageSize) => _repository.list(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
includeDeleted: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshCatalog() async {
|
||||
final catalog = _catalog;
|
||||
if (catalog == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await catalog.refresh();
|
||||
} catch (_) {
|
||||
// 카탈로그 동기화 실패는 UI 재시도 버튼으로 처리하므로 무시한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MenuRepository>())
|
||||
..addListener(_handleControllerUpdate);
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controllerInitialized) {
|
||||
return;
|
||||
}
|
||||
final catalog = MenuCatalogScope.of(context);
|
||||
_controller = menu.MenuController(
|
||||
repository: GetIt.I<MenuRepository>(),
|
||||
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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<UserAccount>? _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 (_) {
|
||||
|
||||
@@ -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<UserRepository>(),
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
permissionRepository: GetIt.I<GroupPermissionRepository>(),
|
||||
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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<PostalSearchPage> {
|
||||
subtitle: '창고/고객사 등 주소 입력 폼에서 재사용되는 검색 모달입니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '유틸리티', path: '/utilities/postal-search'),
|
||||
AppBreadcrumbItem(
|
||||
label: '유틸리티',
|
||||
path: utilitiesPostalSearchRoutePath,
|
||||
),
|
||||
AppBreadcrumbItem(label: '우편번호 검색'),
|
||||
],
|
||||
child: Center(
|
||||
|
||||
@@ -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<SuperportApp> {
|
||||
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<MenuRepository>());
|
||||
if (GetIt.I.isRegistered<PermissionManager>()) {
|
||||
GetIt.I.unregister<PermissionManager>();
|
||||
}
|
||||
GetIt.I.registerSingleton<PermissionManager>(_permissionManager);
|
||||
if (GetIt.I.isRegistered<MenuCatalog>()) {
|
||||
GetIt.I.unregister<MenuCatalog>();
|
||||
}
|
||||
GetIt.I.registerSingleton<MenuCatalog>(_menuCatalog);
|
||||
unawaited(_restorePermissions());
|
||||
unawaited(_preloadMenus());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -63,8 +72,12 @@ class _SuperportAppState extends State<SuperportApp> {
|
||||
if (GetIt.I.isRegistered<PermissionManager>()) {
|
||||
GetIt.I.unregister<PermissionManager>();
|
||||
}
|
||||
if (GetIt.I.isRegistered<MenuCatalog>()) {
|
||||
GetIt.I.unregister<MenuCatalog>();
|
||||
}
|
||||
_themeController.dispose();
|
||||
_permissionManager.dispose();
|
||||
_menuCatalog.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -72,26 +85,32 @@ class _SuperportAppState extends State<SuperportApp> {
|
||||
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<SuperportApp> {
|
||||
manager: _permissionManager,
|
||||
groupRepository: GetIt.I<GroupRepository>(),
|
||||
groupPermissionRepository: GetIt.I<GroupPermissionRepository>(),
|
||||
menuCatalog: _menuCatalog,
|
||||
);
|
||||
await bootstrapper.apply(session);
|
||||
}
|
||||
|
||||
Future<void> _preloadMenus() async {
|
||||
try {
|
||||
await _menuCatalog.refresh();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('메뉴 목록 초기화 실패: $error');
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 960;
|
||||
final manager = PermissionScope.of(context);
|
||||
final filteredPages = <AppPageDescriptor>[
|
||||
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<AuthService>();
|
||||
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<AuthService>();
|
||||
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<MenuItem> menus,
|
||||
) {
|
||||
final addedCodes = <String>{};
|
||||
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<AppPageDescriptor> 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<String> onTap;
|
||||
final List<AppPageDescriptor> 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<AppPageDescriptor> 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<AppPageDescriptor> pages) {
|
||||
return prefix == -1 ? 0 : prefix;
|
||||
}
|
||||
|
||||
bool _hasPageAccess(PermissionManager manager, AppPageDescriptor page) {
|
||||
final requirements = <String>{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});
|
||||
|
||||
Reference in New Issue
Block a user