diff --git a/assets/.env.development b/assets/.env.development index f9fc045..8a9bbc6 100644 --- a/assets/.env.development +++ b/assets/.env.development @@ -4,7 +4,7 @@ FEATURE_VENDORS_ENABLED=true FEATURE_PRODUCTS_ENABLED=true FEATURE_WAREHOUSES_ENABLED=true FEATURE_CUSTOMERS_ENABLED=true -FEATURE_USERS_ENABLED=false +FEATURE_USERS_ENABLED=true FEATURE_GROUPS_ENABLED=false FEATURE_MENUS_ENABLED=false FEATURE_GROUP_PERMISSIONS_ENABLED=false diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 39575b8..919dd21 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -50,7 +50,7 @@ - [x] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) - [ ] 창고: 목록/필터(q/사용), 신규/수정(우편번호 검색 모달 UI 연동) - [x] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) -- [ ] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) +- [x] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) - [ ] 그룹: 목록/필터(q/기본/사용), 신규/수정(그룹명RO) - [ ] 메뉴: 목록/필터(q/상위/사용), 신규/수정(메뉴코드RO) - [ ] 그룹 권한: 목록/필터(그룹/메뉴/사용), 체크박스 매트릭스 편집 UI diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index 19acc41..6739cd8 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -715,11 +715,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { label: '비고', child: ShadTextarea(controller: noteController), ), - if (existing != null) - ..._buildAuditInfo( - existing, - theme, - ), + if (existing != null) ..._buildAuditInfo(existing, theme), ], ), ), diff --git a/lib/features/masters/group/data/dtos/group_dto.dart b/lib/features/masters/group/data/dtos/group_dto.dart new file mode 100644 index 0000000..da47868 --- /dev/null +++ b/lib/features/masters/group/data/dtos/group_dto.dart @@ -0,0 +1,70 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/group.dart'; + +class GroupDto { + GroupDto({ + this.id, + required this.groupName, + this.isDefault = false, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String groupName; + final bool isDefault; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory GroupDto.fromJson(Map json) { + return GroupDto( + id: json['id'] as int?, + groupName: json['group_name'] as String, + isDefault: (json['is_default'] as bool?) ?? false, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + Group toEntity() => Group( + id: id, + groupName: groupName, + isDefault: isDefault, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(GroupDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/masters/group/data/repositories/group_repository_remote.dart b/lib/features/masters/group/data/repositories/group_repository_remote.dart new file mode 100644 index 0000000..107b207 --- /dev/null +++ b/lib/features/masters/group/data/repositories/group_repository_remote.dart @@ -0,0 +1,35 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/group.dart'; +import '../../domain/repositories/group_repository.dart'; +import '../dtos/group_dto.dart'; + +class GroupRepositoryRemote implements GroupRepository { + GroupRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/groups'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (isActive != null) 'is_active': isActive, + }, + options: Options(responseType: ResponseType.json), + ); + return GroupDto.parsePaginated(response.data ?? const {}); + } +} diff --git a/lib/features/masters/group/domain/entities/group.dart b/lib/features/masters/group/domain/entities/group.dart new file mode 100644 index 0000000..92877e5 --- /dev/null +++ b/lib/features/masters/group/domain/entities/group.dart @@ -0,0 +1,43 @@ +class Group { + Group({ + this.id, + required this.groupName, + this.isDefault = false, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String groupName; + final bool isDefault; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + Group copyWith({ + int? id, + String? groupName, + bool? isDefault, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Group( + id: id ?? this.id, + groupName: groupName ?? this.groupName, + isDefault: isDefault ?? this.isDefault, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/features/masters/group/domain/repositories/group_repository.dart b/lib/features/masters/group/domain/repositories/group_repository.dart new file mode 100644 index 0000000..a428d00 --- /dev/null +++ b/lib/features/masters/group/domain/repositories/group_repository.dart @@ -0,0 +1,12 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/group.dart'; + +abstract class GroupRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }); +} diff --git a/lib/features/masters/user/data/dtos/user_dto.dart b/lib/features/masters/user/data/dtos/user_dto.dart new file mode 100644 index 0000000..e165b13 --- /dev/null +++ b/lib/features/masters/user/data/dtos/user_dto.dart @@ -0,0 +1,102 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/user.dart'; + +class UserDto { + UserDto({ + this.id, + required this.employeeNo, + required this.employeeName, + this.email, + this.mobileNo, + this.group, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String employeeNo; + final String employeeName; + final String? email; + final String? mobileNo; + final UserGroupDto? group; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory UserDto.fromJson(Map json) { + return UserDto( + id: json['id'] as int?, + employeeNo: json['employee_no'] as String, + employeeName: json['employee_name'] as String, + email: json['email'] as String?, + mobileNo: json['mobile_no'] as String?, + group: json['group'] is Map + ? UserGroupDto.fromJson(json['group'] as Map) + : null, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + UserAccount toEntity() => UserAccount( + id: id, + employeeNo: employeeNo, + employeeName: employeeName, + email: email, + mobileNo: mobileNo, + group: group?.toEntity(), + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated( + Map? json, + ) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(UserDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +class UserGroupDto { + UserGroupDto({required this.id, required this.groupName}); + + final int id; + final String groupName; + + factory UserGroupDto.fromJson(Map json) { + return UserGroupDto( + id: json['id'] as int, + groupName: json['group_name'] as String, + ); + } + + UserGroup toEntity() => UserGroup(id: id, groupName: groupName); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/masters/user/data/repositories/user_repository_remote.dart b/lib/features/masters/user/data/repositories/user_repository_remote.dart new file mode 100644 index 0000000..490a667 --- /dev/null +++ b/lib/features/masters/user/data/repositories/user_repository_remote.dart @@ -0,0 +1,75 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/user.dart'; +import '../../domain/repositories/user_repository.dart'; +import '../dtos/user_dto.dart'; + +class UserRepositoryRemote implements UserRepository { + UserRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/employees'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? groupId, + bool? isActive, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (groupId != null) 'group_id': groupId, + if (isActive != null) 'is_active': isActive, + 'include': 'group', + }, + options: Options(responseType: ResponseType.json), + ); + return UserDto.parsePaginated(response.data ?? const {}); + } + + @override + Future create(UserInput input) async { + final response = await _api.post>( + _basePath, + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return UserDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, UserInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return UserDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return UserDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/masters/user/domain/entities/user.dart b/lib/features/masters/user/domain/entities/user.dart new file mode 100644 index 0000000..2968406 --- /dev/null +++ b/lib/features/masters/user/domain/entities/user.dart @@ -0,0 +1,94 @@ +class UserAccount { + UserAccount({ + this.id, + required this.employeeNo, + required this.employeeName, + this.email, + this.mobileNo, + this.group, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String employeeNo; + final String employeeName; + final String? email; + final String? mobileNo; + final UserGroup? group; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + UserAccount copyWith({ + int? id, + String? employeeNo, + String? employeeName, + String? email, + String? mobileNo, + UserGroup? group, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return UserAccount( + id: id ?? this.id, + employeeNo: employeeNo ?? this.employeeNo, + employeeName: employeeName ?? this.employeeName, + email: email ?? this.email, + mobileNo: mobileNo ?? this.mobileNo, + group: group ?? this.group, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class UserGroup { + UserGroup({required this.id, required this.groupName}); + + final int id; + final String groupName; +} + +class UserInput { + UserInput({ + required this.employeeNo, + required this.employeeName, + required this.groupId, + this.email, + this.mobileNo, + this.isActive = true, + this.note, + }); + + final String employeeNo; + final String employeeName; + final int groupId; + final String? email; + final String? mobileNo; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'employee_no': employeeNo, + 'employee_name': employeeName, + 'group_id': groupId, + 'email': email, + 'mobile_no': mobileNo, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/user/domain/repositories/user_repository.dart b/lib/features/masters/user/domain/repositories/user_repository.dart new file mode 100644 index 0000000..28eae7a --- /dev/null +++ b/lib/features/masters/user/domain/repositories/user_repository.dart @@ -0,0 +1,21 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/user.dart'; + +abstract class UserRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? groupId, + bool? isActive, + }); + + Future create(UserInput input); + + Future update(int id, UserInput input); + + Future delete(int id); + + Future restore(int id); +} diff --git a/lib/features/masters/user/presentation/controllers/user_controller.dart b/lib/features/masters/user/presentation/controllers/user_controller.dart new file mode 100644 index 0000000..09ea085 --- /dev/null +++ b/lib/features/masters/user/presentation/controllers/user_controller.dart @@ -0,0 +1,165 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../../group/domain/entities/group.dart'; +import '../../../group/domain/repositories/group_repository.dart'; +import '../../domain/entities/user.dart'; +import '../../domain/repositories/user_repository.dart'; + +enum UserStatusFilter { all, activeOnly, inactiveOnly } + +class UserController extends ChangeNotifier { + UserController({ + required UserRepository userRepository, + required GroupRepository groupRepository, + }) : _userRepository = userRepository, + _groupRepository = groupRepository; + + final UserRepository _userRepository; + final GroupRepository _groupRepository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + bool _isLoadingGroups = false; + String _query = ''; + int? _groupFilter; + UserStatusFilter _statusFilter = UserStatusFilter.all; + String? _errorMessage; + List _groups = const []; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + bool get isLoadingGroups => _isLoadingGroups; + String get query => _query; + int? get groupFilter => _groupFilter; + UserStatusFilter get statusFilter => _statusFilter; + String? get errorMessage => _errorMessage; + List get groups => _groups; + + Future loadGroups() async { + _isLoadingGroups = true; + notifyListeners(); + try { + final response = await _groupRepository.list(page: 1, pageSize: 100); + _groups = response.items; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingGroups = false; + notifyListeners(); + } + } + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final isActive = switch (_statusFilter) { + UserStatusFilter.all => null, + UserStatusFilter.activeOnly => true, + UserStatusFilter.inactiveOnly => false, + }; + final response = await _userRepository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + groupId: _groupFilter, + isActive: isActive, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateGroupFilter(int? groupId) { + _groupFilter = groupId; + notifyListeners(); + } + + void updateStatusFilter(UserStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + Future create(UserInput input) async { + _setSubmitting(true); + try { + final created = await _userRepository.create(input); + await fetch(page: 1); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update(int id, UserInput input) async { + _setSubmitting(true); + try { + final updated = await _userRepository.update(id, input); + await fetch(page: _result?.page ?? 1); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _userRepository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _userRepository.restore(id); + await fetch(page: _result?.page ?? 1); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index f2dd819..73fcac9 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -1,59 +1,861 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../../group/domain/entities/group.dart'; +import '../../../group/domain/repositories/group_repository.dart'; +import '../../domain/entities/user.dart'; +import '../../domain/repositories/user_repository.dart'; +import '../controllers/user_controller.dart'; class UserPage extends StatelessWidget { const UserPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '사용자(사원) 관리', - summary: '사번 기반 계정과 그룹, 사용 상태를 관리합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '사번 [Text]', - '성명 [Text]', - '이메일 [Text]', - '연락처 [Text]', - '그룹 [Dropdown]', - '사용여부 [Switch]', - '비고 [Text]', - ], - ), - SpecSection(title: '수정 폼', items: ['사번 [ReadOnly]', '생성일시 [ReadOnly]']), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: [ - '번호', - '사번', - '성명', - '이메일', - '연락처', - '그룹', - '사용여부', - '비고', - '변경일시', + final enabled = Environment.flag('FEATURE_USERS_ENABLED'); + if (!enabled) { + return const SpecPage( + title: '사용자(사원) 관리', + summary: '사번 기반 계정과 그룹, 사용 상태를 관리합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '사번 [Text]', + '성명 [Text]', + '이메일 [Text]', + '연락처 [Text]', + '그룹 [Dropdown]', + '사용여부 [Switch]', + '비고 [Text]', ], - rows: [ - [ - '1', - 'A0001', - '김철수', - 'kim@superport.com', - '010-1111-2222', - '관리자', - 'Y', - '-', - '2024-03-01 10:00', + ), + SpecSection( + title: '수정 폼', + items: ['사번 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '사번', + '성명', + '이메일', + '연락처', + '그룹', + '사용여부', + '비고', + '변경일시', ], + rows: [ + [ + '1', + 'A0001', + '김철수', + 'kim@superport.com', + '010-1111-2222', + '관리자', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } + + return const _UserEnabledPage(); + } +} + +class _UserEnabledPage extends StatefulWidget { + const _UserEnabledPage(); + + @override + State<_UserEnabledPage> createState() => _UserEnabledPageState(); +} + +class _UserEnabledPageState extends State<_UserEnabledPage> { + late final UserController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + bool _groupsLoaded = false; + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = UserController( + userRepository: GetIt.I(), + groupRepository: GetIt.I(), + )..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _controller.loadGroups(); + await _controller.fetch(); + setState(() { + _groupsLoaded = true; + }); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final users = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('사용자(사원) 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '사번 기반 계정과 그룹, 사용 상태를 관리합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + const SizedBox(width: 16), + ShadButton( + onPressed: _controller.isSubmitting + ? null + : () => _openUserForm(context), + child: const Text('신규 등록'), + ), + ], + ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 및 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('사번, 성명, 이메일 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.groupFilter), + initialValue: _controller.groupFilter, + placeholder: Text( + _groupsLoaded ? '그룹 전체' : '그룹 로딩중...', + ), + selectedOptionBuilder: (context, value) { + if (value == null) { + return Text( + _groupsLoaded ? '그룹 전체' : '그룹 로딩중...', + ); + } + final group = _controller.groups.firstWhere( + (g) => g.id == value, + orElse: () => Group(id: value, groupName: ''), + ); + return Text(group.groupName); + }, + onChanged: _controller.isLoadingGroups + ? null + : (value) { + _controller.updateGroupFilter(value); + }, + options: [ + const ShadOption( + value: null, + child: Text('그룹 전체'), + ), + ..._controller.groups.map( + (group) => ShadOption( + value: group.id, + child: Text(group.groupName), + ), + ), + ], + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: UserStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading + ? null + : _applyFilters, + child: const Text('검색 적용'), + ), + if (_searchController.text.isNotEmpty || + _controller.groupFilter != null || + _controller.statusFilter != UserStatusFilter.all) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateGroupFilter(null); + _controller.updateStatusFilter( + UserStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('사용자 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : users.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 사용자가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _UserTable( + users: users, + onEdit: _controller.isSubmitting + ? null + : (user) => _openUserForm(context, user: user), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreUser, + ), + ), + ], + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + String _statusLabel(UserStatusFilter filter) { + switch (filter) { + case UserStatusFilter.all: + return '전체(사용/미사용)'; + case UserStatusFilter.activeOnly: + return '사용중'; + case UserStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openUserForm(BuildContext context, {UserAccount? user}) async { + final existing = user; + final isEdit = existing != null; + final userId = existing?.id; + if (isEdit && userId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + if (!_groupsLoaded) { + _showSnack('그룹 정보를 불러오는 중입니다. 잠시 후 다시 시도하세요.'); + return; + } + + final parentContext = context; + + final codeController = TextEditingController( + text: existing?.employeeNo ?? '', + ); + final nameController = TextEditingController( + text: existing?.employeeName ?? '', + ); + final emailController = TextEditingController(text: existing?.email ?? ''); + final mobileController = TextEditingController( + text: existing?.mobileNo ?? '', + ); + final noteController = TextEditingController(text: existing?.note ?? ''); + final groupNotifier = ValueNotifier(existing?.group?.id); + final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); + final saving = ValueNotifier(false); + final codeError = ValueNotifier(null); + final nameError = ValueNotifier(null); + final groupError = ValueNotifier(null); + + await showDialog( + context: parentContext, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ShadCard( + title: Text( + isEdit ? '사용자 수정' : '사용자 등록', + style: theme.textTheme.h3, + ), + description: Text( + '사용자 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final email = emailController.text.trim(); + final mobile = mobileController.text.trim(); + final note = noteController.text.trim(); + final groupId = groupNotifier.value; + + codeError.value = code.isEmpty + ? '사번을 입력하세요.' + : null; + nameError.value = name.isEmpty + ? '성명을 입력하세요.' + : null; + groupError.value = groupId == null + ? '그룹을 선택하세요.' + : null; + + if (codeError.value != null || + nameError.value != null || + groupError.value != null) { + return; + } + + saving.value = true; + final input = UserInput( + employeeNo: code, + employeeName: name, + groupId: groupId!, + email: email.isEmpty ? null : email, + mobileNo: mobile.isEmpty ? null : mobile, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final response = isEdit + ? await _controller.update(userId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '사용자를 수정했습니다.' : '사용자를 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], + ); + }, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '사번', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '성명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '이메일', + child: ShadInput( + controller: emailController, + keyboardType: TextInputType.emailAddress, + ), + ), + const SizedBox(height: 16), + _FormField( + label: '연락처', + child: ShadInput( + controller: mobileController, + keyboardType: TextInputType.phone, + ), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: groupNotifier, + builder: (_, value, __) { + return ValueListenableBuilder( + valueListenable: groupError, + builder: (_, errorText, __) { + return _FormField( + label: '그룹', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: value, + onChanged: saving.value + ? null + : (next) { + groupNotifier.value = next; + groupError.value = null; + }, + options: _controller.groups + .map( + (group) => ShadOption( + value: group.id, + child: Text(group.groupName), + ), + ) + .toList(), + placeholder: const Text('그룹을 선택하세요'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('그룹을 선택하세요'); + } + final group = _controller.groups + .firstWhere( + (g) => g.id == selected, + orElse: () => Group( + id: selected, + groupName: '', + ), + ); + return Text(group.groupName); + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: + materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (existing != null) ..._buildAuditInfo(existing, theme), + ], + ), + ), + ), + ), + ); + }, + ); + + codeController.dispose(); + nameController.dispose(); + emailController.dispose(); + mobileController.dispose(); + noteController.dispose(); + groupNotifier.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + codeError.dispose(); + nameError.dispose(); + groupError.dispose(); + } + + Future _confirmDelete(UserAccount user) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('사용자 삭제'), + content: Text('"${user.employeeName}" 사용자를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && user.id != null) { + final success = await _controller.delete(user.id!); + if (success && mounted) { + _showSnack('사용자를 삭제했습니다.'); + } + } + } + + Future _restoreUser(UserAccount user) async { + if (user.id == null) return; + final restored = await _controller.restore(user.id!); + if (restored != null && mounted) { + _showSnack('사용자를 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + List _buildAuditInfo(UserAccount user, ShadThemeData theme) { + return [ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(user.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(user.updatedAt)}', + style: theme.textTheme.small, + ), + ]; + } + + String _formatDateTime(DateTime? value) { + if (value == null) return '-'; + return value.toLocal().toIso8601String(); + } +} + +class _UserTable extends StatelessWidget { + const _UserTable({ + required this.users, + required this.onEdit, + required this.onDelete, + required this.onRestore, + }); + + final List users; + final void Function(UserAccount user)? onEdit; + final void Function(UserAccount user)? onDelete; + final void Function(UserAccount user)? onRestore; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '사번', + '성명', + '이메일', + '연락처', + '그룹', + '사용', + '삭제', + '비고', + '변경일시', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = users.map((user) { + return [ + user.id?.toString() ?? '-', + user.employeeNo, + user.employeeName, + user.email?.isEmpty ?? true ? '-' : user.email!, + user.mobileNo?.isEmpty ?? true ? '-' : user.mobileNo!, + user.group?.groupName ?? '-', + user.isActive ? 'Y' : 'N', + user.isDeleted ? 'Y' : '-', + user.note?.isEmpty ?? true ? '-' : user.note!, + user.updatedAt == null + ? '-' + : user.updatedAt!.toLocal().toIso8601String(), + ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(user), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + user.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(user), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(user), + child: const Icon(LucideIcons.trash2, size: 16), + ), ], ), ), + ); + }).toList(); + + return SizedBox( + height: 56.0 * (users.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => index == 10 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), + ), + ); + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, ], ); } diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 9016e92..a6de128 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -7,8 +7,12 @@ import 'core/network/api_client.dart'; import 'core/network/interceptors/auth_interceptor.dart'; import 'features/masters/customer/data/repositories/customer_repository_remote.dart'; import 'features/masters/customer/domain/repositories/customer_repository.dart'; +import 'features/masters/group/data/repositories/group_repository_remote.dart'; +import 'features/masters/group/domain/repositories/group_repository.dart'; import 'features/masters/product/data/repositories/product_repository_remote.dart'; import 'features/masters/product/domain/repositories/product_repository.dart'; +import 'features/masters/user/data/repositories/user_repository_remote.dart'; +import 'features/masters/user/domain/repositories/user_repository.dart'; import 'features/masters/vendor/data/repositories/vendor_repository_remote.dart'; import 'features/masters/vendor/domain/repositories/vendor_repository.dart'; import 'features/masters/warehouse/data/repositories/warehouse_repository_remote.dart'; @@ -21,15 +25,17 @@ final GetIt sl = GetIt.instance; /// 의존성 등록(스켈레톤) /// - Environment.initialize() 이후 호출하여 baseUrl/타임아웃 등을 주입한다. -Future initInjection({required String baseUrl, Duration? connectTimeout, Duration? receiveTimeout}) async { +Future initInjection({ + required String baseUrl, + Duration? connectTimeout, + Duration? receiveTimeout, +}) async { // Dio 기본 옵션 설정 final options = BaseOptions( baseUrl: baseUrl, connectTimeout: connectTimeout ?? const Duration(seconds: 15), receiveTimeout: receiveTimeout ?? const Duration(seconds: 30), - headers: const { - 'Accept': 'application/json', - }, + headers: const {'Accept': 'application/json'}, ); final dio = Dio(options); @@ -63,4 +69,12 @@ Future initInjection({required String baseUrl, Duration? connectTimeout, D sl.registerLazySingleton( () => CustomerRepositoryRemote(apiClient: sl()), ); + + sl.registerLazySingleton( + () => GroupRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => UserRepositoryRemote(apiClient: sl()), + ); } diff --git a/test/features/masters/user/presentation/controllers/user_controller_test.dart b/test/features/masters/user/presentation/controllers/user_controller_test.dart new file mode 100644 index 0000000..8002700 --- /dev/null +++ b/test/features/masters/user/presentation/controllers/user_controller_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/user/domain/entities/user.dart'; +import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart'; +import 'package:superport_v2/features/masters/user/presentation/controllers/user_controller.dart'; + +class _MockUserRepository extends Mock implements UserRepository {} + +class _MockGroupRepository extends Mock implements GroupRepository {} + +class _FakeUserInput extends Fake implements UserInput {} + +void main() { + late UserController controller; + late _MockUserRepository userRepository; + late _MockGroupRepository groupRepository; + + final sampleUser = UserAccount( + id: 1, + employeeNo: 'A001', + employeeName: '홍길동', + email: 'hong@superport.com', + group: UserGroup(id: 2, groupName: '관리자'), + ); + + PaginatedResult createResult({List? items}) { + final list = items ?? [sampleUser]; + return PaginatedResult( + items: list, + page: 1, + pageSize: 20, + total: list.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeUserInput()); + }); + + setUp(() { + userRepository = _MockUserRepository(); + groupRepository = _MockGroupRepository(); + controller = UserController( + userRepository: userRepository, + groupRepository: groupRepository, + ); + }); + + test('loadGroups 호출 시 그룹 목록 저장', () async { + when( + () => groupRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [Group(id: 1, groupName: '관리자')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await controller.loadGroups(); + + expect(controller.groups, isNotEmpty); + }); + + group('fetch', () { + setUp(() { + when( + () => groupRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [Group(id: 2, groupName: '관리자')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + }); + + test('정상 조회', () async { + when( + () => userRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + groupId: any(named: 'groupId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + verify( + () => userRepository.list( + page: 1, + pageSize: 20, + query: null, + groupId: null, + isActive: null, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => userRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + groupId: any(named: 'groupId'), + isActive: any(named: 'isActive'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + test('필터 업데이트', () { + controller.updateQuery('hong'); + controller.updateGroupFilter(2); + controller.updateStatusFilter(UserStatusFilter.inactiveOnly); + + expect(controller.query, 'hong'); + expect(controller.groupFilter, 2); + expect(controller.statusFilter, UserStatusFilter.inactiveOnly); + }); + + group('mutations', () { + setUp(() { + when( + () => userRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + groupId: any(named: 'groupId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + }); + + final input = UserInput( + employeeNo: 'A001', + employeeName: '홍길동', + groupId: 2, + ); + + test('create 성공', () async { + when( + () => userRepository.create(any()), + ).thenAnswer((_) async => sampleUser); + + final created = await controller.create(input); + + expect(created, isNotNull); + verify(() => userRepository.create(any())).called(1); + }); + + test('update 성공', () async { + when( + () => userRepository.update(any(), any()), + ).thenAnswer((_) async => sampleUser); + + final updated = await controller.update(1, input); + + expect(updated, isNotNull); + verify(() => userRepository.update(1, any())).called(1); + }); + + test('delete 성공', () async { + when(() => userRepository.delete(any())).thenAnswer((_) async {}); + + final success = await controller.delete(1); + + expect(success, isTrue); + verify(() => userRepository.delete(1)).called(1); + }); + + test('restore 성공', () async { + when( + () => userRepository.restore(any()), + ).thenAnswer((_) async => sampleUser); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => userRepository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/user/presentation/pages/user_page_test.dart b/test/features/masters/user/presentation/pages/user_page_test.dart new file mode 100644 index 0000000..c5f727a --- /dev/null +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; +import 'package:superport_v2/features/masters/user/domain/entities/user.dart'; +import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart'; +import 'package:superport_v2/features/masters/user/presentation/pages/user_page.dart'; + +class _MockUserRepository extends Mock implements UserRepository {} + +class _MockGroupRepository extends Mock implements GroupRepository {} + +class _FakeUserInput extends Fake implements UserInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeUserInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 화면 유지', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const UserPage())); + await tester.pump(); + + expect(find.text('사용자(사원) 관리'), findsOneWidget); + expect(find.text('테이블 리스트'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockUserRepository userRepository; + late _MockGroupRepository groupRepository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_USERS_ENABLED=true\n'); + userRepository = _MockUserRepository(); + groupRepository = _MockGroupRepository(); + GetIt.I.registerLazySingleton(() => userRepository); + GetIt.I.registerLazySingleton(() => groupRepository); + + when( + () => groupRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [Group(id: 1, groupName: '관리자')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + }); + + testWidgets('목록 조회 후 테이블 렌더', (tester) async { + when( + () => userRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + groupId: any(named: 'groupId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + UserAccount( + id: 1, + employeeNo: 'A001', + employeeName: '홍길동', + email: 'hong@superport.com', + group: UserGroup(id: 1, groupName: '관리자'), + ), + ], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const UserPage())); + await tester.pumpAndSettle(); + + expect(find.text('A001'), findsOneWidget); + verify( + () => userRepository.list( + page: 1, + pageSize: 20, + query: null, + groupId: null, + isActive: null, + ), + ).called(1); + }); + + testWidgets('폼 검증: 필수값 누락', (tester) async { + when( + () => userRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + groupId: any(named: 'groupId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const UserPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('사번을 입력하세요.'), findsOneWidget); + expect(find.text('성명을 입력하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공', (tester) async { + var listCallCount = 0; + when( + () => userRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + groupId: any(named: 'groupId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async { + listCallCount += 1; + if (listCallCount == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [ + UserAccount( + id: 2, + employeeNo: 'A010', + employeeName: '신규 사용자', + email: 'new@superport.com', + group: UserGroup(id: 1, groupName: '관리자'), + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + UserInput? capturedInput; + when(() => userRepository.create(any())).thenAnswer((invocation) async { + capturedInput = invocation.positionalArguments.first as UserInput; + return UserAccount( + id: 2, + employeeNo: capturedInput!.employeeNo, + employeeName: capturedInput!.employeeName, + group: UserGroup(id: capturedInput!.groupId, groupName: '관리자'), + ); + }); + + await tester.pumpWidget(_buildApp(const UserPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final dialog = find.byType(Dialog); + final editableTexts = find.descendant( + of: dialog, + matching: find.byType(EditableText), + ); + + await tester.enterText(editableTexts.at(0), 'A010'); + await tester.enterText(editableTexts.at(1), '신규 사용자'); + + await tester.tap(find.text('그룹을 선택하세요')); + await tester.pumpAndSettle(); + await tester.tap(find.text('관리자')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.employeeNo, 'A010'); + expect(find.byType(Dialog), findsNothing); + expect(find.text('A010'), findsOneWidget); + verify(() => userRepository.create(any())).called(1); + }); + }); +}