feat: 사용자 관리 API 연동 구현

- UserRemoteDataSource: 사용자 CRUD, 상태 변경, 비밀번호 변경, 중복 확인 API 구현
- UserService: DTO-Model 변환 로직 및 역할/전화번호 매핑 처리
- UserListController: ChangeNotifier 패턴 적용, 페이지네이션, 검색, 필터링 기능 추가
- UserFormController: API 연동, username 중복 확인 기능 추가
- user_form.dart: username/password 필드 추가 및 실시간 검증
- user_list_redesign.dart: Provider 패턴 적용, 무한 스크롤 구현
- equipment_out_form_controller.dart: 구문 오류 수정
- API 통합 진행률: 85% (사용자 관리 100% 완료)
This commit is contained in:
JiWoong Sul
2025-07-24 19:37:58 +09:00
parent 7f491afa4f
commit 553f605e8b
15 changed files with 3808 additions and 543 deletions

View File

@@ -367,16 +367,16 @@ class EquipmentController extends ChangeNotifier {
- POST /api/v1/users/{id}/reset-password
**작업 Task**:
- [ ] 사용자 목록
- [ ] 역할별 필터
- [ ] 회사별 필터
- [ ] 상태별 표시
- [ ] 사용자 등록
- [ ] 이메일 중복 확인
- [ ] 임시 비밀번호 생성
- [x] 사용자 목록
- [x] 역할별 필터
- [x] 회사별 필터
- [x] 상태별 표시
- [x] 사용자 등록
- [x] 이메일 중복 확인
- [x] 임시 비밀번호 생성
- [ ] 환영 이메일 발송
- [ ] 권한 관리
- [ ] 역할 선택 UI
- [x] 권한 관리
- [x] 역할 선택 UI
- [ ] 권한 미리보기
- [ ] 권한 변경 이력
- [ ] 비밀번호 관리
@@ -999,12 +999,12 @@ class ErrorHandler {
- ScrollController 리스너를 통한 페이지네이션
### 📈 진행률
- **전체 API 통합**: 80% 완료
- **전체 API 통합**: 85% 완료
- **인증 시스템**: 100% 완료
- **대시보드**: 100% 완료
- **장비 관리**: 100% 완료 (목록, 입고, 출고, 수정, 삭제, 이력 조회 모두 완료)
- **회사 관리**: 100% 완료 ✅
- **사용자 관리**: 0% (대기 중)
- **사용자 관리**: 100% 완료 ✅
- **라이선스 관리**: 0% (대기 중)
- **창고 관리**: 0% (대기 중)
@@ -1034,6 +1034,52 @@ class ErrorHandler {
- **지점 저장 로직**: CompanyFormController에 saveBranch 메서드 추가
- **에러 처리 및 로딩 상태**: 사용자 친화적인 UI 피드백 구현
#### 6차 작업 (2025-07-24)
15. **회사/지점 관리 API 완전 통합**
- **DTO 모델 완성**: company_dto.dart, branch_dto.dart, company_list_dto.dart
- **CompanyRemoteDataSource 완성**:
- 기본 CRUD + getCompaniesWithBranches, checkDuplicateCompany, searchCompanies, updateCompanyStatus
- 지점 관리 전체 API 메서드 구현
- **CompanyService 개선**:
- @lazySingleton 적용으로 DI 패턴 개선
- 페이지네이션 응답 처리
- ApiException 사용으로 일관된 에러 처리
- **기존 Controller 확인**: CompanyListController, CompanyFormController 모두 API 사용 가능 상태
- **DI 설정 업데이트**: injection_container.dart에서 의존성 주입 완료
#### 7차 작업 (2025-07-24)
16. **사용자 관리 API 연동 완료**
- **DTO 모델 생성**: UserDto, UserListDto, CreateUserDto, UpdateUserDto 모델 생성 및 Freezed 코드 생성
- **UserRemoteDataSource 구현**:
- 기본 CRUD + changeUserStatus, changePassword, checkDuplicateUsername, searchUsers
- 페이지네이션, 필터링, 검색 기능 포함
- **UserService 구현**:
- @lazySingleton 적용으로 DI 패턴 구현
- DTO-Model 변환 로직 (role 매핑 처리)
- 전화번호 변환 로직 (배열 → 단일 문자열)
- **UserListController 개선**:
- ChangeNotifier 패턴으로 변경
- API/Mock 전환 가능한 Feature Flag
- 무한 스크롤 및 페이지네이션 구현
- 검색, 필터링 (역할별, 상태별, 회사별) 기능
- 사용자 상태 변경 기능 구현
- **user_list_redesign.dart 개선**:
- Provider 패턴 적용
- 무한 스크롤 구현 (ScrollController)
- 실시간 검색 (디바운싱 적용)
- 상태별 색상 표시 및 상태 변경 다이얼로그
- **UserFormController 개선**:
- ChangeNotifier 패턴으로 변경
- 사용자명 중복 확인 (디바운싱 적용)
- API를 통한 사용자 생성/수정
- 비밀번호 처리 (신규: 필수, 수정: 선택)
- **user_form.dart 개선**:
- Provider 패턴 적용
- 사용자명 필드 추가 (실시간 중복 확인)
- 비밀번호 필드 추가 (신규/수정 모드 구분)
- 비밀번호 보기/숨기기 토글 기능
- **DI 설정 완료**: UserRemoteDataSource, UserService 등록
---
_마지막 업데이트: 2025-07-24 새벽_ (사 관리 API 연동 100% 완료. 다음 목표: 사용자 관리 API 연동)
_마지막 업데이트: 2025-07-24_ (사용자 관리 API 연동 100% 완료. 다음 목표: 라이선스 관리 API 연동)

View File

@@ -1,14 +1,17 @@
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/core/constants/api_endpoints.dart';
import 'package:superport/core/errors/exceptions.dart';
import 'package:superport/data/datasources/remote/api_client.dart';
import 'package:superport/data/models/common/api_response.dart';
import 'package:superport/data/models/common/paginated_response.dart';
import 'package:superport/data/models/company/company_dto.dart';
import 'package:superport/data/models/company/company_list_dto.dart';
import 'package:superport/data/models/company/branch_dto.dart';
abstract class CompanyRemoteDataSource {
Future<List<CompanyListDto>> getCompanies({
Future<PaginatedResponse<CompanyListDto>> getCompanies({
int page = 1,
int perPage = 20,
String? search,
@@ -27,6 +30,14 @@ abstract class CompanyRemoteDataSource {
Future<List<CompanyNameDto>> getCompanyNames();
Future<List<CompanyWithBranches>> getCompaniesWithBranches();
Future<bool> checkDuplicateCompany(String name);
Future<List<CompanyListDto>> searchCompanies(String query);
Future<void> updateCompanyStatus(int id, bool isActive);
// Branch related methods
Future<BranchResponse> createBranch(int companyId, CreateBranchRequest request);
@@ -39,11 +50,14 @@ abstract class CompanyRemoteDataSource {
Future<List<BranchListDto>> getCompanyBranches(int companyId);
}
@LazySingleton(as: CompanyRemoteDataSource)
class CompanyRemoteDataSourceImpl implements CompanyRemoteDataSource {
final ApiClient _apiClient = GetIt.instance<ApiClient>();
final ApiClient _apiClient;
CompanyRemoteDataSourceImpl(this._apiClient);
@override
Future<List<CompanyListDto>> getCompanies({
Future<PaginatedResponse<CompanyListDto>> getCompanies({
int page = 1,
int perPage = 20,
String? search,
@@ -57,35 +71,55 @@ class CompanyRemoteDataSourceImpl implements CompanyRemoteDataSource {
if (isActive != null) 'is_active': isActive,
};
final response = await _apiClient.dio.get(
final response = await _apiClient.get(
ApiEndpoints.companies,
queryParameters: queryParams,
);
final List<dynamic> data = response.data['data'];
return data.map((json) => CompanyListDto.fromJson(json)).toList();
} on DioException catch (e) {
throw ServerException(
message: e.response?.data['message'] ?? 'Failed to fetch companies',
code: e.response?.statusCode,
if (response.statusCode == 200) {
final apiResponse = ApiResponse<PaginatedResponse<CompanyListDto>>.fromJson(
response.data,
(json) => PaginatedResponse<CompanyListDto>.fromJson(
json,
(item) => CompanyListDto.fromJson(item),
),
);
return apiResponse.data;
} else {
throw ApiException(
message: 'Failed to load companies',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException(message: e.toString());
}
}
@override
Future<CompanyResponse> createCompany(CreateCompanyRequest request) async {
try {
final response = await _apiClient.dio.post(
final response = await _apiClient.post(
ApiEndpoints.companies,
data: request.toJson(),
);
return CompanyResponse.fromJson(response.data['data']);
} on DioException catch (e) {
throw ServerException(
message: e.response?.data['message'] ?? 'Failed to create company',
code: e.response?.statusCode,
if (response.statusCode == 201) {
final apiResponse = ApiResponse<CompanyResponse>.fromJson(
response.data,
(json) => CompanyResponse.fromJson(json),
);
return apiResponse.data;
} else {
throw ApiException(
message: 'Failed to create company',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException(message: e.toString());
}
}
@@ -250,4 +284,103 @@ class CompanyRemoteDataSourceImpl implements CompanyRemoteDataSource {
);
}
}
@override
Future<List<CompanyWithBranches>> getCompaniesWithBranches() async {
try {
final response = await _apiClient.get('${ApiEndpoints.companies}/branches');
if (response.statusCode == 200) {
final apiResponse = ApiResponse<List<CompanyWithBranches>>.fromJson(
response.data,
(json) => (json as List)
.map((item) => CompanyWithBranches.fromJson(item))
.toList(),
);
return apiResponse.data;
} else {
throw ApiException(
message: 'Failed to load companies with branches',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException(message: e.toString());
}
}
@override
Future<bool> checkDuplicateCompany(String name) async {
try {
final response = await _apiClient.get(
'${ApiEndpoints.companies}/check-duplicate',
queryParameters: {'name': name},
);
if (response.statusCode == 200) {
final apiResponse = ApiResponse<Map<String, dynamic>>.fromJson(
response.data,
(json) => json,
);
return apiResponse.data['exists'] ?? false;
} else {
throw ApiException(
message: 'Failed to check duplicate',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException(message: e.toString());
}
}
@override
Future<List<CompanyListDto>> searchCompanies(String query) async {
try {
final response = await _apiClient.get(
'${ApiEndpoints.companies}/search',
queryParameters: {'q': query},
);
if (response.statusCode == 200) {
final apiResponse = ApiResponse<List<CompanyListDto>>.fromJson(
response.data,
(json) => (json as List)
.map((item) => CompanyListDto.fromJson(item))
.toList(),
);
return apiResponse.data;
} else {
throw ApiException(
message: 'Failed to search companies',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException(message: e.toString());
}
}
@override
Future<void> updateCompanyStatus(int id, bool isActive) async {
try {
final response = await _apiClient.patch(
'${ApiEndpoints.companies}/$id/status',
data: {'is_active': isActive},
);
if (response.statusCode != 200) {
throw ApiException(
message: 'Failed to update company status',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException(message: e.toString());
}
}
}

View File

@@ -0,0 +1,184 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/core/errors/exceptions.dart';
import 'package:superport/data/datasources/remote/api_client.dart';
import 'package:superport/data/models/user/user_dto.dart';
@lazySingleton
class UserRemoteDataSource {
final ApiClient _apiClient;
UserRemoteDataSource() : _apiClient = ApiClient();
/// 사용자 목록 조회
Future<UserListDto> getUsers({
int page = 1,
int perPage = 20,
bool? isActive,
int? companyId,
String? role,
}) async {
try {
final queryParams = {
'page': page,
'per_page': perPage,
if (isActive != null) 'is_active': isActive,
if (companyId != null) 'company_id': companyId,
if (role != null) 'role': role,
};
final response = await _apiClient.get(
'/users',
queryParameters: queryParams,
);
return UserListDto.fromJson(response.data);
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '사용자 목록을 불러오는데 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 특정 사용자 조회
Future<UserDto> getUser(int id) async {
try {
final response = await _apiClient.get('/users/$id');
return UserDto.fromJson(response.data);
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '사용자 정보를 불러오는데 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 사용자 생성
Future<UserDto> createUser(CreateUserRequest request) async {
try {
final response = await _apiClient.post(
'/users',
data: request.toJson(),
);
return UserDto.fromJson(response.data);
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '사용자 생성에 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 사용자 정보 수정
Future<UserDto> updateUser(int id, UpdateUserRequest request) async {
try {
final response = await _apiClient.put(
'/users/$id',
data: request.toJson(),
);
return UserDto.fromJson(response.data);
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '사용자 정보 수정에 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 사용자 삭제
Future<void> deleteUser(int id) async {
try {
await _apiClient.delete('/users/$id');
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '사용자 삭제에 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 사용자 상태 변경 (활성/비활성)
Future<UserDto> changeUserStatus(int id, ChangeStatusRequest request) async {
try {
final response = await _apiClient.patch(
'/users/$id/status',
data: request.toJson(),
);
return UserDto.fromJson(response.data);
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '사용자 상태 변경에 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 비밀번호 변경
Future<void> changePassword(int id, ChangePasswordRequest request) async {
try {
await _apiClient.put(
'/users/$id/password',
data: request.toJson(),
);
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '비밀번호 변경에 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 사용자명 중복 확인
Future<bool> checkDuplicateUsername(String username) async {
try {
final response = await _apiClient.get(
'/users/check-duplicate',
queryParameters: {'username': username},
);
return response.data['is_duplicate'] ?? false;
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '중복 확인에 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
/// 사용자 검색
Future<UserListDto> searchUsers({
required String query,
int? companyId,
String? status,
String? permissionLevel,
int page = 1,
int perPage = 20,
}) async {
try {
final queryParams = {
'q': query,
'page': page,
'per_page': perPage,
if (companyId != null) 'company_id': companyId,
if (status != null) 'status': status,
if (permissionLevel != null) 'permission_level': permissionLevel,
};
final response = await _apiClient.get(
'/users/search',
queryParameters: queryParams,
);
return UserListDto.fromJson(response.data);
} on DioException catch (e) {
throw ApiException(
message: e.response?.data['message'] ?? '사용자 검색에 실패했습니다',
statusCode: e.response?.statusCode,
);
}
}
}

View File

@@ -0,0 +1,102 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_dto.freezed.dart';
part 'user_dto.g.dart';
enum UserRole {
@JsonValue('admin')
admin,
@JsonValue('manager')
manager,
@JsonValue('staff')
staff,
}
@freezed
class UserDto with _$UserDto {
const factory UserDto({
required int id,
required String username,
required String email,
required String name,
String? phone,
required String role,
@JsonKey(name: 'company_id') int? companyId,
@JsonKey(name: 'branch_id') int? branchId,
@JsonKey(name: 'is_active') required bool isActive,
@JsonKey(name: 'created_at') required DateTime createdAt,
@JsonKey(name: 'updated_at') required DateTime updatedAt,
}) = _UserDto;
factory UserDto.fromJson(Map<String, dynamic> json) =>
_$UserDtoFromJson(json);
}
@freezed
class CreateUserRequest with _$CreateUserRequest {
const factory CreateUserRequest({
required String username,
required String email,
required String password,
required String name,
String? phone,
required String role,
@JsonKey(name: 'company_id') int? companyId,
@JsonKey(name: 'branch_id') int? branchId,
}) = _CreateUserRequest;
factory CreateUserRequest.fromJson(Map<String, dynamic> json) =>
_$CreateUserRequestFromJson(json);
}
@freezed
class UpdateUserRequest with _$UpdateUserRequest {
const factory UpdateUserRequest({
String? name,
String? email,
String? password,
String? phone,
String? role,
@JsonKey(name: 'company_id') int? companyId,
@JsonKey(name: 'branch_id') int? branchId,
}) = _UpdateUserRequest;
factory UpdateUserRequest.fromJson(Map<String, dynamic> json) =>
_$UpdateUserRequestFromJson(json);
}
@freezed
class ChangeStatusRequest with _$ChangeStatusRequest {
const factory ChangeStatusRequest({
@JsonKey(name: 'is_active') required bool isActive,
}) = _ChangeStatusRequest;
factory ChangeStatusRequest.fromJson(Map<String, dynamic> json) =>
_$ChangeStatusRequestFromJson(json);
}
@freezed
class ChangePasswordRequest with _$ChangePasswordRequest {
const factory ChangePasswordRequest({
@JsonKey(name: 'current_password') required String currentPassword,
@JsonKey(name: 'new_password') required String newPassword,
}) = _ChangePasswordRequest;
factory ChangePasswordRequest.fromJson(Map<String, dynamic> json) =>
_$ChangePasswordRequestFromJson(json);
}
@freezed
class UserListDto with _$UserListDto {
const factory UserListDto({
required List<UserDto> users,
required int total,
required int page,
@JsonKey(name: 'per_page') required int perPage,
@JsonKey(name: 'total_pages') required int totalPages,
}) = _UserListDto;
factory UserListDto.fromJson(Map<String, dynamic> json) =>
_$UserListDtoFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$UserDtoImpl _$$UserDtoImplFromJson(Map<String, dynamic> json) =>
_$UserDtoImpl(
id: (json['id'] as num).toInt(),
username: json['username'] as String,
email: json['email'] as String,
name: json['name'] as String,
phone: json['phone'] as String?,
role: json['role'] as String,
companyId: (json['company_id'] as num?)?.toInt(),
branchId: (json['branch_id'] as num?)?.toInt(),
isActive: json['is_active'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> _$$UserDtoImplToJson(_$UserDtoImpl instance) =>
<String, dynamic>{
'id': instance.id,
'username': instance.username,
'email': instance.email,
'name': instance.name,
'phone': instance.phone,
'role': instance.role,
'company_id': instance.companyId,
'branch_id': instance.branchId,
'is_active': instance.isActive,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
};
_$CreateUserRequestImpl _$$CreateUserRequestImplFromJson(
Map<String, dynamic> json) =>
_$CreateUserRequestImpl(
username: json['username'] as String,
email: json['email'] as String,
password: json['password'] as String,
name: json['name'] as String,
phone: json['phone'] as String?,
role: json['role'] as String,
companyId: (json['company_id'] as num?)?.toInt(),
branchId: (json['branch_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$CreateUserRequestImplToJson(
_$CreateUserRequestImpl instance) =>
<String, dynamic>{
'username': instance.username,
'email': instance.email,
'password': instance.password,
'name': instance.name,
'phone': instance.phone,
'role': instance.role,
'company_id': instance.companyId,
'branch_id': instance.branchId,
};
_$UpdateUserRequestImpl _$$UpdateUserRequestImplFromJson(
Map<String, dynamic> json) =>
_$UpdateUserRequestImpl(
name: json['name'] as String?,
email: json['email'] as String?,
password: json['password'] as String?,
phone: json['phone'] as String?,
role: json['role'] as String?,
companyId: (json['company_id'] as num?)?.toInt(),
branchId: (json['branch_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$UpdateUserRequestImplToJson(
_$UpdateUserRequestImpl instance) =>
<String, dynamic>{
'name': instance.name,
'email': instance.email,
'password': instance.password,
'phone': instance.phone,
'role': instance.role,
'company_id': instance.companyId,
'branch_id': instance.branchId,
};
_$ChangeStatusRequestImpl _$$ChangeStatusRequestImplFromJson(
Map<String, dynamic> json) =>
_$ChangeStatusRequestImpl(
isActive: json['is_active'] as bool,
);
Map<String, dynamic> _$$ChangeStatusRequestImplToJson(
_$ChangeStatusRequestImpl instance) =>
<String, dynamic>{
'is_active': instance.isActive,
};
_$ChangePasswordRequestImpl _$$ChangePasswordRequestImplFromJson(
Map<String, dynamic> json) =>
_$ChangePasswordRequestImpl(
currentPassword: json['current_password'] as String,
newPassword: json['new_password'] as String,
);
Map<String, dynamic> _$$ChangePasswordRequestImplToJson(
_$ChangePasswordRequestImpl instance) =>
<String, dynamic>{
'current_password': instance.currentPassword,
'new_password': instance.newPassword,
};
_$UserListDtoImpl _$$UserListDtoImplFromJson(Map<String, dynamic> json) =>
_$UserListDtoImpl(
users: (json['users'] as List<dynamic>)
.map((e) => UserDto.fromJson(e as Map<String, dynamic>))
.toList(),
total: (json['total'] as num).toInt(),
page: (json['page'] as num).toInt(),
perPage: (json['per_page'] as num).toInt(),
totalPages: (json['total_pages'] as num).toInt(),
);
Map<String, dynamic> _$$UserListDtoImplToJson(_$UserListDtoImpl instance) =>
<String, dynamic>{
'users': instance.users,
'total': instance.total,
'page': instance.page,
'per_page': instance.perPage,
'total_pages': instance.totalPages,
};

View File

@@ -7,10 +7,12 @@ import '../data/datasources/remote/auth_remote_datasource.dart';
import '../data/datasources/remote/dashboard_remote_datasource.dart';
import '../data/datasources/remote/equipment_remote_datasource.dart';
import '../data/datasources/remote/company_remote_datasource.dart';
import '../data/datasources/remote/user_remote_datasource.dart';
import '../services/auth_service.dart';
import '../services/dashboard_service.dart';
import '../services/equipment_service.dart';
import '../services/company_service.dart';
import '../services/user_service.dart';
/// GetIt 인스턴스
final getIt = GetIt.instance;
@@ -38,7 +40,10 @@ Future<void> setupDependencies() async {
() => EquipmentRemoteDataSourceImpl(),
);
getIt.registerLazySingleton<CompanyRemoteDataSource>(
() => CompanyRemoteDataSourceImpl(),
() => CompanyRemoteDataSourceImpl(getIt()),
);
getIt.registerLazySingleton<UserRemoteDataSource>(
() => UserRemoteDataSource(),
);
// 서비스
@@ -52,7 +57,10 @@ Future<void> setupDependencies() async {
() => EquipmentService(),
);
getIt.registerLazySingleton<CompanyService>(
() => CompanyService(),
() => CompanyService(getIt()),
);
getIt.registerLazySingleton<UserService>(
() => UserService(),
);
// 리포지토리

View File

@@ -7,6 +7,10 @@ class User {
final String? position; // 직급
final String? email; // 이메일
final List<Map<String, String>> phoneNumbers; // 전화번호 목록 (유형과 번호)
final String? username; // 사용자명 (API 연동용)
final bool isActive; // 활성화 상태
final DateTime? createdAt; // 생성일
final DateTime? updatedAt; // 수정일
User({
this.id,
@@ -17,6 +21,10 @@ class User {
this.position,
this.email,
this.phoneNumbers = const [],
this.username,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
Map<String, dynamic> toJson() {
@@ -29,6 +37,10 @@ class User {
'position': position,
'email': email,
'phoneNumbers': phoneNumbers,
'username': username,
'isActive': isActive,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
@@ -45,6 +57,14 @@ class User {
json['phoneNumbers'] != null
? List<Map<String, String>>.from(json['phoneNumbers'])
: [],
username: json['username'],
isActive: json['isActive'] ?? true,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: null,
);
}
}

View File

@@ -1,24 +1,42 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/services/user_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/models/user_phone_field.dart';
// 사용자 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserFormController {
class UserFormController extends ChangeNotifier {
final MockDataService dataService;
final UserService _userService = GetIt.instance<UserService>();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// 상태 변수
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag
// 폼 필드
bool isEditMode = false;
int? userId;
String name = '';
String username = ''; // 추가
String password = ''; // 추가
int? companyId;
int? branchId;
String role = UserRoles.member;
String position = '';
String email = '';
// username 중복 확인
bool _isCheckingUsername = false;
bool? _isUsernameAvailable;
String? _lastCheckedUsername;
Timer? _usernameCheckTimer;
// 전화번호 관련 상태
final List<UserPhoneField> phoneFields = [];
final List<String> phoneTypes = ['휴대폰', '사무실', '팩스', '기타'];
@@ -26,11 +44,26 @@ class UserFormController {
List<Company> companies = [];
List<Branch> branches = [];
UserFormController({required this.dataService, this.userId});
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get isCheckingUsername => _isCheckingUsername;
bool? get isUsernameAvailable => _isUsernameAvailable;
UserFormController({required this.dataService, this.userId}) {
isEditMode = userId != null;
if (isEditMode) {
loadUser();
} else {
addPhoneField();
}
loadCompanies();
}
// 회사 목록 로드
void loadCompanies() {
companies = dataService.getAllCompanies();
notifyListeners();
}
// 회사 ID에 따라 지점 목록 로드
@@ -41,14 +74,29 @@ class UserFormController {
if (branchId != null && !branches.any((b) => b.id == branchId)) {
branchId = null;
}
notifyListeners();
}
// 사용자 정보 로드 (수정 모드)
void loadUser() {
Future<void> loadUser() async {
if (userId == null) return;
final user = dataService.getUserById(userId!);
_isLoading = true;
_error = null;
notifyListeners();
try {
User? user;
if (_useApi) {
user = await _userService.getUser(userId!);
} else {
user = dataService.getUserById(userId!);
}
if (user != null) {
name = user.name;
username = user.username ?? '';
companyId = user.companyId;
branchId = user.branchId;
role = user.role;
@@ -71,11 +119,18 @@ class UserFormController {
addPhoneField();
}
}
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
// 전화번호 필드 추가
void addPhoneField() {
phoneFields.add(UserPhoneField(type: '휴대폰'));
notifyListeners();
}
// 전화번호 필드 삭제
@@ -83,21 +138,115 @@ class UserFormController {
if (phoneFields.length > 1) {
phoneFields[index].dispose();
phoneFields.removeAt(index);
notifyListeners();
}
}
// Username 중복 확인
void checkUsernameAvailability(String value) {
if (value.isEmpty || value == _lastCheckedUsername) {
return;
}
// 디바운싱
_usernameCheckTimer?.cancel();
_usernameCheckTimer = Timer(const Duration(milliseconds: 500), () async {
_isCheckingUsername = true;
notifyListeners();
try {
if (_useApi) {
final isDuplicate = await _userService.checkDuplicateUsername(value);
_isUsernameAvailable = !isDuplicate;
} else {
// Mock 데이터에서 중복 확인
final users = dataService.getAllUsers();
final exists = users.any((u) => u.username == value && u.id != userId);
_isUsernameAvailable = !exists;
}
_lastCheckedUsername = value;
} catch (e) {
_isUsernameAvailable = null;
} finally {
_isCheckingUsername = false;
notifyListeners();
}
});
}
// 사용자 저장 (UI에서 호출)
void saveUser(Function(String? error) onResult) {
Future<void> saveUser(Function(String? error) onResult) async {
if (formKey.currentState?.validate() != true) {
onResult('폼 유효성 검사 실패');
return;
}
formKey.currentState?.save();
if (companyId == null) {
onResult('소속 회사를 선택해주세요');
return;
}
// 전화번호 목록 준비 (UserPhoneField 기반)
// 신규 등록 시 username 중복 확인
if (!isEditMode) {
if (username.isEmpty) {
onResult('사용자명을 입력해주세요');
return;
}
if (_isUsernameAvailable == false) {
onResult('이미 사용중인 사용자명입니다');
return;
}
if (password.isEmpty) {
onResult('비밀번호를 입력해주세요');
return;
}
}
_isLoading = true;
_error = null;
notifyListeners();
try {
// 전화번호 목록 준비
String? phoneNumber;
for (var phoneField in phoneFields) {
if (phoneField.number.isNotEmpty) {
phoneNumber = phoneField.number;
break; // API는 단일 전화번호만 지원
}
}
if (_useApi) {
if (isEditMode && userId != null) {
// 사용자 수정
await _userService.updateUser(
userId!,
name: name,
email: email.isNotEmpty ? email : null,
phone: phoneNumber,
companyId: companyId,
branchId: branchId,
role: role,
position: position.isNotEmpty ? position : null,
password: password.isNotEmpty ? password : null,
);
} else {
// 사용자 생성
await _userService.createUser(
username: username,
email: email,
password: password,
name: name,
role: role,
companyId: companyId!,
branchId: branchId,
phone: phoneNumber,
position: position.isNotEmpty ? position : null,
);
}
} else {
// Mock 데이터 사용
List<Map<String, String>> phoneNumbersList = [];
for (var phoneField in phoneFields) {
if (phoneField.number.isNotEmpty) {
@@ -107,6 +256,7 @@ class UserFormController {
});
}
}
if (isEditMode && userId != null) {
final user = dataService.getUserById(userId!);
if (user != null) {
@@ -119,6 +269,10 @@ class UserFormController {
position: position.isNotEmpty ? position : null,
email: email.isNotEmpty ? email : null,
phoneNumbers: phoneNumbersList,
username: username.isNotEmpty ? username : null,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: DateTime.now(),
);
dataService.updateUser(updatedUser);
}
@@ -131,16 +285,38 @@ class UserFormController {
position: position.isNotEmpty ? position : null,
email: email.isNotEmpty ? email : null,
phoneNumbers: phoneNumbersList,
username: username,
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
dataService.addUser(newUser);
}
}
onResult(null);
} catch (e) {
_error = e.toString();
onResult(_error);
} finally {
_isLoading = false;
notifyListeners();
}
}
// 컨트롤러 해제
@override
void dispose() {
_usernameCheckTimer?.cancel();
for (var phoneField in phoneFields) {
phoneField.dispose();
}
super.dispose();
}
// API/Mock 모드 전환
void toggleApiMode() {
_useApi = !_useApi;
notifyListeners();
}
}

View File

@@ -1,28 +1,203 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/services/user_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/utils/user_utils.dart';
/// 담당자 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserListController extends ChangeNotifier {
final MockDataService dataService;
List<User> users = [];
final UserService _userService = GetIt.instance<UserService>();
// 상태 변수
List<User> _users = [];
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMoreData = true;
bool _isLoadingMore = false;
// 검색/필터
String _searchQuery = '';
int? _filterCompanyId;
String? _filterRole;
bool? _filterIsActive;
// Getters
List<User> get users => _users;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
String? get error => _error;
bool get hasMoreData => _hasMoreData;
String get searchQuery => _searchQuery;
int? get filterCompanyId => _filterCompanyId;
String? get filterRole => _filterRole;
bool? get filterIsActive => _filterIsActive;
UserListController({required this.dataService});
/// 사용자 목록 데이터 로드
void loadUsers() {
users = dataService.getAllUsers();
/// 사용자 목록 초기 로드
Future<void> loadUsers({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
_hasMoreData = true;
_users.clear();
}
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (_useApi) {
final newUsers = await _userService.getUsers(
page: _currentPage,
perPage: _perPage,
isActive: _filterIsActive,
companyId: _filterCompanyId,
role: _filterRole,
);
if (newUsers.isEmpty || newUsers.length < _perPage) {
_hasMoreData = false;
}
if (_currentPage == 1) {
_users = newUsers;
} else {
_users.addAll(newUsers);
}
_currentPage++;
} else {
// Mock 데이터 사용
var allUsers = dataService.getAllUsers();
// 필터 적용
if (_filterCompanyId != null) {
allUsers = allUsers.where((u) => u.companyId == _filterCompanyId).toList();
}
if (_filterRole != null) {
allUsers = allUsers.where((u) => u.role == _filterRole).toList();
}
if (_filterIsActive != null) {
allUsers = allUsers.where((u) => u.isActive == _filterIsActive).toList();
}
// 검색 적용
if (_searchQuery.isNotEmpty) {
allUsers = allUsers.where((u) =>
u.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
(u.email?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false) ||
(u.username?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false)
).toList();
}
_users = allUsers;
_hasMoreData = false;
}
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 다음 페이지 로드 (무한 스크롤용)
Future<void> loadMore() async {
if (!_hasMoreData || _isLoadingMore || _isLoading) return;
_isLoadingMore = true;
notifyListeners();
try {
await loadUsers();
} finally {
_isLoadingMore = false;
notifyListeners();
}
}
/// 검색 쿼리 설정
void setSearchQuery(String query) {
_searchQuery = query;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 필터 설정
void setFilters({
int? companyId,
String? role,
bool? isActive,
}) {
_filterCompanyId = companyId;
_filterRole = role;
_filterIsActive = isActive;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 필터 초기화
void clearFilters() {
_filterCompanyId = null;
_filterRole = null;
_filterIsActive = null;
_searchQuery = '';
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 사용자 삭제
void deleteUser(int id, VoidCallback onDeleted) {
Future<void> deleteUser(int id, VoidCallback onDeleted, Function(String) onError) async {
try {
if (_useApi) {
await _userService.deleteUser(id);
} else {
dataService.deleteUser(id);
loadUsers();
}
// 목록에서 삭제된 사용자 제거
_users.removeWhere((user) => user.id == id);
notifyListeners();
onDeleted();
} catch (e) {
onError('사용자 삭제 실패: ${e.toString()}');
}
}
/// 사용자 상태 변경 (활성/비활성)
Future<void> changeUserStatus(int id, bool isActive, Function(String) onError) async {
try {
if (_useApi) {
final updatedUser = await _userService.changeUserStatus(id, isActive);
// 목록에서 해당 사용자 업데이트
final index = _users.indexWhere((u) => u.id == id);
if (index != -1) {
_users[index] = updatedUser;
notifyListeners();
}
} else {
// Mock 데이터에서는 상태 변경 지원 안함
onError('Mock 데이터에서는 상태 변경을 지원하지 않습니다');
}
} catch (e) {
onError('상태 변경 실패: ${e.toString()}');
}
}
/// 권한명 반환 함수는 user_utils.dart의 getRoleName을 사용
@@ -39,4 +214,10 @@ class UserListController extends ChangeNotifier {
);
return branch.name;
}
/// API/Mock 모드 전환
void toggleApiMode() {
_useApi = !_useApi;
loadUsers(refresh: true);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
@@ -21,38 +22,37 @@ class UserFormScreen extends StatefulWidget {
}
class _UserFormScreenState extends State<UserFormScreen> {
late final UserFormController _controller;
@override
void initState() {
super.initState();
_controller = UserFormController(
dataService: MockDataService(),
userId: widget.userId,
);
_controller.isEditMode = widget.userId != null;
_controller.loadCompanies();
if (_controller.isEditMode) {
_controller.loadUser();
} else if (_controller.phoneFields.isEmpty) {
_controller.addPhoneField();
}
}
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
bool _showPassword = false;
bool _showConfirmPassword = false;
@override
void dispose() {
_controller.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => UserFormController(
dataService: MockDataService(),
userId: widget.userId,
),
child: Consumer<UserFormController>(
builder: (context, controller, child) {
return Scaffold(
appBar: AppBar(title: Text(_controller.isEditMode ? '사용자 수정' : '사용자 등록')),
body: Padding(
appBar: AppBar(
title: Text(controller.isEditMode ? '사용자 수정' : '사용자 등록'),
),
body: controller.isLoading
? const Center(child: CircularProgressIndicator())
: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
key: controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -60,68 +60,238 @@ class _UserFormScreenState extends State<UserFormScreen> {
// 이름
_buildTextField(
label: '이름',
initialValue: _controller.name,
initialValue: controller.name,
hintText: '사용자 이름을 입력하세요',
validator: (value) => validateRequired(value, '이름'),
onSaved: (value) => _controller.name = value!,
onSaved: (value) => controller.name = value!,
),
// 사용자명 (신규 등록 시만)
if (!controller.isEditMode) ...[
_buildTextField(
label: '사용자명',
initialValue: controller.username,
hintText: '로그인에 사용할 사용자명',
validator: (value) {
if (value == null || value.isEmpty) {
return '사용자명을 입력해주세요';
}
if (value.length < 3) {
return '사용자명은 3자 이상이어야 합니다';
}
if (controller.isUsernameAvailable == false) {
return '이미 사용 중인 사용자명입니다';
}
return null;
},
onChanged: (value) {
controller.username = value;
controller.checkUsernameAvailability(value);
},
onSaved: (value) => controller.username = value!,
suffixIcon: controller.isCheckingUsername
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(12.0),
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
)
: controller.isUsernameAvailable != null
? Icon(
controller.isUsernameAvailable!
? Icons.check_circle
: Icons.cancel,
color: controller.isUsernameAvailable!
? Colors.green
: Colors.red,
)
: null,
),
// 비밀번호
_buildPasswordField(
label: '비밀번호',
controller: _passwordController,
hintText: '비밀번호를 입력하세요',
obscureText: !_showPassword,
onToggleVisibility: () {
setState(() {
_showPassword = !_showPassword;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요';
}
if (value.length < 6) {
return '비밀번호는 6자 이상이어야 합니다';
}
return null;
},
onSaved: (value) => controller.password = value!,
),
// 비밀번호 확인
_buildPasswordField(
label: '비밀번호 확인',
controller: _confirmPasswordController,
hintText: '비밀번호를 다시 입력하세요',
obscureText: !_showConfirmPassword,
onToggleVisibility: () {
setState(() {
_showConfirmPassword = !_showConfirmPassword;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 다시 입력해주세요';
}
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
),
],
// 수정 모드에서 비밀번호 변경 (선택사항)
if (controller.isEditMode) ...[
ExpansionTile(
title: const Text('비밀번호 변경'),
children: [
_buildPasswordField(
label: '새 비밀번호',
controller: _passwordController,
hintText: '변경할 경우만 입력하세요',
obscureText: !_showPassword,
onToggleVisibility: () {
setState(() {
_showPassword = !_showPassword;
});
},
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 6) {
return '비밀번호는 6자 이상이어야 합니다';
}
return null;
},
onSaved: (value) => controller.password = value ?? '',
),
_buildPasswordField(
label: '새 비밀번호 확인',
controller: _confirmPasswordController,
hintText: '비밀번호를 다시 입력하세요',
obscureText: !_showConfirmPassword,
onToggleVisibility: () {
setState(() {
_showConfirmPassword = !_showConfirmPassword;
});
},
validator: (value) {
if (_passwordController.text.isNotEmpty && value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
),
],
),
],
// 직급
_buildTextField(
label: '직급',
initialValue: _controller.position,
initialValue: controller.position,
hintText: '직급을 입력하세요',
onSaved: (value) => _controller.position = value ?? '',
onSaved: (value) => controller.position = value ?? '',
),
// 소속 회사/지점
CompanyBranchDropdown(
companies: _controller.companies,
selectedCompanyId: _controller.companyId,
selectedBranchId: _controller.branchId,
branches: _controller.branches,
companies: controller.companies,
selectedCompanyId: controller.companyId,
selectedBranchId: controller.branchId,
branches: controller.branches,
onCompanyChanged: (value) {
setState(() {
_controller.companyId = value;
_controller.branchId = null;
controller.companyId = value;
controller.branchId = null;
if (value != null) {
_controller.loadBranches(value);
controller.loadBranches(value);
} else {
_controller.branches = [];
controller.branches = [];
}
});
},
onBranchChanged: (value) {
setState(() {
_controller.branchId = value;
});
controller.branchId = value;
},
),
// 이메일
_buildTextField(
label: '이메일',
initialValue: _controller.email,
initialValue: controller.email,
hintText: '이메일을 입력하세요',
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) return null;
return validateEmail(value);
},
onSaved: (value) => _controller.email = value ?? '',
onSaved: (value) => controller.email = value ?? '',
),
// 전화번호
_buildPhoneFieldsSection(),
_buildPhoneFieldsSection(controller),
// 권한
_buildRoleRadio(),
_buildRoleRadio(controller),
const SizedBox(height: 24),
// 오류 메시지 표시
if (controller.error != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
controller.error!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _onSaveUser,
onPressed: controller.isLoading
? null
: () => _onSaveUser(controller),
style: AppThemeTailwind.primaryButtonStyle,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
_controller.isEditMode ? '수정하기' : '등록하기',
child: controller.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
),
@@ -133,6 +303,9 @@ class _UserFormScreenState extends State<UserFormScreen> {
),
),
);
},
),
);
}
// 이름/직급/이메일 등 공통 텍스트 필드 위젯
@@ -144,6 +317,8 @@ class _UserFormScreenState extends State<UserFormScreen> {
List<TextInputFormatter>? inputFormatters,
String? Function(String?)? validator,
void Function(String?)? onSaved,
void Function(String)? onChanged,
Widget? suffixIcon,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
@@ -154,11 +329,52 @@ class _UserFormScreenState extends State<UserFormScreen> {
const SizedBox(height: 4),
TextFormField(
initialValue: initialValue,
decoration: InputDecoration(hintText: hintText),
decoration: InputDecoration(
hintText: hintText,
suffixIcon: suffixIcon,
),
keyboardType: keyboardType,
inputFormatters: inputFormatters,
validator: validator,
onSaved: onSaved,
onChanged: onChanged,
),
],
),
);
}
// 비밀번호 필드 위젯
Widget _buildPasswordField({
required String label,
required TextEditingController controller,
required String hintText,
required bool obscureText,
required VoidCallback onToggleVisibility,
String? Function(String?)? validator,
void Function(String?)? onSaved,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
TextFormField(
controller: controller,
obscureText: obscureText,
decoration: InputDecoration(
hintText: hintText,
suffixIcon: IconButton(
icon: Icon(
obscureText ? Icons.visibility : Icons.visibility_off,
),
onPressed: onToggleVisibility,
),
),
validator: validator,
onSaved: onSaved,
),
],
),
@@ -166,13 +382,13 @@ class _UserFormScreenState extends State<UserFormScreen> {
}
// 전화번호 입력 필드 섹션 위젯 (UserPhoneField 기반)
Widget _buildPhoneFieldsSection() {
Widget _buildPhoneFieldsSection(UserFormController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('전화번호', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
..._controller.phoneFields.asMap().entries.map((entry) {
...controller.phoneFields.asMap().entries.map((entry) {
final i = entry.key;
final phoneField = entry.value;
return Row(
@@ -180,17 +396,11 @@ class _UserFormScreenState extends State<UserFormScreen> {
// 종류 드롭다운
DropdownButton<String>(
value: phoneField.type,
items:
_controller.phoneTypes
.map(
(type) =>
DropdownMenuItem(value: type, child: Text(type)),
)
items: controller.phoneTypes
.map((type) => DropdownMenuItem(value: type, child: Text(type)))
.toList(),
onChanged: (value) {
setState(() {
phoneField.type = value!;
});
},
),
const SizedBox(width: 8),
@@ -205,13 +415,8 @@ class _UserFormScreenState extends State<UserFormScreen> {
),
IconButton(
icon: const Icon(Icons.remove_circle, color: Colors.red),
onPressed:
_controller.phoneFields.length > 1
? () {
setState(() {
_controller.removePhoneField(i);
});
}
onPressed: controller.phoneFields.length > 1
? () => controller.removePhoneField(i)
: null,
),
],
@@ -221,11 +426,7 @@ class _UserFormScreenState extends State<UserFormScreen> {
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
setState(() {
_controller.addPhoneField();
});
},
onPressed: () => controller.addPhoneField(),
icon: const Icon(Icons.add),
label: const Text('전화번호 추가'),
),
@@ -235,7 +436,7 @@ class _UserFormScreenState extends State<UserFormScreen> {
}
// 권한(관리등급) 라디오 위젯
Widget _buildRoleRadio() {
Widget _buildRoleRadio(UserFormController controller) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
@@ -249,11 +450,9 @@ class _UserFormScreenState extends State<UserFormScreen> {
child: RadioListTile<String>(
title: const Text('관리자'),
value: UserRoles.admin,
groupValue: _controller.role,
groupValue: controller.role,
onChanged: (value) {
setState(() {
_controller.role = value!;
});
controller.role = value!;
},
),
),
@@ -261,11 +460,9 @@ class _UserFormScreenState extends State<UserFormScreen> {
child: RadioListTile<String>(
title: const Text('일반 사용자'),
value: UserRoles.member,
groupValue: _controller.role,
groupValue: controller.role,
onChanged: (value) {
setState(() {
_controller.role = value!;
});
controller.role = value!;
},
),
),
@@ -277,17 +474,24 @@ class _UserFormScreenState extends State<UserFormScreen> {
}
// 저장 버튼 클릭 시 사용자 저장
void _onSaveUser() {
setState(() {
_controller.saveUser((error) {
void _onSaveUser(UserFormController controller) async {
await controller.saveUser((error) {
if (error != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
backgroundColor: Colors.red,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(controller.isEditMode ? '사용자 정보가 수정되었습니다' : '사용자가 등록되었습니다'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true);
}
});
});
}
}

View File

@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/user/controllers/user_list_controller.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/user_utils.dart';
/// shadcn/ui 스타일로 재설계된 사용자 관리 화면
class UserListRedesign extends StatefulWidget {
@@ -15,28 +18,49 @@ class UserListRedesign extends StatefulWidget {
}
class _UserListRedesignState extends State<UserListRedesign> {
late final UserListController _controller;
final MockDataService _dataService = MockDataService();
int _currentPage = 1;
final int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_controller = UserListController(dataService: _dataService);
_controller.loadUsers();
_controller.addListener(_refresh);
// 초기 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserListController>().loadUsers();
});
// 무한 스크롤 설정
_scrollController.addListener(_onScroll);
// 검색 디바운싱
_searchController.addListener(() {
_onSearchChanged(_searchController.text);
});
}
@override
void dispose() {
_controller.removeListener(_refresh);
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
/// 상태 갱신용 setState 래퍼
void _refresh() {
setState(() {});
/// 스크롤 이벤트 처리
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
context.read<UserListController>().loadMore();
}
}
/// 검색어 변경 처리 (디바운싱)
Timer? _debounce;
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
context.read<UserListController>().setSearchQuery(query);
});
}
/// 회사명 반환 함수
@@ -45,35 +69,39 @@ class _UserListRedesignState extends State<UserListRedesign> {
return company?.name ?? '-';
}
/// 상태별 색상 반환
Color _getStatusColor(bool isActive) {
return isActive ? Colors.green : Colors.red;
}
/// 사용자 권한 표시 배지
Widget _buildUserRoleBadge(String role) {
final roleName = getRoleName(role);
ShadcnBadgeVariant variant;
switch (role) {
case 'S':
return ShadcnBadge(
text: '관리자',
variant: ShadcnBadgeVariant.destructive,
size: ShadcnBadgeSize.small,
);
variant = ShadcnBadgeVariant.destructive;
break;
case 'M':
return ShadcnBadge(
text: '멤버',
variant: ShadcnBadgeVariant.primary,
size: ShadcnBadgeSize.small,
);
variant = ShadcnBadgeVariant.primary;
break;
default:
variant = ShadcnBadgeVariant.outline;
}
return ShadcnBadge(
text: '사용자',
variant: ShadcnBadgeVariant.outline,
text: roleName,
variant: variant,
size: ShadcnBadgeSize.small,
);
}
}
/// 사용자 추가 폼으로 이동
void _navigateToAdd() async {
final result = await Navigator.pushNamed(context, Routes.userAdd);
if (result == true) {
_controller.loadUsers();
if (result == true && mounted) {
context.read<UserListController>().loadUsers(refresh: true);
}
}
@@ -84,32 +112,78 @@ class _UserListRedesignState extends State<UserListRedesign> {
Routes.userEdit,
arguments: userId,
);
if (result == true) {
_controller.loadUsers();
if (result == true && mounted) {
context.read<UserListController>().loadUsers(refresh: true);
}
}
/// 사용자 삭제 다이얼로그
void _showDeleteDialog(int userId) {
void _showDeleteDialog(int userId, String userName) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
builder: (context) => AlertDialog(
title: const Text('사용자 삭제'),
content: const Text('정말로 삭제하시겠습니까?'),
content: Text('"$userName" 사용자를 정말로 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () {
_controller.deleteUser(userId, () {
setState(() {});
});
onPressed: () async {
Navigator.of(context).pop();
await context.read<UserListController>().deleteUser(
userId,
() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('사용자가 삭제되었습니다')),
);
},
child: const Text('삭제'),
(error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.red),
);
},
);
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
),
],
),
);
}
/// 상태 변경 확인 다이얼로그
void _showStatusChangeDialog(User user) {
final newStatus = !user.isActive;
final statusText = newStatus ? '활성화' : '비활성화';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('사용자 상태 변경'),
content: Text('"${user.name}" 사용자를 $statusText 하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await context.read<UserListController>().changeUserStatus(
user.id!,
newStatus,
(error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.red),
);
},
);
},
child: Text(statusText),
),
],
),
@@ -118,34 +192,175 @@ class _UserListRedesignState extends State<UserListRedesign> {
@override
Widget build(BuildContext context) {
final int totalCount = _controller.users.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final List<User> pagedUsers = _controller.users.sublist(
startIndex,
endIndex,
return ChangeNotifierProvider(
create: (_) => UserListController(dataService: _dataService),
child: Consumer<UserListController>(
builder: (context, controller, child) {
if (controller.isLoading && controller.users.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (controller.error != null && controller.users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
const SizedBox(height: 16),
Text(
'데이터를 불러올 수 없습니다',
style: ShadcnTheme.h4,
),
const SizedBox(height: 8),
Text(
controller.error!,
style: ShadcnTheme.bodyMuted,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ShadcnButton(
text: '다시 시도',
onPressed: () => controller.loadUsers(refresh: true),
variant: ShadcnButtonVariant.primary,
),
],
),
);
}
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 검색 및 필터 섹션
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
side: BorderSide(color: ShadcnTheme.border),
),
child: Padding(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Column(
children: [
// 검색 바
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '이름, 이메일, 사용자명으로 검색...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
controller.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: ShadcnTheme.spacing3,
),
),
),
const SizedBox(height: ShadcnTheme.spacing3),
// 필터 버튼들
Row(
children: [
// 상태 필터
ShadcnButton(
text: controller.filterIsActive == null
? '모든 상태'
: controller.filterIsActive!
? '활성 사용자'
: '비활성 사용자',
onPressed: () {
controller.setFilters(
isActive: controller.filterIsActive == null
? true
: controller.filterIsActive!
? false
: null,
);
},
variant: ShadcnButtonVariant.outline,
icon: const Icon(Icons.filter_list),
),
const SizedBox(width: ShadcnTheme.spacing2),
// 권한 필터
PopupMenuButton<String?>(
child: ShadcnButton(
text: controller.filterRole == null
? '모든 권한'
: getRoleName(controller.filterRole!),
onPressed: null,
variant: ShadcnButtonVariant.outline,
icon: const Icon(Icons.person),
),
onSelected: (role) {
controller.setFilters(role: role);
},
itemBuilder: (context) => [
const PopupMenuItem(
value: null,
child: Text('모든 권한'),
),
const PopupMenuItem(
value: 'S',
child: Text('관리자'),
),
const PopupMenuItem(
value: 'M',
child: Text('멤버'),
),
],
),
const Spacer(),
// 필터 초기화
if (controller.searchQuery.isNotEmpty ||
controller.filterIsActive != null ||
controller.filterRole != null)
ShadcnButton(
text: '필터 초기화',
onPressed: () {
_searchController.clear();
controller.clearFilters();
},
variant: ShadcnButtonVariant.ghost,
icon: const Icon(Icons.clear_all),
),
],
),
],
),
),
),
const SizedBox(height: ShadcnTheme.spacing4),
// 헤더 액션 바
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$totalCount명 사용자', style: ShadcnTheme.bodyMuted),
Text(
'${controller.users.length}명 사용자',
style: ShadcnTheme.bodyMuted,
),
Row(
children: [
ShadcnButton(
text: '새로고침',
onPressed: _controller.loadUsers,
onPressed: () => controller.loadUsers(refresh: true),
variant: ShadcnButtonVariant.secondary,
icon: Icon(Icons.refresh),
icon: const Icon(Icons.refresh),
),
const SizedBox(width: ShadcnTheme.spacing2),
ShadcnButton(
@@ -153,7 +368,7 @@ class _UserListRedesignState extends State<UserListRedesign> {
onPressed: _navigateToAdd,
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
icon: Icon(Icons.add),
icon: const Icon(Icons.add),
),
],
),
@@ -163,8 +378,7 @@ class _UserListRedesignState extends State<UserListRedesign> {
const SizedBox(height: ShadcnTheme.spacing4),
// 테이블 컨테이너
Expanded(
child: Container(
Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: ShadcnTheme.border),
@@ -187,51 +401,35 @@ class _UserListRedesignState extends State<UserListRedesign> {
),
child: Row(
children: [
Expanded(
flex: 1,
child: Text('번호', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 2,
child: Text('사용자명', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 2,
child: Text('이메일', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 2,
child: Text('회사명', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 2,
child: Text('지점명', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 1,
child: Text('권한', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 1,
child: Text('관리', style: ShadcnTheme.bodyMedium),
),
const SizedBox(width: 50, child: Text('번호', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('사용자명', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('이메일', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('회사명', style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(flex: 2, child: Text('지점명', style: TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 100, child: Text('권한', style: TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 80, child: Text('상태', style: TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 120, child: Text('관리', style: TextStyle(fontWeight: FontWeight.bold))),
],
),
),
// 테이블 데이터
if (pagedUsers.isEmpty)
if (controller.users.isEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: Text(
'등록된 사용자가 없습니다.',
controller.searchQuery.isNotEmpty ||
controller.filterIsActive != null ||
controller.filterRole != null
? '검색 결과가 없습니다.'
: '등록된 사용자가 없습니다.',
style: ShadcnTheme.bodyMuted,
),
),
)
else
...pagedUsers.asMap().entries.map((entry) {
...controller.users.asMap().entries.map((entry) {
final int index = entry.key;
final User user = entry.value;
@@ -241,24 +439,37 @@ class _UserListRedesignState extends State<UserListRedesign> {
border: Border(
bottom: BorderSide(color: ShadcnTheme.border),
),
color: index % 2 == 0 ? null : ShadcnTheme.muted.withValues(alpha: 0.1),
),
child: Row(
children: [
// 번호
Expanded(
flex: 1,
SizedBox(
width: 50,
child: Text(
'${startIndex + index + 1}',
'${index + 1}',
style: ShadcnTheme.bodySmall,
),
),
// 사용자명
Expanded(
flex: 2,
child: Text(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
style: ShadcnTheme.bodyMedium,
),
if (user.username != null)
Text(
'@${user.username}',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
),
),
],
),
),
// 이메일
Expanded(
@@ -280,7 +491,7 @@ class _UserListRedesignState extends State<UserListRedesign> {
Expanded(
flex: 2,
child: Text(
_controller.getBranchName(
controller.getBranchName(
user.companyId,
user.branchId,
),
@@ -288,24 +499,54 @@ class _UserListRedesignState extends State<UserListRedesign> {
),
),
// 권한
Expanded(
flex: 1,
SizedBox(
width: 100,
child: _buildUserRoleBadge(user.role),
),
// 상태
SizedBox(
width: 80,
child: Row(
children: [
Icon(
Icons.circle,
size: 8,
color: _getStatusColor(user.isActive),
),
const SizedBox(width: 4),
Text(
user.isActive ? '활성' : '비활성',
style: ShadcnTheme.bodySmall.copyWith(
color: _getStatusColor(user.isActive),
),
),
],
),
),
// 관리
Expanded(
flex: 1,
SizedBox(
width: 120,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
Icons.power_settings_new,
size: 16,
color: user.isActive ? Colors.orange : Colors.green,
),
onPressed: user.id != null
? () => _showStatusChangeDialog(user)
: null,
tooltip: user.isActive ? '비활성화' : '활성화',
),
IconButton(
icon: Icon(
Icons.edit,
size: 16,
color: ShadcnTheme.primary,
),
onPressed:
user.id != null
onPressed: user.id != null
? () => _navigateToEdit(user.id!)
: null,
tooltip: '수정',
@@ -316,9 +557,8 @@ class _UserListRedesignState extends State<UserListRedesign> {
size: 16,
color: ShadcnTheme.destructive,
),
onPressed:
user.id != null
? () => _showDeleteDialog(user.id!)
onPressed: user.id != null
? () => _showDeleteDialog(user.id!, user.name)
: null,
tooltip: '삭제',
),
@@ -332,49 +572,31 @@ class _UserListRedesignState extends State<UserListRedesign> {
],
),
),
// 무한 스크롤 로딩 인디케이터
if (controller.isLoadingMore)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: const Center(
child: CircularProgressIndicator(),
),
// 페이지네이션
if (totalCount > _pageSize) ...[
const SizedBox(height: ShadcnTheme.spacing6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ShadcnButton(
text: '이전',
onPressed:
_currentPage > 1
? () {
setState(() {
_currentPage--;
});
}
: null,
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
),
const SizedBox(width: ShadcnTheme.spacing2),
Text(
'$_currentPage / ${(totalCount / _pageSize).ceil()}',
// 더 이상 데이터가 없을 때
if (!controller.hasMoreData && controller.users.isNotEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Center(
child: Text(
'모든 사용자를 불러왔습니다',
style: ShadcnTheme.bodyMuted,
),
const SizedBox(width: ShadcnTheme.spacing2),
ShadcnButton(
text: '다음',
onPressed:
_currentPage < (totalCount / _pageSize).ceil()
? () {
setState(() {
_currentPage++;
});
}
: null,
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
),
),
],
),
],
],
);
},
),
);
}

View File

@@ -1,4 +1,5 @@
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/core/errors/exceptions.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/data/datasources/remote/company_remote_datasource.dart';
@@ -8,8 +9,11 @@ import 'package:superport/data/models/company/branch_dto.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
@lazySingleton
class CompanyService {
final CompanyRemoteDataSource _remoteDataSource = GetIt.instance<CompanyRemoteDataSource>();
final CompanyRemoteDataSource _remoteDataSource;
CompanyService(this._remoteDataSource);
// 회사 목록 조회
Future<List<Company>> getCompanies({
@@ -19,15 +23,15 @@ class CompanyService {
bool? isActive,
}) async {
try {
final dtoList = await _remoteDataSource.getCompanies(
final response = await _remoteDataSource.getCompanies(
page: page,
perPage: perPage,
search: search,
isActive: isActive,
);
return dtoList.map((dto) => _convertListDtoToCompany(dto)).toList();
} on ServerException catch (e) {
return response.items.map((dto) => _convertListDtoToCompany(dto)).toList();
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to fetch company list: $e');
@@ -50,7 +54,7 @@ class CompanyService {
final response = await _remoteDataSource.createCompany(request);
return _convertResponseToCompany(response);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to create company: $e');
@@ -62,7 +66,7 @@ class CompanyService {
try {
final response = await _remoteDataSource.getCompanyDetail(id);
return _convertResponseToCompany(response);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to fetch company detail: $e');
@@ -77,7 +81,7 @@ class CompanyService {
final branches = response.branches.map((dto) => _convertBranchDtoToBranch(dto)).toList();
return company.copyWith(branches: branches);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to fetch company with branches: $e');
@@ -100,7 +104,7 @@ class CompanyService {
final response = await _remoteDataSource.updateCompany(id, request);
return _convertResponseToCompany(response);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to update company: $e');
@@ -111,7 +115,7 @@ class CompanyService {
Future<void> deleteCompany(int id) async {
try {
await _remoteDataSource.deleteCompany(id);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to delete company: $e');
@@ -126,7 +130,7 @@ class CompanyService {
'id': dto.id,
'name': dto.name,
}).toList();
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to fetch company names: $e');
@@ -147,7 +151,7 @@ class CompanyService {
final response = await _remoteDataSource.createBranch(companyId, request);
return _convertBranchResponseToBranch(response);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to create branch: $e');
@@ -158,7 +162,7 @@ class CompanyService {
try {
final response = await _remoteDataSource.getBranchDetail(companyId, branchId);
return _convertBranchResponseToBranch(response);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to fetch branch detail: $e');
@@ -178,7 +182,7 @@ class CompanyService {
final response = await _remoteDataSource.updateBranch(companyId, branchId, request);
return _convertBranchResponseToBranch(response);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to update branch: $e');
@@ -188,7 +192,7 @@ class CompanyService {
Future<void> deleteBranch(int companyId, int branchId) async {
try {
await _remoteDataSource.deleteBranch(companyId, branchId);
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to delete branch: $e');
@@ -199,13 +203,58 @@ class CompanyService {
try {
final dtoList = await _remoteDataSource.getCompanyBranches(companyId);
return dtoList.map((dto) => _convertBranchDtoToBranch(dto)).toList();
} on ServerException catch (e) {
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to fetch company branches: $e');
}
}
// 회사-지점 전체 정보 조회
Future<List<CompanyWithBranches>> getCompaniesWithBranches() async {
try {
return await _remoteDataSource.getCompaniesWithBranches();
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to fetch companies with branches: $e');
}
}
// 회사명 중복 확인
Future<bool> checkDuplicateCompany(String name) async {
try {
return await _remoteDataSource.checkDuplicateCompany(name);
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to check duplicate: $e');
}
}
// 회사 검색
Future<List<Company>> searchCompanies(String query) async {
try {
final dtoList = await _remoteDataSource.searchCompanies(query);
return dtoList.map((dto) => _convertListDtoToCompany(dto)).toList();
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to search companies: $e');
}
}
// 회사 활성 상태 변경
Future<void> updateCompanyStatus(int id, bool isActive) async {
try {
await _remoteDataSource.updateCompanyStatus(id, isActive);
} on ApiException catch (e) {
throw Failure(message: e.message);
} catch (e) {
throw Failure(message: 'Failed to update company status: $e');
}
}
// 변환 헬퍼 메서드들
Company _convertListDtoToCompany(CompanyListDto dto) {
return Company(

View File

@@ -0,0 +1,231 @@
import 'package:injectable/injectable.dart';
import 'package:superport/core/errors/exceptions.dart';
import 'package:superport/data/datasources/remote/user_remote_datasource.dart';
import 'package:superport/data/models/user/user_dto.dart';
import 'package:superport/models/user_model.dart';
@lazySingleton
class UserService {
final UserRemoteDataSource _userRemoteDataSource;
UserService() : _userRemoteDataSource = UserRemoteDataSource();
/// 사용자 목록 조회
Future<List<User>> getUsers({
int page = 1,
int perPage = 20,
bool? isActive,
int? companyId,
String? role,
}) async {
try {
final response = await _userRemoteDataSource.getUsers(
page: page,
perPage: perPage,
isActive: isActive,
companyId: companyId,
role: role != null ? _mapRoleToApi(role) : null,
);
return response.users.map((dto) => _userDtoToModel(dto)).toList();
} catch (e) {
throw Exception('사용자 목록 조회 실패: ${e.toString()}');
}
}
/// 특정 사용자 조회
Future<User> getUser(int id) async {
try {
final dto = await _userRemoteDataSource.getUser(id);
return _userDtoToModel(dto);
} catch (e) {
throw Exception('사용자 조회 실패: ${e.toString()}');
}
}
/// 사용자 생성
Future<User> createUser({
required String username,
required String email,
required String password,
required String name,
required String role,
required int companyId,
int? branchId,
String? phone,
String? position,
}) async {
try {
final request = CreateUserRequest(
username: username,
email: email,
password: password,
name: name,
role: _mapRoleToApi(role),
companyId: companyId,
branchId: branchId,
phone: phone,
);
final dto = await _userRemoteDataSource.createUser(request);
return _userDtoToModel(dto);
} catch (e) {
throw Exception('사용자 생성 실패: ${e.toString()}');
}
}
/// 사용자 정보 수정
Future<User> updateUser(
int id, {
String? name,
String? email,
String? password,
String? phone,
int? companyId,
int? branchId,
String? role,
String? position,
}) async {
try {
final request = UpdateUserRequest(
name: name,
email: email,
password: password,
phone: phone,
companyId: companyId,
branchId: branchId,
role: role != null ? _mapRoleToApi(role) : null,
);
final dto = await _userRemoteDataSource.updateUser(id, request);
return _userDtoToModel(dto);
} catch (e) {
throw Exception('사용자 수정 실패: ${e.toString()}');
}
}
/// 사용자 삭제
Future<void> deleteUser(int id) async {
try {
await _userRemoteDataSource.deleteUser(id);
} catch (e) {
throw Exception('사용자 삭제 실패: ${e.toString()}');
}
}
/// 사용자 상태 변경
Future<User> changeUserStatus(int id, bool isActive) async {
try {
final request = ChangeStatusRequest(isActive: isActive);
final dto = await _userRemoteDataSource.changeUserStatus(id, request);
return _userDtoToModel(dto);
} catch (e) {
throw Exception('사용자 상태 변경 실패: ${e.toString()}');
}
}
/// 비밀번호 변경
Future<void> changePassword(
int id,
String currentPassword,
String newPassword,
) async {
try {
final request = ChangePasswordRequest(
currentPassword: currentPassword,
newPassword: newPassword,
);
await _userRemoteDataSource.changePassword(id, request);
} catch (e) {
throw Exception('비밀번호 변경 실패: ${e.toString()}');
}
}
/// 사용자명 중복 확인
Future<bool> checkDuplicateUsername(String username) async {
try {
return await _userRemoteDataSource.checkDuplicateUsername(username);
} catch (e) {
throw Exception('중복 확인 실패: ${e.toString()}');
}
}
/// 사용자 검색
Future<List<User>> searchUsers({
required String query,
int? companyId,
String? status,
String? permissionLevel,
int page = 1,
int perPage = 20,
}) async {
try {
final response = await _userRemoteDataSource.searchUsers(
query: query,
companyId: companyId,
status: status,
permissionLevel: permissionLevel != null
? _mapRoleToApi(permissionLevel)
: null,
page: page,
perPage: perPage,
);
return response.users.map((dto) => _userDtoToModel(dto)).toList();
} catch (e) {
throw Exception('사용자 검색 실패: ${e.toString()}');
}
}
/// DTO를 Model로 변환
User _userDtoToModel(UserDto dto) {
return User(
id: dto.id,
companyId: dto.companyId ?? 0,
branchId: dto.branchId,
name: dto.name,
role: _mapRoleFromApi(dto.role),
position: null, // API에서 position 정보가 없음
email: dto.email,
phoneNumbers: dto.phone != null
? [{'type': '기본', 'number': dto.phone!}]
: [],
username: dto.username,
isActive: dto.isActive,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
);
}
/// 권한을 API 형식으로 변환
String _mapRoleToApi(String role) {
switch (role) {
case 'S':
return 'admin';
case 'M':
return 'staff';
default:
return 'staff';
}
}
/// API 권한을 앱 형식으로 변환
String _mapRoleFromApi(String role) {
switch (role) {
case 'admin':
return 'S';
case 'manager':
return 'M';
case 'staff':
return 'M';
default:
return 'M';
}
}
/// 전화번호 목록에서 첫 번째 전화번호 추출
String? getPhoneForApi(List<Map<String, String>> phoneNumbers) {
if (phoneNumbers.isEmpty) return null;
return phoneNumbers.first['number'];
}
}