Build mock directory UI
This commit is contained in:
@@ -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';
|
||||
|
||||
class DirectoryScreen extends StatelessWidget {
|
||||
import '../data/mock_directory_data.dart';
|
||||
import '../domain/employee.dart';
|
||||
|
||||
class DirectoryScreen extends StatefulWidget {
|
||||
const DirectoryScreen({super.key});
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final employees = _filteredEmployees;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('직원검색')),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
_SearchField(),
|
||||
SizedBox(height: 16),
|
||||
_FeatureTile(
|
||||
icon: Icons.manage_search,
|
||||
title: '직원검색',
|
||||
subtitle: '이름, 부서, 직위로 직원을 검색합니다.',
|
||||
children: [
|
||||
_SearchField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
_FeatureTile(
|
||||
icon: Icons.phone,
|
||||
title: '전화번호검색',
|
||||
subtitle: '전화번호로 직원을 검색합니다.',
|
||||
const SizedBox(height: 12),
|
||||
_TenantFilterBar(
|
||||
selectedTenantSlug: _selectedTenantSlug,
|
||||
onSelected: (slug) => setState(() => _selectedTenantSlug = slug),
|
||||
),
|
||||
_FeatureTile(
|
||||
icon: Icons.account_tree,
|
||||
title: '조직도',
|
||||
subtitle: '가족사와 부서 기준으로 직원을 탐색합니다.',
|
||||
const SizedBox(height: 12),
|
||||
_ViewSelector(
|
||||
selected: _view,
|
||||
onSelected: (value) => setState(() => _view = value),
|
||||
),
|
||||
_FeatureTile(
|
||||
icon: Icons.star,
|
||||
title: '즐겨찾기',
|
||||
subtitle: '자주 연락하는 직원을 저장합니다.',
|
||||
const SizedBox(height: 16),
|
||||
_SummaryRow(
|
||||
count: employees.length,
|
||||
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 {
|
||||
const _SearchField();
|
||||
const _SearchField({required this.controller, required this.onChanged});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const TextField(
|
||||
decoration: InputDecoration(
|
||||
return TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '직원명 또는 전화번호 검색',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
@@ -57,25 +221,276 @@ class _SearchField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureTile extends StatelessWidget {
|
||||
const _FeatureTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
class _TenantFilterBar extends StatelessWidget {
|
||||
const _TenantFilterBar({
|
||||
required this.selectedTenantSlug,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String selectedTenantSlug;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
@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
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
leading: CircleAvatar(child: Text(employee.name.characters.first)),
|
||||
title: Text(employee.name),
|
||||
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(' · ');
|
||||
}
|
||||
|
||||
@@ -22,6 +22,67 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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` |
|
||||
| 16 | 테스트 자동화 스크립트 초기 구성 | 완료 | 테스트 정책 기반 scripts 추가 및 scaffold 상태 문서화 | `scripts/*.sh`, `docs/tdc114plus-script-automation-plan-2026-07-02.md` |
|
||||
| 17 | 테스트 실행 로그 관리 체계 생성 | 완료 | 월별 누적 테스트 로그 폴더와 2026-07 로그 파일 생성 | `docs/test-logs/` |
|
||||
| 18 | Mock 데이터 기반 화면 확장 | 다음 작업 | 직원목록, 검색, 가족사 필터, 조직도 화면을 mock 데이터로 우선 구현 | 동작 가능한 UI |
|
||||
| 19 | Baron SSO 로그인 연동 | 대기 | 전화번호 입력 후 SSO 등록 인원 여부 확인 연동 | 로그인 client/repository |
|
||||
| 18 | Mock 데이터 기반 화면 확장 | 완료 | 직원목록, 검색, 가족사 필터, 조직도, 직원 상세, 즐겨찾기 화면을 mock 데이터로 우선 구현 | 동작 가능한 UI 및 widget test |
|
||||
| 19 | Baron SSO 로그인 연동 | 다음 작업 | 전화번호 입력 후 SSO 등록 인원 여부 확인 연동 | 로그인 client/repository |
|
||||
| 20 | orgFront 데이터 연동 | 대기 | 직원/조직 데이터 API 연동 | directory/organization client |
|
||||
| 21 | 전화/문자 액션 구현 | 대기 | `url_launcher` 기반 전화걸기/문자보내기 구현 | 연락 액션 |
|
||||
| 22 | 즐겨찾기 구현 | 대기 | 1차 로컬 저장 기반 즐겨찾기 구현 | favorites feature |
|
||||
@@ -69,14 +69,14 @@
|
||||
| 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-6 | 완료 | 테스트 실행 로그 관리 체계 생성 | 월별 누적 테스트 로그 문서와 작성 규칙 추가 | `docs/test-logs/` |
|
||||
| Phase 3 | 다음 | Mock 기반 1차 UI 완성 | 직원목록, 검색, 가족사 필터, 조직도, 상세 화면 구성 | API 없이 화면 흐름 확인 가능 |
|
||||
| Phase 4 | 이후 | 실제 API 연동 | SSO 로그인, orgFront 직원/조직 데이터 연동 | 등록 사용자 로그인 및 직원목록 조회 |
|
||||
| Phase 3 | 완료 | Mock 기반 1차 UI 완성 | 직원목록, 검색, 가족사 필터, 조직도, 상세 화면 구성 | analyze/test 통과 |
|
||||
| Phase 4 | 다음 | 실제 API 연동 | SSO 로그인, orgFront 직원/조직 데이터 연동 | 등록 사용자 로그인 및 직원목록 조회 |
|
||||
| Phase 5 | 이후 | 핵심 액션 완성 | 전화걸기, 문자보내기, 즐겨찾기 저장 | 1차 기본 기능 수동 검증 |
|
||||
| Phase 6 | 이후 | 빌드/배포 준비 | Android debug APK, README 실행법, 잔여 이슈 정리 | APK 빌드 성공 |
|
||||
|
||||
## 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하지 않는다.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
현재 문서 기준 다음 작업은 `Phase 3: Mock 기반 1차 UI 완성`이다.
|
||||
현재 문서 기준 다음 작업은 `Phase 4: 실제 API 연동 준비 및 Baron SSO 로그인 연동`이다.
|
||||
|
||||
@@ -88,3 +88,20 @@
|
||||
- Phase 3 선택 테스트에 Playwright MCP 기반 smoke/screenshot 검증 후보 추가
|
||||
- 후속 조치:
|
||||
- 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 로그인 연동
|
||||
|
||||
Reference in New Issue
Block a user