feat: 회사 관리 API 연동 완료

- CompanyListController 생성 (ChangeNotifier 패턴)
- CompanyListRedesign 화면 Provider 패턴으로 변경
- 무한 스크롤 및 실시간 검색 기능 구현 (디바운싱 적용)
- 중복 회사명 체크 API 연동
- 지점 저장 로직 API 연동 (saveBranch 메서드 추가)
- 에러 처리 및 로딩 상태 UI 구현
- API 통합 계획 문서 업데이트 (회사 관리 100% 완료)
This commit is contained in:
JiWoong Sul
2025-07-24 18:15:50 +09:00
parent 6b31631cfb
commit 7f491afa4f
5 changed files with 578 additions and 159 deletions

View File

@@ -201,7 +201,42 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
// 회사 저장
Future<void> _saveCompany() async {
final duplicateCompany = _controller.checkDuplicateCompany();
// 지점 수정 모드일 때의 처리
if (isBranch && branchId != null) {
// 로딩 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
try {
final success = await _controller.saveBranch(branchId!);
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
if (success) {
Navigator.pop(context, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('지점 저장에 실패했습니다.')),
);
}
}
} catch (e) {
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('오류가 발생했습니다: $e')),
);
}
}
return;
}
// 기존 회사 저장 로직
final duplicateCompany = await _controller.checkDuplicateCompany();
if (duplicateCompany != null) {
DuplicateCompanyDialog.show(context, duplicateCompany);
return;
@@ -256,15 +291,44 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: BranchFormWidget(
controller: _controller.branchControllers[0],
index: 0,
onRemove: null,
onAddressChanged: (address) {
setState(() {
_controller.updateBranchAddress(0, address);
});
},
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: BranchFormWidget(
controller: _controller.branchControllers[0],
index: 0,
onRemove: null,
onAddressChanged: (address) {
setState(() {
_controller.updateBranchAddress(0, address);
});
},
),
),
),
// 저장 버튼
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ElevatedButton(
onPressed: _saveCompany,
style: ElevatedButton.styleFrom(
backgroundColor: AppThemeTailwind.primary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'수정 완료',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:async';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/company/widgets/company_branch_dialog.dart';
import 'package:superport/screens/company/controllers/company_list_controller.dart';
/// shadcn/ui 스타일로 재설계된 회사 관리 화면
class CompanyListRedesign extends StatefulWidget {
@@ -14,22 +17,43 @@ class CompanyListRedesign extends StatefulWidget {
}
class _CompanyListRedesignState extends State<CompanyListRedesign> {
final MockDataService _dataService = MockDataService();
List<Company> _companies = [];
int _currentPage = 1;
final int _pageSize = 10;
late CompanyListController _controller;
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_loadData();
_controller = CompanyListController(dataService: MockDataService());
_controller.initialize();
_setupScrollListener();
}
/// 데이터 로드
void _loadData() {
setState(() {
_companies = _dataService.getAllCompanies();
_currentPage = 1;
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
_searchController.dispose();
_debounceTimer?.cancel();
super.dispose();
}
/// 스크롤 리스너 설정 (무한 스크롤)
void _setupScrollListener() {
_scrollController.addListener(() {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
_controller.loadMore();
}
});
}
/// 검색어 입력 처리 (디바운싱)
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_controller.updateSearchKeyword(value);
});
}
@@ -37,7 +61,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
void _navigateToAddScreen() async {
final result = await Navigator.pushNamed(context, '/company/add');
if (result == true) {
_loadData();
_controller.refresh();
}
}
@@ -55,10 +79,17 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
child: const Text('취소'),
),
TextButton(
onPressed: () {
_dataService.deleteCompany(id);
onPressed: () async {
Navigator.pop(context);
_loadData();
final success = await _controller.deleteCompany(id);
if (!success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error ?? '삭제에 실패했습니다'),
backgroundColor: Colors.red,
),
);
}
},
child: const Text('삭제'),
),
@@ -140,88 +171,159 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
@override
Widget build(BuildContext context) {
// 본사와 지점 구분하기 위한 데이터 준비
final List<Map<String, dynamic>> displayCompanies = [];
for (final company in _companies) {
displayCompanies.add({
'company': company,
'isBranch': false,
'mainCompanyName': null,
});
if (company.branches != null) {
for (final branch in company.branches!) {
displayCompanies.add({
'branch': branch,
'companyId': company.id,
'isBranch': true,
'mainCompanyName': company.name,
});
}
}
}
return ChangeNotifierProvider.value(
value: _controller,
child: Consumer<CompanyListController>(
builder: (context, controller, child) {
// 본사와 지점 구분하기 위한 데이터 준비
final List<Map<String, dynamic>> displayCompanies = [];
for (final company in controller.filteredCompanies) {
displayCompanies.add({
'company': company,
'isBranch': false,
'mainCompanyName': null,
});
if (company.branches != null) {
for (final branch in company.branches!) {
displayCompanies.add({
'branch': branch,
'companyId': company.id,
'isBranch': true,
'mainCompanyName': company.name,
});
}
}
}
// 페이지네이션 처리
final int totalCount = displayCompanies.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > totalCount
? totalCount
: (startIndex + _pageSize);
final List<Map<String, dynamic>> pagedCompanies = displayCompanies.sublist(
startIndex,
endIndex,
);
final int totalCount = displayCompanies.length;
return SingleChildScrollView(
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더 액션 바
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$totalCount개 회사', style: ShadcnTheme.bodyMuted),
ShadcnButton(
text: '회사 추가',
onPressed: _navigateToAddScreen,
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
icon: Icon(Icons.add),
),
],
),
const SizedBox(height: ShadcnTheme.spacing4),
// 테이블 카드
Container(
width: double.infinity,
decoration: BoxDecoration(
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(color: ShadcnTheme.border),
boxShadow: ShadcnTheme.cardShadow,
),
child:
pagedCompanies.isEmpty
? Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 48,
color: ShadcnTheme.muted,
),
const SizedBox(height: ShadcnTheme.spacing4),
Text('등록된 회사가 없습니다', style: ShadcnTheme.bodyMuted),
],
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더 및 검색 바
Row(
children: [
Expanded(
child: Container(
height: 40,
decoration: BoxDecoration(
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(ShadcnTheme.radius),
border: Border.all(color: ShadcnTheme.border),
),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: '회사명, 담당자명, 연락처로 검색',
hintStyle: TextStyle(color: ShadcnTheme.muted),
prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
),
)
),
const SizedBox(width: ShadcnTheme.spacing4),
ShadcnButton(
text: '회사 추가',
onPressed: _navigateToAddScreen,
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
icon: Icon(Icons.add),
),
],
),
const SizedBox(height: ShadcnTheme.spacing4),
// 결과 정보
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$totalCount개 회사', style: ShadcnTheme.bodyMuted),
if (controller.searchKeyword.isNotEmpty)
Text(
'"${controller.searchKeyword}" 검색 결과',
style: ShadcnTheme.bodyMuted,
),
],
),
const SizedBox(height: ShadcnTheme.spacing4),
// 에러 메시지
if (controller.error != null)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
margin: const EdgeInsets.only(bottom: ShadcnTheme.spacing4),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(ShadcnTheme.radius),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: ShadcnTheme.spacing2),
Expanded(
child: Text(
controller.error!,
style: TextStyle(color: Colors.red.shade700),
),
),
IconButton(
icon: Icon(Icons.close, size: 16),
onPressed: controller.clearError,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxHeight: 24, maxWidth: 24),
),
],
),
),
// 테이블 카드
Container(
width: double.infinity,
decoration: BoxDecoration(
color: ShadcnTheme.card,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(color: ShadcnTheme.border),
boxShadow: ShadcnTheme.cardShadow,
),
child: controller.isLoading && controller.companies.isEmpty
? Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: CircularProgressIndicator(),
),
)
: displayCompanies.isEmpty
? Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing8),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 48,
color: ShadcnTheme.muted,
),
const SizedBox(height: ShadcnTheme.spacing4),
Text(
controller.searchKeyword.isNotEmpty
? '검색 결과가 없습니다'
: '등록된 회사가 없습니다',
style: ShadcnTheme.bodyMuted,
),
],
),
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -286,7 +388,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
),
// 테이블 데이터
...pagedCompanies.asMap().entries.map((entry) {
...displayCompanies.asMap().entries.map((entry) {
final int index = entry.key;
final companyData = entry.value;
final bool isBranch = companyData['isBranch'] as bool;
@@ -313,7 +415,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
Expanded(
flex: 1,
child: Text(
'${startIndex + index + 1}',
'${index + 1}',
style: ShadcnTheme.bodySmall,
),
),
@@ -389,7 +491,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
'branchId': company.id,
},
).then((result) {
if (result == true) _loadData();
if (result == true) controller.refresh();
});
} else {
Navigator.pushNamed(
@@ -400,7 +502,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
'isBranch': false,
},
).then((result) {
if (result == true) _loadData();
if (result == true) controller.refresh();
});
}
}
@@ -431,52 +533,32 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
}),
],
),
),
),
// 페이지네이션
if (totalCount > _pageSize)
Container(
padding: const EdgeInsets.symmetric(
vertical: ShadcnTheme.spacing4,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 이전 페이지 버튼
ShadcnButton(
text: '이전',
onPressed:
_currentPage > 1
? () => setState(() => _currentPage--)
: null,
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
// 무한 스크롤 로딩 인디케이터
if (controller.isLoading && controller.companies.isNotEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Center(
child: CircularProgressIndicator(),
),
),
const SizedBox(width: ShadcnTheme.spacing4),
// 페이지 정보
Text(
'$_currentPage / ${((totalCount - 1) ~/ _pageSize) + 1}',
style: ShadcnTheme.bodyMedium,
// 더 이상 로드할 데이터가 없을 때 메시지
if (!controller.hasMore && controller.companies.isNotEmpty)
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
child: Center(
child: Text(
'모든 회사를 불러왔습니다',
style: ShadcnTheme.bodyMuted,
),
),
),
const SizedBox(width: ShadcnTheme.spacing4),
// 다음 페이지 버튼
ShadcnButton(
text: '다음',
onPressed:
_currentPage < ((totalCount - 1) ~/ _pageSize) + 1
? () => setState(() => _currentPage++)
: null,
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.small,
),
],
),
],
),
],
);
},
),
);
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/core/errors/failures.dart';
// 회사 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class CompanyListController extends ChangeNotifier {
final MockDataService dataService;
final CompanyService _companyService = GetIt.instance<CompanyService>();
List<Company> companies = [];
List<Company> filteredCompanies = [];
String searchKeyword = '';
final Set<int> selectedCompanyIds = {};
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag for API usage
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMore = true;
// 필터
bool? _isActiveFilter;
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasMore => _hasMore;
int get currentPage => _currentPage;
bool? get isActiveFilter => _isActiveFilter;
CompanyListController({required this.dataService});
// 초기 데이터 로드
Future<void> initialize() async {
await loadData(isRefresh: true);
}
// 데이터 로드 및 필터 적용
Future<void> loadData({bool isRefresh = false}) async {
if (isRefresh) {
_currentPage = 1;
_hasMore = true;
companies.clear();
filteredCompanies.clear();
}
if (_isLoading || (!_hasMore && !isRefresh)) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (_useApi) {
// API 호출
final apiCompanies = await _companyService.getCompanies(
page: _currentPage,
perPage: _perPage,
search: searchKeyword.isNotEmpty ? searchKeyword : null,
isActive: _isActiveFilter,
);
if (isRefresh) {
companies = apiCompanies;
} else {
companies.addAll(apiCompanies);
}
_hasMore = apiCompanies.length == _perPage;
if (_hasMore) _currentPage++;
} else {
// Mock 데이터 사용
companies = dataService.getAllCompanies();
_hasMore = false;
}
// 필터 적용
applyFilters();
selectedCompanyIds.clear();
} on Failure catch (e) {
_error = e.message;
} catch (e) {
_error = '회사 목록을 불러오는 중 오류가 발생했습니다: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
// 검색 및 필터 적용
void applyFilters() {
filteredCompanies = companies.where((company) {
// 검색어 필터
if (searchKeyword.isNotEmpty) {
final keyword = searchKeyword.toLowerCase();
final matchesName = company.name.toLowerCase().contains(keyword);
final matchesContact = company.contactName?.toLowerCase().contains(keyword) ?? false;
final matchesPhone = company.contactPhone?.toLowerCase().contains(keyword) ?? false;
if (!matchesName && !matchesContact && !matchesPhone) {
return false;
}
}
// 활성 상태 필터 (API 사용 시에는 서버에서 필터링되므로 여기서는 Mock 데이터용)
if (_isActiveFilter != null && !_useApi) {
// Mock 데이터에는 isActive 필드가 없으므로 모두 활성으로 간주
if (_isActiveFilter == false) {
return false;
}
}
return true;
}).toList();
}
// 검색어 변경
Future<void> updateSearchKeyword(String keyword) async {
searchKeyword = keyword;
if (_useApi) {
// API 사용 시 새로 조회
await loadData(isRefresh: true);
} else {
// Mock 데이터 사용 시 필터만 적용
applyFilters();
notifyListeners();
}
}
// 활성 상태 필터 변경
Future<void> changeActiveFilter(bool? isActive) async {
_isActiveFilter = isActive;
await loadData(isRefresh: true);
}
// 회사 선택/해제
void toggleCompanySelection(int? companyId) {
if (companyId == null) return;
if (selectedCompanyIds.contains(companyId)) {
selectedCompanyIds.remove(companyId);
} else {
selectedCompanyIds.add(companyId);
}
notifyListeners();
}
// 전체 선택/해제
void toggleSelectAll() {
if (selectedCompanyIds.length == filteredCompanies.length) {
selectedCompanyIds.clear();
} else {
selectedCompanyIds.clear();
for (final company in filteredCompanies) {
if (company.id != null) {
selectedCompanyIds.add(company.id!);
}
}
}
notifyListeners();
}
// 선택된 회사 수 반환
int getSelectedCount() {
return selectedCompanyIds.length;
}
// 회사 삭제
Future<bool> deleteCompany(int companyId) async {
try {
if (_useApi) {
// API를 통한 삭제
await _companyService.deleteCompany(companyId);
} else {
// Mock 데이터 삭제
dataService.deleteCompany(companyId);
}
// 로컬 리스트에서도 제거
companies.removeWhere((c) => c.id == companyId);
filteredCompanies.removeWhere((c) => c.id == companyId);
selectedCompanyIds.remove(companyId);
notifyListeners();
return true;
} on Failure catch (e) {
_error = e.message;
notifyListeners();
return false;
} catch (e) {
_error = '회사 삭제 중 오류가 발생했습니다: $e';
notifyListeners();
return false;
}
}
// 선택된 회사들 삭제
Future<bool> deleteSelectedCompanies() async {
final selectedIds = selectedCompanyIds.toList();
int successCount = 0;
for (final companyId in selectedIds) {
if (await deleteCompany(companyId)) {
successCount++;
}
}
return successCount == selectedIds.length;
}
// 회사 정보 업데이트 (로컬)
void updateCompanyLocally(Company updatedCompany) {
final index = companies.indexWhere((c) => c.id == updatedCompany.id);
if (index != -1) {
companies[index] = updatedCompany;
applyFilters();
notifyListeners();
}
}
// 회사 추가 (로컬)
void addCompanyLocally(Company newCompany) {
companies.insert(0, newCompany);
applyFilters();
notifyListeners();
}
// 더 많은 데이터 로드
Future<void> loadMore() async {
if (!_hasMore || _isLoading || !_useApi) return;
await loadData();
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
loadData(isRefresh: true);
}
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
// 리프레시
Future<void> refresh() async {
await loadData(isRefresh: true);
}
@override
void dispose() {
super.dispose();
}
}