Build mock directory UI

This commit is contained in:
Codex
2026-07-02 13:43:07 +09:00
parent ffb93347e2
commit d5edaf0a4d
5 changed files with 658 additions and 41 deletions
@@ -0,0 +1,124 @@
import '../domain/employee.dart';
import '../../organization/domain/organization_models.dart';
const mockTenants = [
TenantSummary(
id: 'tenant-hanmac',
name: '한맥',
slug: 'hanmac',
type: 'COMPANY',
parentId: null,
memberCount: 4,
totalMemberCount: 4,
),
TenantSummary(
id: 'tenant-tdc',
name: 'TDC',
slug: 'tdc',
type: 'COMPANY',
parentId: null,
memberCount: 2,
totalMemberCount: 2,
),
TenantSummary(
id: 'tenant-ict',
name: 'ICT사업부',
slug: 'ict',
type: 'ORGANIZATION',
parentId: 'tenant-hanmac',
memberCount: 2,
totalMemberCount: 2,
),
TenantSummary(
id: 'tenant-rnd',
name: '기술연구소',
slug: 'rnd',
type: 'ORGANIZATION',
parentId: 'tenant-hanmac',
memberCount: 2,
totalMemberCount: 2,
),
];
const mockEmployees = [
Employee(
id: 'user-001',
name: '김하늘',
phoneNumber: '+821012345678',
phoneDisplay: '010-1234-5678',
email: 'haneul.kim@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
department: '기술연구소',
grade: '책임',
position: '팀장',
jobTitle: 'Flutter 개발',
status: 'active',
sortOrder: 10,
),
Employee(
id: 'user-002',
name: '박서준',
phoneNumber: '+821023456789',
phoneDisplay: '010-2345-6789',
email: 'seojun.park@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
department: 'ICT사업부',
grade: '선임',
position: '매니저',
jobTitle: 'API 연동',
status: 'active',
sortOrder: 20,
),
Employee(
id: 'user-003',
name: '이도윤',
phoneNumber: '+821034567890',
phoneDisplay: '010-3456-7890',
email: 'doyun.lee@example.com',
tenantId: 'tenant-tdc',
tenantName: 'TDC',
tenantSlug: 'tdc',
department: '운영지원',
grade: '책임',
position: '파트장',
jobTitle: '서비스 운영',
status: 'active',
sortOrder: 30,
),
Employee(
id: 'user-004',
name: '정수빈',
phoneNumber: '+821045678901',
phoneDisplay: '010-4567-8901',
email: null,
tenantId: 'tenant-tdc',
tenantName: 'TDC',
tenantSlug: 'tdc',
department: '고객지원',
grade: '사원',
position: '담당',
jobTitle: '상담',
status: 'active',
sortOrder: 40,
),
Employee(
id: 'user-005',
name: '최민준',
phoneNumber: '',
phoneDisplay: null,
email: 'minjun.choi@example.com',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
department: '기술연구소',
grade: '수석',
position: '연구원',
jobTitle: '보안 검토',
status: 'temporary_leave',
sortOrder: 50,
),
];
@@ -1,54 +1,218 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DirectoryScreen extends StatelessWidget { import '../data/mock_directory_data.dart';
import '../domain/employee.dart';
class DirectoryScreen extends StatefulWidget {
const DirectoryScreen({super.key}); const DirectoryScreen({super.key});
static const routePath = '/directory'; static const routePath = '/directory';
@override
State<DirectoryScreen> createState() => _DirectoryScreenState();
}
enum _DirectoryView { employees, organization, favorites }
class _DirectoryScreenState extends State<DirectoryScreen> {
final _searchController = TextEditingController();
final _favoriteIds = <String>{'user-001'};
var _view = _DirectoryView.employees;
var _selectedTenantSlug = 'all';
var _query = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Employee> get _filteredEmployees {
final normalizedQuery = _query.replaceAll('-', '').toLowerCase();
return mockEmployees.where((employee) {
final matchesTenant =
_selectedTenantSlug == 'all' ||
employee.tenantSlug == _selectedTenantSlug;
final phone = employee.phoneNumber.replaceAll(RegExp(r'[^0-9]'), '');
final phoneDisplay = employee.phoneDisplay?.replaceAll('-', '') ?? '';
final searchable = [
employee.name,
employee.phoneNumber,
employee.phoneDisplay ?? '',
employee.email ?? '',
employee.tenantName,
employee.department ?? '',
employee.grade ?? '',
employee.position ?? '',
employee.jobTitle ?? '',
phone,
phoneDisplay,
].join(' ').toLowerCase();
final matchesQuery =
normalizedQuery.isEmpty || searchable.contains(normalizedQuery);
final matchesFavorite =
_view != _DirectoryView.favorites ||
_favoriteIds.contains(employee.id);
return matchesTenant && matchesQuery && matchesFavorite;
}).toList()..sort((a, b) => (a.sortOrder ?? 0).compareTo(b.sortOrder ?? 0));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final employees = _filteredEmployees;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('직원검색')), appBar: AppBar(title: const Text('직원검색')),
body: SafeArea( body: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: const [ children: [
_SearchField(), _SearchField(
SizedBox(height: 16), controller: _searchController,
_FeatureTile( onChanged: (value) => setState(() => _query = value),
icon: Icons.manage_search,
title: '직원검색',
subtitle: '이름, 부서, 직위로 직원을 검색합니다.',
), ),
_FeatureTile( const SizedBox(height: 12),
icon: Icons.phone, _TenantFilterBar(
title: '전화번호검색', selectedTenantSlug: _selectedTenantSlug,
subtitle: '전화번호로 직원을 검색합니다.', onSelected: (slug) => setState(() => _selectedTenantSlug = slug),
), ),
_FeatureTile( const SizedBox(height: 12),
icon: Icons.account_tree, _ViewSelector(
title: '조직도', selected: _view,
subtitle: '가족사와 부서 기준으로 직원을 탐색합니다.', onSelected: (value) => setState(() => _view = value),
), ),
_FeatureTile( const SizedBox(height: 16),
icon: Icons.star, _SummaryRow(
title: '즐겨찾기', count: employees.length,
subtitle: '자주 연락하는 직원을 저장합니다.', favoritesCount: _favoriteIds.length,
),
const SizedBox(height: 8),
if (_view == _DirectoryView.organization)
_OrganizationSection(
employees: employees,
favoriteIds: _favoriteIds,
onFavoriteToggle: _toggleFavorite,
onEmployeeTap: _showEmployeeDetail,
)
else if (employees.isEmpty)
const _EmptyState()
else
for (final employee in employees)
_EmployeeTile(
employee: employee,
isFavorite: _favoriteIds.contains(employee.id),
onFavoriteToggle: () => _toggleFavorite(employee.id),
onTap: () => _showEmployeeDetail(employee),
), ),
], ],
), ),
), ),
); );
} }
void _toggleFavorite(String employeeId) {
setState(() {
if (_favoriteIds.contains(employeeId)) {
_favoriteIds.remove(employeeId);
} else {
_favoriteIds.add(employeeId);
}
});
}
void _showEmployeeDetail(Employee employee) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) {
final hasPhone = employee.phoneNumber.trim().isNotEmpty;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(child: Text(employee.name.characters.first)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
employee.name,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
'${employee.tenantName} · ${employee.department ?? '-'}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
IconButton(
tooltip: _favoriteIds.contains(employee.id)
? '즐겨찾기 해제'
: '즐겨찾기 추가',
onPressed: () {
_toggleFavorite(employee.id);
Navigator.of(context).pop();
},
icon: Icon(
_favoriteIds.contains(employee.id)
? Icons.star
: Icons.star_border,
),
),
],
),
const SizedBox(height: 16),
_DetailLine(label: '전화번호', value: employee.phoneDisplay ?? '-'),
_DetailLine(label: '이메일', value: employee.email ?? '-'),
_DetailLine(label: '직급/직위', value: _roleText(employee)),
_DetailLine(label: '직무', value: employee.jobTitle ?? '-'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: hasPhone ? () {} : null,
icon: const Icon(Icons.call),
label: const Text('전화'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: hasPhone ? () {} : null,
icon: const Icon(Icons.sms),
label: const Text('문자'),
),
),
],
),
],
),
),
);
},
);
}
} }
class _SearchField extends StatelessWidget { class _SearchField extends StatelessWidget {
const _SearchField(); const _SearchField({required this.controller, required this.onChanged});
final TextEditingController controller;
final ValueChanged<String> onChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const TextField( return TextField(
decoration: InputDecoration( controller: controller,
onChanged: onChanged,
decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
hintText: '직원명 또는 전화번호 검색', hintText: '직원명 또는 전화번호 검색',
prefixIcon: Icon(Icons.search), prefixIcon: Icon(Icons.search),
@@ -57,25 +221,276 @@ class _SearchField extends StatelessWidget {
} }
} }
class _FeatureTile extends StatelessWidget { class _TenantFilterBar extends StatelessWidget {
const _FeatureTile({ const _TenantFilterBar({
required this.icon, required this.selectedTenantSlug,
required this.title, required this.onSelected,
required this.subtitle,
}); });
final IconData icon; final String selectedTenantSlug;
final String title; final ValueChanged<String> onSelected;
final String subtitle;
@override
Widget build(BuildContext context) {
final companyTenants = mockTenants.where(
(tenant) => tenant.type == 'COMPANY',
);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: const Text('전체'),
selected: selectedTenantSlug == 'all',
onSelected: (_) => onSelected('all'),
),
),
for (final tenant in companyTenants)
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(tenant.name),
selected: selectedTenantSlug == tenant.slug,
onSelected: (_) => onSelected(tenant.slug),
),
),
],
),
);
}
}
class _ViewSelector extends StatelessWidget {
const _ViewSelector({required this.selected, required this.onSelected});
final _DirectoryView selected;
final ValueChanged<_DirectoryView> onSelected;
@override
Widget build(BuildContext context) {
return SegmentedButton<_DirectoryView>(
segments: const [
ButtonSegment(
value: _DirectoryView.employees,
icon: Icon(Icons.people),
label: Text('직원'),
),
ButtonSegment(
value: _DirectoryView.organization,
icon: Icon(Icons.account_tree),
label: Text('조직도'),
),
ButtonSegment(
value: _DirectoryView.favorites,
icon: Icon(Icons.star),
label: Text('즐겨찾기'),
),
],
selected: {selected},
onSelectionChanged: (values) => onSelected(values.first),
);
}
}
class _SummaryRow extends StatelessWidget {
const _SummaryRow({required this.count, required this.favoritesCount});
final int count;
final int favoritesCount;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Text('검색 결과 $count명')),
Icon(
Icons.star,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
Text('즐겨찾기 $favoritesCount명'),
],
);
}
}
class _EmployeeTile extends StatelessWidget {
const _EmployeeTile({
required this.employee,
required this.isFavorite,
required this.onFavoriteToggle,
required this.onTap,
});
final Employee employee;
final bool isFavorite;
final VoidCallback onFavoriteToggle;
final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
child: ListTile( child: ListTile(
leading: Icon(icon), leading: CircleAvatar(child: Text(employee.name.characters.first)),
title: Text(title), title: Text(employee.name),
subtitle: Text(subtitle), subtitle: Text(
[
employee.tenantName,
employee.department,
employee.position,
employee.phoneDisplay,
].whereType<String>().where((value) => value.isNotEmpty).join(' · '),
),
trailing: IconButton(
tooltip: isFavorite ? '즐겨찾기 해제' : '즐겨찾기 추가',
onPressed: onFavoriteToggle,
icon: Icon(isFavorite ? Icons.star : Icons.star_border),
),
onTap: onTap,
), ),
); );
} }
} }
class _OrganizationSection extends StatelessWidget {
const _OrganizationSection({
required this.employees,
required this.favoriteIds,
required this.onFavoriteToggle,
required this.onEmployeeTap,
});
final List<Employee> employees;
final Set<String> favoriteIds;
final ValueChanged<String> onFavoriteToggle;
final ValueChanged<Employee> onEmployeeTap;
@override
Widget build(BuildContext context) {
final tenantNames = employees
.map((employee) => employee.tenantName)
.toSet();
if (employees.isEmpty) {
return const _EmptyState();
}
return Column(
children: [
for (final tenantName in tenantNames)
_OrganizationGroup(
tenantName: tenantName,
employees: employees
.where((employee) => employee.tenantName == tenantName)
.toList(),
favoriteIds: favoriteIds,
onFavoriteToggle: onFavoriteToggle,
onEmployeeTap: onEmployeeTap,
),
],
);
}
}
class _OrganizationGroup extends StatelessWidget {
const _OrganizationGroup({
required this.tenantName,
required this.employees,
required this.favoriteIds,
required this.onFavoriteToggle,
required this.onEmployeeTap,
});
final String tenantName;
final List<Employee> employees;
final Set<String> favoriteIds;
final ValueChanged<String> onFavoriteToggle;
final ValueChanged<Employee> onEmployeeTap;
@override
Widget build(BuildContext context) {
final departments = employees
.map((employee) => employee.department ?? '미지정')
.toSet();
return Card(
child: ExpansionTile(
initiallyExpanded: true,
leading: const Icon(Icons.account_tree),
title: Text(tenantName),
subtitle: Text('${employees.length}'),
children: [
for (final department in departments)
ExpansionTile(
title: Text(department),
children: [
for (final employee in employees.where(
(employee) => (employee.department ?? '미지정') == department,
))
ListTile(
leading: const Icon(Icons.person),
title: Text(employee.name),
subtitle: Text(_roleText(employee)),
trailing: IconButton(
tooltip: favoriteIds.contains(employee.id)
? '즐겨찾기 해제'
: '즐겨찾기 추가',
onPressed: () => onFavoriteToggle(employee.id),
icon: Icon(
favoriteIds.contains(employee.id)
? Icons.star
: Icons.star_border,
),
),
onTap: () => onEmployeeTap(employee),
),
],
),
],
),
);
}
}
class _DetailLine extends StatelessWidget {
const _DetailLine({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 80,
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
),
Expanded(child: Text(value)),
],
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(child: Text('검색 결과가 없습니다.')),
);
}
}
String _roleText(Employee employee) {
return [
employee.grade,
employee.position,
employee.jobTitle,
].whereType<String>().where((value) => value.isNotEmpty).join(' · ');
}
+62 -1
View File
@@ -22,6 +22,67 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('직원검색'), findsWidgets); expect(find.text('직원검색'), findsWidgets);
expect(find.text('전화번호검색'), findsOneWidget); expect(find.text('김하늘'), findsOneWidget);
expect(find.text('검색 결과 5명'), findsOneWidget);
});
testWidgets('filters employees by search query', (WidgetTester tester) async {
await _openDirectory(tester);
await tester.enterText(find.byType(TextField), '서준');
await tester.pumpAndSettle();
expect(find.text('박서준'), findsOneWidget);
expect(find.text('김하늘'), findsNothing);
expect(find.text('검색 결과 1명'), findsOneWidget);
});
testWidgets('filters employees by tenant chip', (WidgetTester tester) async {
await _openDirectory(tester);
await tester.tap(find.text('TDC'));
await tester.pumpAndSettle();
expect(find.text('이도윤'), findsOneWidget);
expect(find.text('정수빈'), findsOneWidget);
expect(find.text('김하늘'), findsNothing);
expect(find.text('검색 결과 2명'), findsOneWidget);
});
testWidgets('shows favorite employees view', (WidgetTester tester) async {
await _openDirectory(tester);
await tester.tap(find.text('즐겨찾기'));
await tester.pumpAndSettle();
expect(find.text('김하늘'), findsOneWidget);
expect(find.text('박서준'), findsNothing);
expect(find.text('검색 결과 1명'), findsOneWidget);
});
testWidgets('opens employee detail bottom sheet', (
WidgetTester tester,
) async {
await _openDirectory(tester);
await tester.tap(find.text('김하늘'));
await tester.pumpAndSettle();
expect(find.text('haneul.kim@example.com'), findsOneWidget);
expect(find.text('전화'), findsOneWidget);
expect(find.text('문자'), findsOneWidget);
}); });
} }
Future<void> _openDirectory(WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: Tdc114PlusApp()));
await tester.pumpAndSettle();
if (find.text('직원검색').evaluate().isNotEmpty) {
return;
}
await tester.enterText(find.byType(TextField), '01012345678');
await tester.tap(find.text('로그인'));
await tester.pumpAndSettle();
}
@@ -49,8 +49,8 @@
| 15 | 테스트 정책 정식화 | 완료 | AI testing 참고 초안을 tdc114plus Flutter 앱/API/보안 중심 테스트 정책으로 재해석 | `docs/tdc114plus-testing-policy-2026-07-02.md` | | 15 | 테스트 정책 정식화 | 완료 | AI testing 참고 초안을 tdc114plus Flutter 앱/API/보안 중심 테스트 정책으로 재해석 | `docs/tdc114plus-testing-policy-2026-07-02.md` |
| 16 | 테스트 자동화 스크립트 초기 구성 | 완료 | 테스트 정책 기반 scripts 추가 및 scaffold 상태 문서화 | `scripts/*.sh`, `docs/tdc114plus-script-automation-plan-2026-07-02.md` | | 16 | 테스트 자동화 스크립트 초기 구성 | 완료 | 테스트 정책 기반 scripts 추가 및 scaffold 상태 문서화 | `scripts/*.sh`, `docs/tdc114plus-script-automation-plan-2026-07-02.md` |
| 17 | 테스트 실행 로그 관리 체계 생성 | 완료 | 월별 누적 테스트 로그 폴더와 2026-07 로그 파일 생성 | `docs/test-logs/` | | 17 | 테스트 실행 로그 관리 체계 생성 | 완료 | 월별 누적 테스트 로그 폴더와 2026-07 로그 파일 생성 | `docs/test-logs/` |
| 18 | Mock 데이터 기반 화면 확장 | 다음 작업 | 직원목록, 검색, 가족사 필터, 조직도 화면을 mock 데이터로 우선 구현 | 동작 가능한 UI | | 18 | Mock 데이터 기반 화면 확장 | 완료 | 직원목록, 검색, 가족사 필터, 조직도, 직원 상세, 즐겨찾기 화면을 mock 데이터로 우선 구현 | 동작 가능한 UI 및 widget test |
| 19 | Baron SSO 로그인 연동 | 대기 | 전화번호 입력 후 SSO 등록 인원 여부 확인 연동 | 로그인 client/repository | | 19 | Baron SSO 로그인 연동 | 다음 작업 | 전화번호 입력 후 SSO 등록 인원 여부 확인 연동 | 로그인 client/repository |
| 20 | orgFront 데이터 연동 | 대기 | 직원/조직 데이터 API 연동 | directory/organization client | | 20 | orgFront 데이터 연동 | 대기 | 직원/조직 데이터 API 연동 | directory/organization client |
| 21 | 전화/문자 액션 구현 | 대기 | `url_launcher` 기반 전화걸기/문자보내기 구현 | 연락 액션 | | 21 | 전화/문자 액션 구현 | 대기 | `url_launcher` 기반 전화걸기/문자보내기 구현 | 연락 액션 |
| 22 | 즐겨찾기 구현 | 대기 | 1차 로컬 저장 기반 즐겨찾기 구현 | favorites feature | | 22 | 즐겨찾기 구현 | 대기 | 1차 로컬 저장 기반 즐겨찾기 구현 | favorites feature |
@@ -69,14 +69,14 @@
| Phase 2-4 | 완료 | 테스트 정책 정식화 | AI testing 참고 초안을 tdc114plus 적용 기준으로 정리 | `docs/tdc114plus-testing-policy-2026-07-02.md` | | Phase 2-4 | 완료 | 테스트 정책 정식화 | AI testing 참고 초안을 tdc114plus 적용 기준으로 정리 | `docs/tdc114plus-testing-policy-2026-07-02.md` |
| Phase 2-5 | 완료 | 테스트 자동화 스크립트 초기 구성 | 즉시 실행 가능한 quality/format/report 스크립트와 scaffold 스크립트 추가 | `docs/tdc114plus-script-automation-plan-2026-07-02.md` | | Phase 2-5 | 완료 | 테스트 자동화 스크립트 초기 구성 | 즉시 실행 가능한 quality/format/report 스크립트와 scaffold 스크립트 추가 | `docs/tdc114plus-script-automation-plan-2026-07-02.md` |
| Phase 2-6 | 완료 | 테스트 실행 로그 관리 체계 생성 | 월별 누적 테스트 로그 문서와 작성 규칙 추가 | `docs/test-logs/` | | Phase 2-6 | 완료 | 테스트 실행 로그 관리 체계 생성 | 월별 누적 테스트 로그 문서와 작성 규칙 추가 | `docs/test-logs/` |
| Phase 3 | 다음 | Mock 기반 1차 UI 완성 | 직원목록, 검색, 가족사 필터, 조직도, 상세 화면 구성 | API 없이 화면 흐름 확인 가능 | | Phase 3 | 완료 | Mock 기반 1차 UI 완성 | 직원목록, 검색, 가족사 필터, 조직도, 상세 화면 구성 | analyze/test 통과 |
| Phase 4 | 이후 | 실제 API 연동 | SSO 로그인, orgFront 직원/조직 데이터 연동 | 등록 사용자 로그인 및 직원목록 조회 | | Phase 4 | 다음 | 실제 API 연동 | SSO 로그인, orgFront 직원/조직 데이터 연동 | 등록 사용자 로그인 및 직원목록 조회 |
| Phase 5 | 이후 | 핵심 액션 완성 | 전화걸기, 문자보내기, 즐겨찾기 저장 | 1차 기본 기능 수동 검증 | | Phase 5 | 이후 | 핵심 액션 완성 | 전화걸기, 문자보내기, 즐겨찾기 저장 | 1차 기본 기능 수동 검증 |
| Phase 6 | 이후 | 빌드/배포 준비 | Android debug APK, README 실행법, 잔여 이슈 정리 | APK 빌드 성공 | | Phase 6 | 이후 | 빌드/배포 준비 | Android debug APK, README 실행법, 잔여 이슈 정리 | APK 빌드 성공 |
## 4. 다음 작업 판단 ## 4. 다음 작업 판단
현재 다음 작업은 **Phase 3: Mock 기반 1차 UI 완성**이다. 현재 다음 작업은 **Phase 4: 실제 API 연동 준비 및 Baron SSO 로그인 연동**이다.
공식 Baron SSO `origin/dev` 기준의 API 개발용 worktree는 `/home/ubuntu/workspace/baron-sso-tdc114plus-api`에 생성 완료했다. 기존 `baron-sso` 작업 브랜치는 수정/미추적 파일이 많으므로 직접 merge/rebase하지 않는다. 공식 Baron SSO `origin/dev` 기준의 API 개발용 worktree는 `/home/ubuntu/workspace/baron-sso-tdc114plus-api`에 생성 완료했다. 기존 `baron-sso` 작업 브랜치는 수정/미추적 파일이 많으므로 직접 merge/rebase하지 않는다.
@@ -140,4 +140,4 @@ git -C /home/ubuntu/workspace/tdc114plus log --oneline origin/main -n 10
git -C /home/ubuntu/workspace/tdc114plus status --short --branch git -C /home/ubuntu/workspace/tdc114plus status --short --branch
``` ```
현재 문서 기준 다음 작업은 `Phase 3: Mock 기반 1차 UI 완성`이다. 현재 문서 기준 다음 작업은 `Phase 4: 실제 API 연동 준비 및 Baron SSO 로그인 연동`이다.
@@ -88,3 +88,20 @@
- Phase 3 선택 테스트에 Playwright MCP 기반 smoke/screenshot 검증 후보 추가 - Phase 3 선택 테스트에 Playwright MCP 기반 smoke/screenshot 검증 후보 추가
- 후속 조치: - 후속 조치:
- Flutter web/preview 실행 방식이 정해지면 Playwright MCP 시나리오와 로그 기록 방식을 구체화 - Flutter web/preview 실행 방식이 정해지면 Playwright MCP 시나리오와 로그 기록 방식을 구체화
## 2026-07-02 14:05 KST - Phase 3 Mock UI 1차 구현 검증
- 목적: 직원목록, 검색, 가족사 필터, 조직도, 직원 상세, 즐겨찾기 mock UI 구현 결과 검증
- 실행 명령:
- `./scripts/format-dart.sh`
- `./scripts/quality-gate.sh`
- 결과: 1차 실패 후 수정하여 통과
- 주요 출력:
- `format-dart.sh`: `directory_screen.dart`, `widget_test.dart` 포맷 적용
- 1차 `quality-gate.sh`: analyze 통과, widget test 4건 실패
- 실패 원인: 전역 `GoRouter`가 이전 테스트의 `/directory` 위치를 유지하여 helper가 로그인 버튼을 찾지 못함
- 수정 내용: 테스트 helper가 이미 직원검색 화면이면 로그인 단계를 건너뛰도록 보정
- 최종 `quality-gate.sh`: analyze 통과, `flutter test` All tests passed
- 후속 조치:
- Phase 3 Mock 기반 1차 UI 완성 처리
- 다음 단계는 Phase 4 실제 API 연동 준비 및 Baron SSO 로그인 연동