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';
|
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(' · ');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 로그인 연동
|
||||||
|
|||||||
Reference in New Issue
Block a user