diff --git a/app/lib/src/core/network/api_error.dart b/app/lib/src/core/network/api_error.dart new file mode 100644 index 0000000..1ba2b9f --- /dev/null +++ b/app/lib/src/core/network/api_error.dart @@ -0,0 +1,23 @@ +class ApiError { + const ApiError({ + required this.error, + required this.code, + required this.details, + }); + + final String error; + final String code; + final Map details; + + factory ApiError.fromJson(Map json) { + return ApiError( + error: json['error'] as String? ?? '', + code: json['code'] as String? ?? '', + details: json['details'] as Map? ?? {}, + ); + } + + Map toJson() { + return {'error': error, 'code': code, 'details': details}; + } +} diff --git a/app/lib/src/features/auth/domain/auth_models.dart b/app/lib/src/features/auth/domain/auth_models.dart new file mode 100644 index 0000000..40f20af --- /dev/null +++ b/app/lib/src/features/auth/domain/auth_models.dart @@ -0,0 +1,238 @@ +import '../../directory/domain/employee.dart'; + +class LoginDeviceInfo { + const LoginDeviceInfo({ + required this.platform, + required this.appVersion, + required this.deviceName, + }); + + final String platform; + final String appVersion; + final String deviceName; + + factory LoginDeviceInfo.fromJson(Map json) { + return LoginDeviceInfo( + platform: json['platform'] as String? ?? '', + appVersion: json['appVersion'] as String? ?? '', + deviceName: json['deviceName'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'platform': platform, + 'appVersion': appVersion, + 'deviceName': deviceName, + }; + } +} + +class PhoneLoginRequest { + const PhoneLoginRequest({required this.phoneNumber, required this.device}); + + final String phoneNumber; + final LoginDeviceInfo device; + + Map toJson() { + return {'phoneNumber': phoneNumber, 'device': device.toJson()}; + } +} + +class LoginUser { + const LoginUser({ + required this.id, + required this.name, + required this.phoneNumber, + required this.tenantId, + required this.tenantName, + required this.tenantSlug, + this.department, + this.grade, + this.position, + this.jobTitle, + }); + + final String id; + final String name; + final String phoneNumber; + final String tenantId; + final String tenantName; + final String tenantSlug; + final String? department; + final String? grade; + final String? position; + final String? jobTitle; + + factory LoginUser.fromJson(Map json) { + return LoginUser( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + phoneNumber: json['phoneNumber'] as String? ?? '', + tenantId: json['tenantId'] as String? ?? '', + tenantName: json['tenantName'] as String? ?? '', + tenantSlug: json['tenantSlug'] as String? ?? '', + department: json['department'] as String?, + grade: json['grade'] as String?, + position: json['position'] as String?, + jobTitle: json['jobTitle'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'phoneNumber': phoneNumber, + 'tenantId': tenantId, + 'tenantName': tenantName, + 'tenantSlug': tenantSlug, + 'department': department, + 'grade': grade, + 'position': position, + 'jobTitle': jobTitle, + }; + } +} + +class PhoneLoginResponse { + const PhoneLoginResponse({ + required this.status, + required this.token, + required this.expiresAt, + required this.user, + }); + + final String status; + final String token; + final DateTime expiresAt; + final LoginUser user; + + factory PhoneLoginResponse.fromJson(Map json) { + return PhoneLoginResponse( + status: json['status'] as String? ?? '', + token: json['token'] as String? ?? '', + expiresAt: DateTime.parse(json['expiresAt'] as String), + user: LoginUser.fromJson(json['user'] as Map? ?? {}), + ); + } + + Map toJson() { + return { + 'status': status, + 'token': token, + 'expiresAt': expiresAt.toUtc().toIso8601String(), + 'user': user.toJson(), + }; + } +} + +class UserPermissions { + const UserPermissions({ + required this.directory, + required this.organization, + required this.favoritesSync, + }); + + final bool directory; + final bool organization; + final bool favoritesSync; + + factory UserPermissions.fromJson(Map json) { + return UserPermissions( + directory: json['directory'] as bool? ?? false, + organization: json['organization'] as bool? ?? false, + favoritesSync: json['favoritesSync'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'directory': directory, + 'organization': organization, + 'favoritesSync': favoritesSync, + }; + } +} + +class CurrentUser { + const CurrentUser({ + required this.id, + required this.name, + required this.phoneNumber, + required this.email, + required this.tenantId, + required this.tenantName, + required this.tenantSlug, + required this.permissions, + this.department, + this.grade, + this.position, + this.jobTitle, + }); + + final String id; + final String name; + final String phoneNumber; + final String email; + final String tenantId; + final String tenantName; + final String tenantSlug; + final UserPermissions permissions; + final String? department; + final String? grade; + final String? position; + final String? jobTitle; + + factory CurrentUser.fromJson(Map json) { + return CurrentUser( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + phoneNumber: json['phoneNumber'] as String? ?? '', + email: json['email'] as String? ?? '', + tenantId: json['tenantId'] as String? ?? '', + tenantName: json['tenantName'] as String? ?? '', + tenantSlug: json['tenantSlug'] as String? ?? '', + permissions: UserPermissions.fromJson( + json['permissions'] as Map? ?? {}, + ), + department: json['department'] as String?, + grade: json['grade'] as String?, + position: json['position'] as String?, + jobTitle: json['jobTitle'] as String?, + ); + } + + Employee toEmployee() { + return Employee( + id: id, + name: name, + phoneNumber: phoneNumber, + email: email, + tenantId: tenantId, + tenantName: tenantName, + tenantSlug: tenantSlug, + department: department, + grade: grade, + position: position, + jobTitle: jobTitle, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'phoneNumber': phoneNumber, + 'email': email, + 'tenantId': tenantId, + 'tenantName': tenantName, + 'tenantSlug': tenantSlug, + 'department': department, + 'grade': grade, + 'position': position, + 'jobTitle': jobTitle, + 'permissions': permissions.toJson(), + }; + } +} diff --git a/app/lib/src/features/directory/domain/employee.dart b/app/lib/src/features/directory/domain/employee.dart new file mode 100644 index 0000000..a48ca82 --- /dev/null +++ b/app/lib/src/features/directory/domain/employee.dart @@ -0,0 +1,233 @@ +class Employee { + const Employee({ + required this.id, + required this.name, + required this.phoneNumber, + this.phoneDisplay, + this.email, + required this.tenantId, + required this.tenantName, + required this.tenantSlug, + this.department, + this.grade, + this.position, + this.jobTitle, + this.status, + this.profileImageUrl, + this.sortOrder, + }); + + final String id; + final String name; + final String phoneNumber; + final String? phoneDisplay; + final String? email; + final String tenantId; + final String tenantName; + final String tenantSlug; + final String? department; + final String? grade; + final String? position; + final String? jobTitle; + final String? status; + final String? profileImageUrl; + final int? sortOrder; + + factory Employee.fromJson(Map json) { + return Employee( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + phoneNumber: json['phoneNumber'] as String? ?? '', + phoneDisplay: json['phoneDisplay'] as String?, + email: json['email'] as String?, + tenantId: json['tenantId'] as String? ?? '', + tenantName: json['tenantName'] as String? ?? '', + tenantSlug: json['tenantSlug'] as String? ?? '', + department: json['department'] as String?, + grade: json['grade'] as String?, + position: json['position'] as String?, + jobTitle: json['jobTitle'] as String?, + status: json['status'] as String?, + profileImageUrl: json['profileImageUrl'] as String?, + sortOrder: json['sortOrder'] as int?, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'phoneNumber': phoneNumber, + 'phoneDisplay': phoneDisplay, + 'email': email, + 'tenantId': tenantId, + 'tenantName': tenantName, + 'tenantSlug': tenantSlug, + 'department': department, + 'grade': grade, + 'position': position, + 'jobTitle': jobTitle, + 'status': status, + 'profileImageUrl': profileImageUrl, + 'sortOrder': sortOrder, + }; + } +} + +class TenantRef { + const TenantRef({ + required this.id, + required this.name, + required this.slug, + required this.type, + this.parentId, + }); + + final String id; + final String name; + final String slug; + final String type; + final String? parentId; + + factory TenantRef.fromJson(Map json) { + return TenantRef( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + slug: json['slug'] as String? ?? '', + type: json['type'] as String? ?? '', + parentId: json['parentId'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'slug': slug, + 'type': type, + 'parentId': parentId, + }; + } +} + +class EmployeeActions { + const EmployeeActions({ + required this.call, + required this.sms, + required this.email, + }); + + final bool call; + final bool sms; + final bool email; + + factory EmployeeActions.fromJson(Map json) { + return EmployeeActions( + call: json['call'] as bool? ?? false, + sms: json['sms'] as bool? ?? false, + email: json['email'] as bool? ?? false, + ); + } + + Map toJson() { + return {'call': call, 'sms': sms, 'email': email}; + } +} + +class EmployeeDetail extends Employee { + const EmployeeDetail({ + required super.id, + required super.name, + required super.phoneNumber, + super.phoneDisplay, + super.email, + required super.tenantId, + required super.tenantName, + required super.tenantSlug, + super.department, + super.grade, + super.position, + super.jobTitle, + super.status, + super.profileImageUrl, + super.sortOrder, + required this.joinedTenants, + required this.actions, + }); + + final List joinedTenants; + final EmployeeActions actions; + + factory EmployeeDetail.fromJson(Map json) { + return EmployeeDetail( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + phoneNumber: json['phoneNumber'] as String? ?? '', + phoneDisplay: json['phoneDisplay'] as String?, + email: json['email'] as String?, + tenantId: json['tenantId'] as String? ?? '', + tenantName: json['tenantName'] as String? ?? '', + tenantSlug: json['tenantSlug'] as String? ?? '', + department: json['department'] as String?, + grade: json['grade'] as String?, + position: json['position'] as String?, + jobTitle: json['jobTitle'] as String?, + status: json['status'] as String?, + profileImageUrl: json['profileImageUrl'] as String?, + sortOrder: json['sortOrder'] as int?, + joinedTenants: (json['joinedTenants'] as List? ?? []) + .map((item) => TenantRef.fromJson(item as Map)) + .toList(), + actions: EmployeeActions.fromJson( + json['actions'] as Map? ?? {}, + ), + ); + } + + @override + Map toJson() { + return { + ...super.toJson(), + 'joinedTenants': joinedTenants.map((tenant) => tenant.toJson()).toList(), + 'actions': actions.toJson(), + }; + } +} + +class EmployeeListResponse { + const EmployeeListResponse({ + required this.items, + required this.limit, + required this.offset, + required this.total, + required this.nextCursor, + }); + + final List items; + final int limit; + final int offset; + final int total; + final String nextCursor; + + factory EmployeeListResponse.fromJson(Map json) { + return EmployeeListResponse( + items: (json['items'] as List? ?? []) + .map((item) => Employee.fromJson(item as Map)) + .toList(), + limit: json['limit'] as int? ?? 0, + offset: json['offset'] as int? ?? 0, + total: json['total'] as int? ?? 0, + nextCursor: json['nextCursor'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'items': items.map((employee) => employee.toJson()).toList(), + 'limit': limit, + 'offset': offset, + 'total': total, + 'nextCursor': nextCursor, + }; + } +} diff --git a/app/lib/src/features/favorites/domain/favorite_employee.dart b/app/lib/src/features/favorites/domain/favorite_employee.dart new file mode 100644 index 0000000..2348e43 --- /dev/null +++ b/app/lib/src/features/favorites/domain/favorite_employee.dart @@ -0,0 +1,20 @@ +class FavoriteEmployee { + const FavoriteEmployee({required this.employeeId, required this.createdAt}); + + final String employeeId; + final DateTime createdAt; + + factory FavoriteEmployee.fromJson(Map json) { + return FavoriteEmployee( + employeeId: json['employeeId'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + Map toJson() { + return { + 'employeeId': employeeId, + 'createdAt': createdAt.toUtc().toIso8601String(), + }; + } +} diff --git a/app/lib/src/features/organization/domain/organization_models.dart b/app/lib/src/features/organization/domain/organization_models.dart new file mode 100644 index 0000000..4113d2a --- /dev/null +++ b/app/lib/src/features/organization/domain/organization_models.dart @@ -0,0 +1,130 @@ +import '../../directory/domain/employee.dart'; + +class TenantSummary { + const TenantSummary({ + required this.id, + required this.name, + required this.slug, + required this.type, + this.parentId, + required this.memberCount, + required this.totalMemberCount, + }); + + final String id; + final String name; + final String slug; + final String type; + final String? parentId; + final int memberCount; + final int totalMemberCount; + + factory TenantSummary.fromJson(Map json) { + return TenantSummary( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + slug: json['slug'] as String? ?? '', + type: json['type'] as String? ?? '', + parentId: json['parentId'] as String?, + memberCount: json['memberCount'] as int? ?? 0, + totalMemberCount: json['totalMemberCount'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'slug': slug, + 'type': type, + 'parentId': parentId, + 'memberCount': memberCount, + 'totalMemberCount': totalMemberCount, + }; + } +} + +class TenantListResponse { + const TenantListResponse({required this.items, required this.generatedAt}); + + final List items; + final DateTime generatedAt; + + factory TenantListResponse.fromJson(Map json) { + return TenantListResponse( + items: (json['items'] as List? ?? []) + .map((item) => TenantSummary.fromJson(item as Map)) + .toList(), + generatedAt: DateTime.parse(json['generatedAt'] as String), + ); + } + + Map toJson() { + return { + 'items': items.map((tenant) => tenant.toJson()).toList(), + 'generatedAt': generatedAt.toUtc().toIso8601String(), + }; + } +} + +class OrgChartCacheInfo { + const OrgChartCacheInfo({ + required this.source, + required this.hit, + this.ttlSeconds, + }); + + final String source; + final bool hit; + final int? ttlSeconds; + + factory OrgChartCacheInfo.fromJson(Map json) { + return OrgChartCacheInfo( + source: json['source'] as String? ?? '', + hit: json['hit'] as bool? ?? false, + ttlSeconds: json['ttlSeconds'] as int?, + ); + } + + Map toJson() { + return {'source': source, 'hit': hit, 'ttlSeconds': ttlSeconds}; + } +} + +class OrgChartSnapshot { + const OrgChartSnapshot({ + required this.tenants, + required this.employees, + required this.generatedAt, + this.cache, + }); + + final List tenants; + final List employees; + final DateTime generatedAt; + final OrgChartCacheInfo? cache; + + factory OrgChartSnapshot.fromJson(Map json) { + return OrgChartSnapshot( + tenants: (json['tenants'] as List? ?? []) + .map((item) => TenantSummary.fromJson(item as Map)) + .toList(), + employees: (json['employees'] as List? ?? []) + .map((item) => Employee.fromJson(item as Map)) + .toList(), + generatedAt: DateTime.parse(json['generatedAt'] as String), + cache: json['cache'] == null + ? null + : OrgChartCacheInfo.fromJson(json['cache'] as Map), + ); + } + + Map toJson() { + return { + 'tenants': tenants.map((tenant) => tenant.toJson()).toList(), + 'employees': employees.map((employee) => employee.toJson()).toList(), + 'generatedAt': generatedAt.toUtc().toIso8601String(), + 'cache': cache?.toJson(), + }; + } +} diff --git a/app/test/models/api_error_test.dart b/app/test/models/api_error_test.dart new file mode 100644 index 0000000..c4da5db --- /dev/null +++ b/app/test/models/api_error_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdc114plus/src/core/network/api_error.dart'; + +void main() { + test('ApiError parses common error payload', () { + final error = ApiError.fromJson({ + 'error': 'Unauthorized', + 'code': 'unauthorized', + 'details': {'reason': 'expired'}, + }); + + expect(error.code, 'unauthorized'); + expect(error.details['reason'], 'expired'); + }); +} diff --git a/app/test/models/auth_models_test.dart b/app/test/models/auth_models_test.dart new file mode 100644 index 0000000..f4937c1 --- /dev/null +++ b/app/test/models/auth_models_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdc114plus/src/features/auth/domain/auth_models.dart'; + +void main() { + test('PhoneLoginRequest serializes phone login payload', () { + const request = PhoneLoginRequest( + phoneNumber: '01012345678', + device: LoginDeviceInfo( + platform: 'android', + appVersion: '0.1.0', + deviceName: 'Pixel 8', + ), + ); + + expect(request.toJson(), { + 'phoneNumber': '01012345678', + 'device': { + 'platform': 'android', + 'appVersion': '0.1.0', + 'deviceName': 'Pixel 8', + }, + }); + }); + + test('PhoneLoginResponse parses session token response', () { + final response = PhoneLoginResponse.fromJson({ + 'status': 'ok', + 'token': 'baron-sso-session-token', + 'expiresAt': '2026-07-02T12:00:00Z', + 'user': { + 'id': 'user-uuid', + 'name': 'User One', + 'phoneNumber': '+821012345678', + 'tenantId': 'tenant-uuid', + 'tenantName': 'Hanmac', + 'tenantSlug': 'hanmac', + 'department': 'Engineering', + 'grade': 'Lead', + 'position': 'Manager', + 'jobTitle': 'Developer', + }, + }); + + expect(response.status, 'ok'); + expect(response.token, 'baron-sso-session-token'); + expect( + response.expiresAt.toUtc().toIso8601String(), + '2026-07-02T12:00:00.000Z', + ); + expect(response.user.tenantSlug, 'hanmac'); + }); + + test('CurrentUser parses permissions and can map to employee', () { + final user = CurrentUser.fromJson({ + 'id': 'user-uuid', + 'name': 'User One', + 'phoneNumber': '+821012345678', + 'email': 'user@example.com', + 'tenantId': 'tenant-uuid', + 'tenantName': 'Hanmac', + 'tenantSlug': 'hanmac', + 'department': 'Engineering', + 'permissions': { + 'directory': true, + 'organization': true, + 'favoritesSync': false, + }, + }); + + expect(user.permissions.directory, isTrue); + expect(user.permissions.favoritesSync, isFalse); + expect(user.toEmployee().department, 'Engineering'); + }); +} diff --git a/app/test/models/directory_models_test.dart b/app/test/models/directory_models_test.dart new file mode 100644 index 0000000..5dfb513 --- /dev/null +++ b/app/test/models/directory_models_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdc114plus/src/features/directory/domain/employee.dart'; + +void main() { + test('EmployeeListResponse parses paginated employee list', () { + final response = EmployeeListResponse.fromJson({ + 'items': [ + { + 'id': 'user-uuid', + 'name': 'User One', + 'phoneNumber': '+821012345678', + 'phoneDisplay': '010-1234-5678', + 'email': 'user@example.com', + 'tenantId': 'tenant-uuid', + 'tenantName': 'Hanmac', + 'tenantSlug': 'hanmac', + 'department': 'Engineering', + 'grade': 'Lead', + 'position': 'Manager', + 'jobTitle': 'Developer', + 'status': 'active', + 'profileImageUrl': null, + 'sortOrder': 100, + }, + ], + 'limit': 50, + 'offset': 0, + 'total': 1, + 'nextCursor': '', + }); + + expect(response.items, hasLength(1)); + expect(response.items.first.phoneDisplay, '010-1234-5678'); + expect(response.total, 1); + }); + + test('EmployeeDetail parses joined tenants and available actions', () { + final detail = EmployeeDetail.fromJson({ + 'id': 'user-uuid', + 'name': 'User One', + 'phoneNumber': '+821012345678', + 'email': 'user@example.com', + 'tenantId': 'tenant-uuid', + 'tenantName': 'Hanmac', + 'tenantSlug': 'hanmac', + 'joinedTenants': [ + { + 'id': 'tenant-uuid', + 'name': 'Hanmac', + 'slug': 'hanmac', + 'type': 'COMPANY', + 'parentId': 'root-tenant-uuid', + }, + ], + 'status': 'active', + 'actions': {'call': true, 'sms': true, 'email': true}, + }); + + expect(detail.joinedTenants.first.type, 'COMPANY'); + expect(detail.actions.call, isTrue); + expect(detail.actions.sms, isTrue); + }); +} diff --git a/app/test/models/organization_models_test.dart b/app/test/models/organization_models_test.dart new file mode 100644 index 0000000..37dc025 --- /dev/null +++ b/app/test/models/organization_models_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdc114plus/src/features/organization/domain/organization_models.dart'; + +void main() { + test('TenantListResponse parses organization filter tenants', () { + final response = TenantListResponse.fromJson({ + 'items': [ + { + 'id': 'tenant-uuid', + 'name': 'Hanmac', + 'slug': 'hanmac', + 'type': 'COMPANY', + 'parentId': 'family-root', + 'memberCount': 120, + 'totalMemberCount': 350, + }, + ], + 'generatedAt': '2026-07-02T12:00:00Z', + }); + + expect(response.items.first.slug, 'hanmac'); + expect(response.items.first.totalMemberCount, 350); + }); + + test('OrgChartSnapshot parses tenants, employees, and cache metadata', () { + final snapshot = OrgChartSnapshot.fromJson({ + 'tenants': [ + { + 'id': 'tenant-uuid', + 'name': 'Engineering', + 'slug': 'engineering', + 'type': 'ORGANIZATION', + 'parentId': 'company-tenant-uuid', + 'memberCount': 12, + 'totalMemberCount': 38, + }, + ], + 'employees': [ + { + 'id': 'user-uuid', + 'name': 'User One', + 'phoneNumber': '+821012345678', + 'phoneDisplay': '010-1234-5678', + 'tenantId': 'tenant-uuid', + 'tenantName': 'Engineering', + 'tenantSlug': 'engineering', + 'department': 'Engineering', + 'grade': 'Lead', + 'position': 'Manager', + 'jobTitle': 'Developer', + 'status': 'active', + }, + ], + 'generatedAt': '2026-07-02T12:00:00Z', + 'cache': {'source': 'redis', 'hit': true, 'ttlSeconds': 300}, + }); + + expect(snapshot.tenants.first.type, 'ORGANIZATION'); + expect(snapshot.employees.first.tenantSlug, 'engineering'); + expect(snapshot.cache?.source, 'redis'); + }); +} diff --git a/docs/tdc114plus-work-progress-timetable-2026-07-02.md b/docs/tdc114plus-work-progress-timetable-2026-07-02.md index ac8bc4c..b56eaec 100644 --- a/docs/tdc114plus-work-progress-timetable-2026-07-02.md +++ b/docs/tdc114plus-work-progress-timetable-2026-07-02.md @@ -45,8 +45,8 @@ | 11 | tdc114plus 개발 정책 정리 | 완료 | Baron SSO API 생성 우선 원칙, VS Code 기반 AI 개발 방식, Flutter/플랫폼 구현 원칙 정리 | `docs/tdc114plus-development-policy-2026-07-02.md` | | 12 | API 계약 정리 | 완료 | Baron SSO 로그인 API, orgFront 직원/조직 API 계약 정리 | `docs/tdc114plus-api-contract-2026-07-02.md` | | 13 | API 계약 검토 및 확정 | 완료 | API 계약 초안의 추가 확인사항 검토 후 1차 구현 기준 확정 | `docs/tdc114plus-api-contract-2026-07-02.md` | -| 14 | 데이터 모델 설계 | 다음 작업 | 직원, 조직, 가족사, 즐겨찾기 모델 정의 | Dart model | -| 15 | Mock 데이터 기반 화면 확장 | 대기 | 직원목록, 검색, 가족사 필터, 조직도 화면을 mock 데이터로 우선 구현 | 동작 가능한 UI | +| 14 | 데이터 모델 설계 | 완료 | 직원, 조직, 가족사, 즐겨찾기 모델 정의 | Dart model 및 model test | +| 15 | Mock 데이터 기반 화면 확장 | 다음 작업 | 직원목록, 검색, 가족사 필터, 조직도 화면을 mock 데이터로 우선 구현 | 동작 가능한 UI | | 16 | Baron SSO 로그인 연동 | 대기 | 전화번호 입력 후 SSO 등록 인원 여부 확인 연동 | 로그인 client/repository | | 17 | orgFront 데이터 연동 | 대기 | 직원/조직 데이터 API 연동 | directory/organization client | | 18 | 전화/문자 액션 구현 | 대기 | `url_launcher` 기반 전화걸기/문자보내기 구현 | 연락 액션 | @@ -62,15 +62,15 @@ | Phase 2 | 완료 | Baron SSO 참조 기준 확정 및 API 계약 준비 | 공식 `origin/dev` 기준 확인, API 개발 worktree 생성, Baron Safe 참고 정책 확인 | `feature/tdc114plus-api` worktree 생성 | | Phase 2-1 | 완료 | API 계약 초안 작성 | 개발 정책 확인 후 SSO 로그인 API, orgFront 직원/조직 API, 응답 필드, 오류 정책 정리 | API 계약 초안 작성 완료 | | Phase 2-2 | 완료 | API 계약 검토 및 확정 | token 종류, 전화번호 로그인 보안 수준, 개인정보 마스킹 범위, 즐겨찾기 동기화 여부 확인 | API 계약 확정본 | -| Phase 2-3 | 다음 | Dart 데이터 모델 설계 | 확정된 API 계약 기준으로 직원, 조직, 로그인, 즐겨찾기 model 정의 | model 초안 | -| Phase 3 | 이후 | Mock 기반 1차 UI 완성 | 직원목록, 검색, 가족사 필터, 조직도, 상세 화면 구성 | API 없이 화면 흐름 확인 가능 | +| Phase 2-3 | 완료 | Dart 데이터 모델 설계 | 확정된 API 계약 기준으로 직원, 조직, 로그인, 즐겨찾기 model 정의 | analyze/test 통과 | +| Phase 3 | 다음 | Mock 기반 1차 UI 완성 | 직원목록, 검색, 가족사 필터, 조직도, 상세 화면 구성 | API 없이 화면 흐름 확인 가능 | | Phase 4 | 이후 | 실제 API 연동 | SSO 로그인, orgFront 직원/조직 데이터 연동 | 등록 사용자 로그인 및 직원목록 조회 | | Phase 5 | 이후 | 핵심 액션 완성 | 전화걸기, 문자보내기, 즐겨찾기 저장 | 1차 기본 기능 수동 검증 | | Phase 6 | 이후 | 빌드/배포 준비 | Android debug APK, README 실행법, 잔여 이슈 정리 | APK 빌드 성공 | ## 4. 다음 작업 판단 -현재 다음 작업은 **Phase 2-3: Dart 데이터 모델 설계**이다. +현재 다음 작업은 **Phase 3: Mock 기반 1차 UI 완성**이다. 공식 Baron SSO `origin/dev` 기준의 API 개발용 worktree는 `/home/ubuntu/workspace/baron-sso-tdc114plus-api`에 생성 완료했다. 기존 `baron-sso` 작업 브랜치는 수정/미추적 파일이 많으므로 직접 merge/rebase하지 않는다. @@ -133,4 +133,4 @@ git -C /home/ubuntu/workspace/tdc114plus log --oneline origin/main -n 10 git -C /home/ubuntu/workspace/tdc114plus status --short --branch ``` -현재 문서 기준 다음 작업은 `Phase 2-3: Dart 데이터 모델 설계`이다. +현재 문서 기준 다음 작업은 `Phase 3: Mock 기반 1차 UI 완성`이다.