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

@@ -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,
};