feat: 사용자 관리 시스템 백엔드 API 호환성 대폭 개선
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

- UserRemoteDataSource: API v0.2.1 스펙 완전 대응
  • 응답 형식 통일 (success: true 구조)
  • 페이지네이션 처리 개선
  • 에러 핸들링 강화
  • 불필요한 파라미터 제거 (includeInactive 등)

- UserDto 모델 현대화:
  • 서버 응답 구조와 100% 일치
  • 도메인 모델 변환 메서드 추가
  • Freezed 불변성 패턴 완성

- User 도메인 모델 신규 구현:
  • Clean Architecture 원칙 준수
  • UserRole enum 타입 안전성 강화
  • 비즈니스 로직 캡슐화

- 사용자 관련 UseCase 리팩토링:
  • Repository 패턴 완전 적용
  • Either<Failure, Success> 에러 처리
  • 의존성 주입 최적화

- UI 컨트롤러 및 화면 개선:
  • API 응답 변경사항 반영
  • 사용자 권한 표시 정확성 향상
  • 폼 검증 로직 강화

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-08-15 23:31:51 +09:00
parent c1063f5670
commit 93bceb8a6c
20 changed files with 2006 additions and 2017 deletions

View File

@@ -1,100 +1,175 @@
class User {
final int? id;
final int companyId;
final int? branchId; // 지점 ID
final String name;
final String role; // 관리등급: S(관리자), M(멤버)
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; // 수정일
import 'package:freezed_annotation/freezed_annotation.dart';
User({
this.id,
required this.companyId,
this.branchId,
required this.name,
required this.role,
this.position,
this.email,
this.phoneNumbers = const [],
this.username,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
part 'user_model.freezed.dart';
part 'user_model.g.dart';
Map<String, dynamic> toJson() {
return {
'id': id,
'companyId': companyId,
'branchId': branchId,
'name': name,
'role': role,
'position': position,
'email': email,
'phoneNumbers': phoneNumbers,
'username': username,
'isActive': isActive,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
companyId: json['companyId'],
branchId: json['branchId'],
name: json['name'],
role: json['role'],
position: json['position'],
email: json['email'],
phoneNumbers:
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,
);
}
User copyWith({
/// 사용자 도메인 엔티티 (서버 API v0.2.1 스키마 대응)
/// 권한: admin(관리자), manager(매니저), staff(직원)
@freezed
class User with _$User {
const factory User({
/// 사용자 ID (자동 생성)
int? id,
int? companyId,
int? branchId,
String? name,
String? role,
String? position,
String? email,
List<Map<String, String>>? phoneNumbers,
String? username,
bool? isActive,
/// 사용자명 (로그인용, 필수, 유니크, 3자 이상)
required String username,
/// 이메일 (필수, 유니크)
required String email,
/// 이름 (필수)
required String name,
/// 전화번호 (선택, "010-1234-5678" 형태)
String? phone,
/// 권한 (필수: admin, manager, staff)
required UserRole role,
/// 활성화 상태 (기본값: true)
@Default(true) bool isActive,
/// 생성일시 (자동 입력)
DateTime? createdAt,
/// 수정일시 (자동 갱신)
DateTime? updatedAt,
}) {
return User(
id: id ?? this.id,
companyId: companyId ?? this.companyId,
branchId: branchId ?? this.branchId,
name: name ?? this.name,
role: role ?? this.role,
position: position ?? this.position,
email: email ?? this.email,
phoneNumbers: phoneNumbers ?? this.phoneNumbers,
username: username ?? this.username,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
/// 사용자 권한 열거형 (서버 API 스키마 대응)
@JsonEnum()
enum UserRole {
/// 관리자 - 전체 시스템 관리 권한
@JsonValue('admin')
admin,
/// 매니저 - 중간 관리 권한
@JsonValue('manager')
manager,
/// 직원 - 기본 사용 권한
@JsonValue('staff')
staff;
/// 권한 한글명 반환
String get displayName {
switch (this) {
case UserRole.admin:
return '관리자';
case UserRole.manager:
return '매니저';
case UserRole.staff:
return '직원';
}
}
/// 권한 레벨 반환 (높을수록 상위 권한)
int get level {
switch (this) {
case UserRole.admin:
return 3;
case UserRole.manager:
return 2;
case UserRole.staff:
return 1;
}
}
/// 문자열로부터 UserRole 생성
static UserRole fromString(String value) {
switch (value.toLowerCase()) {
case 'admin':
return UserRole.admin;
case 'manager':
return UserRole.manager;
case 'staff':
return UserRole.staff;
default:
throw ArgumentError('Unknown user role: $value');
}
}
}
/// 레거시 권한 시스템 호환성 유틸리티
/// 기존 S/M 코드와의 호환성을 위해 임시 유지
class LegacyUserRoles {
static const String admin = 'S'; // 관리자 (삭제 예정)
static const String member = 'M'; // 멤버 (삭제 예정)
/// 레거시 권한을 새 권한으로 변환
static UserRole toLegacyRole(String legacyRole) {
switch (legacyRole) {
case 'S':
return UserRole.admin;
case 'M':
return UserRole.staff;
default:
return UserRole.staff;
}
}
/// 새 권한을 레거시 권한으로 변환 (임시)
static String fromLegacyRole(UserRole role) {
switch (role) {
case UserRole.admin:
return 'S';
case UserRole.manager:
case UserRole.staff:
return 'M';
}
}
}
/// 전화번호 유틸리티
class PhoneNumberUtil {
/// 전화번호 형식 검증 (010-1234-5678)
static bool isValidFormat(String phone) {
final regex = RegExp(r'^\d{3}-\d{3,4}-\d{4}$');
return regex.hasMatch(phone);
}
/// 전화번호 포맷팅 (01012345678 → 010-1234-5678)
static String format(String phone) {
final cleanPhone = phone.replaceAll(RegExp(r'[^\d]'), '');
if (cleanPhone.length == 10) {
return '${cleanPhone.substring(0, 3)}-${cleanPhone.substring(3, 6)}-${cleanPhone.substring(6)}';
} else if (cleanPhone.length == 11) {
return '${cleanPhone.substring(0, 3)}-${cleanPhone.substring(3, 7)}-${cleanPhone.substring(7)}';
}
return phone; // 형식이 맞지 않으면 원본 반환
}
/// UI용 전화번호 분리 (010-1234-5678 → {prefix: "010", number: "12345678"})
static Map<String, String> splitForUI(String? phone) {
if (phone == null || phone.isEmpty) {
return {'prefix': '010', 'number': ''};
}
final parts = phone.split('-');
if (parts.length >= 2) {
return {
'prefix': parts[0],
'number': parts.sublist(1).join(''),
};
}
return {'prefix': '010', 'number': phone};
}
/// UI에서 서버용 전화번호 조합 ({prefix: "010", number: "12345678"} → "010-1234-5678")
static String combineFromUI(String prefix, String number) {
if (number.isEmpty) return '';
final cleanNumber = number.replaceAll(RegExp(r'[^\d]'), '');
if (cleanNumber.length == 7) {
return '$prefix-${cleanNumber.substring(0, 3)}-${cleanNumber.substring(3)}';
} else if (cleanNumber.length == 8) {
return '$prefix-${cleanNumber.substring(0, 4)}-${cleanNumber.substring(4)}';
}
return '$prefix-$cleanNumber';
}
}

View File

@@ -0,0 +1,380 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'user_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
User _$UserFromJson(Map<String, dynamic> json) {
return _User.fromJson(json);
}
/// @nodoc
mixin _$User {
/// 사용자 ID (자동 생성)
int? get id => throw _privateConstructorUsedError;
/// 사용자명 (로그인용, 필수, 유니크, 3자 이상)
String get username => throw _privateConstructorUsedError;
/// 이메일 (필수, 유니크)
String get email => throw _privateConstructorUsedError;
/// 이름 (필수)
String get name => throw _privateConstructorUsedError;
/// 전화번호 (선택, "010-1234-5678" 형태)
String? get phone => throw _privateConstructorUsedError;
/// 권한 (필수: admin, manager, staff)
UserRole get role => throw _privateConstructorUsedError;
/// 활성화 상태 (기본값: true)
bool get isActive => throw _privateConstructorUsedError;
/// 생성일시 (자동 입력)
DateTime? get createdAt => throw _privateConstructorUsedError;
/// 수정일시 (자동 갱신)
DateTime? get updatedAt => throw _privateConstructorUsedError;
/// Serializes this User to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$UserCopyWith<User> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UserCopyWith<$Res> {
factory $UserCopyWith(User value, $Res Function(User) then) =
_$UserCopyWithImpl<$Res, User>;
@useResult
$Res call(
{int? id,
String username,
String email,
String name,
String? phone,
UserRole role,
bool isActive,
DateTime? createdAt,
DateTime? updatedAt});
}
/// @nodoc
class _$UserCopyWithImpl<$Res, $Val extends User>
implements $UserCopyWith<$Res> {
_$UserCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? username = null,
Object? email = null,
Object? name = null,
Object? phone = freezed,
Object? role = null,
Object? isActive = null,
Object? createdAt = freezed,
Object? updatedAt = freezed,
}) {
return _then(_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
username: null == username
? _value.username
: username // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
phone: freezed == phone
? _value.phone
: phone // ignore: cast_nullable_to_non_nullable
as String?,
role: null == role
? _value.role
: role // ignore: cast_nullable_to_non_nullable
as UserRole,
isActive: null == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool,
createdAt: freezed == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
updatedAt: freezed == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
) as $Val);
}
}
/// @nodoc
abstract class _$$UserImplCopyWith<$Res> implements $UserCopyWith<$Res> {
factory _$$UserImplCopyWith(
_$UserImpl value, $Res Function(_$UserImpl) then) =
__$$UserImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int? id,
String username,
String email,
String name,
String? phone,
UserRole role,
bool isActive,
DateTime? createdAt,
DateTime? updatedAt});
}
/// @nodoc
class __$$UserImplCopyWithImpl<$Res>
extends _$UserCopyWithImpl<$Res, _$UserImpl>
implements _$$UserImplCopyWith<$Res> {
__$$UserImplCopyWithImpl(_$UserImpl _value, $Res Function(_$UserImpl) _then)
: super(_value, _then);
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? username = null,
Object? email = null,
Object? name = null,
Object? phone = freezed,
Object? role = null,
Object? isActive = null,
Object? createdAt = freezed,
Object? updatedAt = freezed,
}) {
return _then(_$UserImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int?,
username: null == username
? _value.username
: username // ignore: cast_nullable_to_non_nullable
as String,
email: null == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
phone: freezed == phone
? _value.phone
: phone // ignore: cast_nullable_to_non_nullable
as String?,
role: null == role
? _value.role
: role // ignore: cast_nullable_to_non_nullable
as UserRole,
isActive: null == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool,
createdAt: freezed == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
updatedAt: freezed == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$UserImpl implements _User {
const _$UserImpl(
{this.id,
required this.username,
required this.email,
required this.name,
this.phone,
required this.role,
this.isActive = true,
this.createdAt,
this.updatedAt});
factory _$UserImpl.fromJson(Map<String, dynamic> json) =>
_$$UserImplFromJson(json);
/// 사용자 ID (자동 생성)
@override
final int? id;
/// 사용자명 (로그인용, 필수, 유니크, 3자 이상)
@override
final String username;
/// 이메일 (필수, 유니크)
@override
final String email;
/// 이름 (필수)
@override
final String name;
/// 전화번호 (선택, "010-1234-5678" 형태)
@override
final String? phone;
/// 권한 (필수: admin, manager, staff)
@override
final UserRole role;
/// 활성화 상태 (기본값: true)
@override
@JsonKey()
final bool isActive;
/// 생성일시 (자동 입력)
@override
final DateTime? createdAt;
/// 수정일시 (자동 갱신)
@override
final DateTime? updatedAt;
@override
String toString() {
return 'User(id: $id, username: $username, email: $email, name: $name, phone: $phone, role: $role, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UserImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.username, username) ||
other.username == username) &&
(identical(other.email, email) || other.email == email) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.phone, phone) || other.phone == phone) &&
(identical(other.role, role) || other.role == role) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, username, email, name, phone,
role, isActive, createdAt, updatedAt);
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
__$$UserImplCopyWithImpl<_$UserImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$UserImplToJson(
this,
);
}
}
abstract class _User implements User {
const factory _User(
{final int? id,
required final String username,
required final String email,
required final String name,
final String? phone,
required final UserRole role,
final bool isActive,
final DateTime? createdAt,
final DateTime? updatedAt}) = _$UserImpl;
factory _User.fromJson(Map<String, dynamic> json) = _$UserImpl.fromJson;
/// 사용자 ID (자동 생성)
@override
int? get id;
/// 사용자명 (로그인용, 필수, 유니크, 3자 이상)
@override
String get username;
/// 이메일 (필수, 유니크)
@override
String get email;
/// 이름 (필수)
@override
String get name;
/// 전화번호 (선택, "010-1234-5678" 형태)
@override
String? get phone;
/// 권한 (필수: admin, manager, staff)
@override
UserRole get role;
/// 활성화 상태 (기본값: true)
@override
bool get isActive;
/// 생성일시 (자동 입력)
@override
DateTime? get createdAt;
/// 수정일시 (자동 갱신)
@override
DateTime? get updatedAt;
/// Create a copy of User
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$UserImplCopyWith<_$UserImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,42 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$UserImpl _$$UserImplFromJson(Map<String, dynamic> json) => _$UserImpl(
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: $enumDecode(_$UserRoleEnumMap, json['role']),
isActive: json['isActive'] as bool? ?? true,
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.parse(json['updatedAt'] as String),
);
Map<String, dynamic> _$$UserImplToJson(_$UserImpl instance) =>
<String, dynamic>{
'id': instance.id,
'username': instance.username,
'email': instance.email,
'name': instance.name,
'phone': instance.phone,
'role': _$UserRoleEnumMap[instance.role]!,
'isActive': instance.isActive,
'createdAt': instance.createdAt?.toIso8601String(),
'updatedAt': instance.updatedAt?.toIso8601String(),
};
const _$UserRoleEnumMap = {
UserRole.admin: 'admin',
UserRole.manager: 'manager',
UserRole.staff: 'staff',
};