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:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user