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(' · ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user