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';
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(' · ');
}
+62 -1
View File
@@ -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();
}