refactor: Clean Architecture 적용 및 코드베이스 전면 리팩토링
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

## 주요 변경사항

### 아키텍처 개선
- Clean Architecture 패턴 적용 (Domain, Data, Presentation 레이어 분리)
- Use Case 패턴 도입으로 비즈니스 로직 캡슐화
- Repository 패턴으로 데이터 접근 추상화
- 의존성 주입 구조 개선

### 상태 관리 최적화
- 모든 Controller에서 불필요한 상태 관리 로직 제거
- 페이지네이션 로직 통일 및 간소화
- 에러 처리 로직 개선 (에러 메시지 한글화)
- 로딩 상태 관리 최적화

### Mock 서비스 제거
- MockDataService 완전 제거
- 모든 화면을 실제 API 전용으로 전환
- 불필요한 Mock 관련 코드 정리

### UI/UX 개선
- Overview 화면 대시보드 기능 강화
- 라이선스 만료 알림 위젯 추가
- 사이드바 네비게이션 개선
- 일관된 UI 컴포넌트 사용

### 코드 품질
- 중복 코드 제거 및 함수 추출
- 파일별 책임 분리 명확화
- 테스트 코드 업데이트

## 영향 범위
- 모든 화면의 Controller 리팩토링
- API 통신 레이어 구조 개선
- 에러 처리 및 로깅 시스템 개선

## 향후 계획
- 단위 테스트 커버리지 확대
- 통합 테스트 시나리오 추가
- 성능 모니터링 도구 통합
This commit is contained in:
JiWoong Sul
2025-08-11 00:04:28 +09:00
parent 6b5d126990
commit 162fe08618
113 changed files with 11072 additions and 3319 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:provider/provider.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/overview/overview_screen_redesign.dart';
@@ -10,6 +11,7 @@ import 'package:superport/screens/license/license_list_redesign.dart';
import 'package:superport/screens/warehouse_location/warehouse_location_list_redesign.dart';
import 'package:superport/services/auth_service.dart';
import 'package:superport/services/dashboard_service.dart';
import 'package:superport/services/lookup_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/data/models/auth/auth_user.dart';
@@ -34,6 +36,7 @@ class _AppLayoutRedesignState extends State<AppLayoutRedesign>
AuthUser? _currentUser;
late final AuthService _authService;
late final DashboardService _dashboardService;
late final LookupService _lookupService;
late Animation<double> _sidebarAnimation;
int _expiringLicenseCount = 0; // 30일 내 만료 예정 라이선스 수
@@ -50,8 +53,10 @@ class _AppLayoutRedesignState extends State<AppLayoutRedesign>
_setupAnimations();
_authService = GetIt.instance<AuthService>();
_dashboardService = GetIt.instance<DashboardService>();
_lookupService = GetIt.instance<LookupService>();
_loadCurrentUser();
_loadLicenseExpirySummary();
_initializeLookupData(); // Lookup 데이터 초기화
}
Future<void> _loadCurrentUser() async {
@@ -92,6 +97,38 @@ class _AppLayoutRedesignState extends State<AppLayoutRedesign>
print('[ERROR] 스택 트레이스: ${StackTrace.current}');
}
}
/// Lookup 데이터 초기화 (앱 시작 시 한 번만 호출)
Future<void> _initializeLookupData() async {
try {
print('[DEBUG] Lookup 데이터 초기화 시작...');
// 캐시가 유효하지 않을 때만 로드
if (!_lookupService.isCacheValid) {
await _lookupService.loadAllLookups();
if (_lookupService.hasData) {
print('[DEBUG] Lookup 데이터 로드 성공!');
print('[DEBUG] - 장비 타입: ${_lookupService.equipmentTypes.length}');
print('[DEBUG] - 장비 상태: ${_lookupService.equipmentStatuses.length}');
print('[DEBUG] - 라이선스 타입: ${_lookupService.licenseTypes.length}');
print('[DEBUG] - 제조사: ${_lookupService.manufacturers.length}');
print('[DEBUG] - 사용자 역할: ${_lookupService.userRoles.length}');
print('[DEBUG] - 회사 상태: ${_lookupService.companyStatuses.length}');
} else {
print('[WARNING] Lookup 데이터가 비어있습니다.');
}
} else {
print('[DEBUG] Lookup 데이터 캐시 사용 (유효)');
}
if (_lookupService.error != null) {
print('[ERROR] Lookup 데이터 로드 실패: ${_lookupService.error}');
}
} catch (e) {
print('[ERROR] Lookup 데이터 초기화 중 예외 발생: $e');
}
}
void _setupAnimations() {
_sidebarAnimationController = AnimationController(
@@ -222,81 +259,84 @@ class _AppLayoutRedesignState extends State<AppLayoutRedesign>
final screenWidth = MediaQuery.of(context).size.width;
final isWideScreen = screenWidth >= 1920;
return Scaffold(
backgroundColor: ShadcnTheme.backgroundSecondary,
body: Column(
children: [
// F-Pattern: 1차 시선 - 상단 헤더
_buildTopHeader(),
return Provider<AuthService>.value(
value: _authService,
child: Scaffold(
backgroundColor: ShadcnTheme.backgroundSecondary,
body: Column(
children: [
// F-Pattern: 1차 시선 - 상단 헤더
_buildTopHeader(),
// 메인 콘텐츠 영역
Expanded(
child: Row(
children: [
// 좌측 사이드바
AnimatedBuilder(
animation: _sidebarAnimation,
builder: (context, child) {
return Container(
width: _sidebarAnimation.value,
decoration: BoxDecoration(
color: ShadcnTheme.background,
border: Border(
right: BorderSide(
color: ShadcnTheme.border,
width: 1,
),
),
),
child: _buildSidebar(),
);
},
),
// 메인 콘텐츠 (최대 너비 제한)
Expanded(
child: Center(
child: Container(
constraints: BoxConstraints(
maxWidth: isWideScreen ? _maxContentWidth : double.infinity,
),
padding: EdgeInsets.all(
isWideScreen ? ShadcnTheme.spacing6 : ShadcnTheme.spacing4
),
child: Column(
children: [
// F-Pattern: 2차 시선 - 페이지 헤더 + 액션
_buildPageHeader(),
const SizedBox(height: ShadcnTheme.spacing4),
// F-Pattern: 주요 작업 영역
Expanded(
child: Container(
decoration: BoxDecoration(
color: ShadcnTheme.background,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(
color: ShadcnTheme.border,
width: 1,
),
boxShadow: ShadcnTheme.shadowSm,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg - 1),
child: _getContentForRoute(_currentRoute),
),
// 메인 콘텐츠 영역
Expanded(
child: Row(
children: [
// 좌측 사이드바
AnimatedBuilder(
animation: _sidebarAnimation,
builder: (context, child) {
return Container(
width: _sidebarAnimation.value,
decoration: BoxDecoration(
color: ShadcnTheme.background,
border: Border(
right: BorderSide(
color: ShadcnTheme.border,
width: 1,
),
),
],
),
child: _buildSidebar(),
);
},
),
// 메인 콘텐츠 (최대 너비 제한)
Expanded(
child: Center(
child: Container(
constraints: BoxConstraints(
maxWidth: isWideScreen ? _maxContentWidth : double.infinity,
),
padding: EdgeInsets.all(
isWideScreen ? ShadcnTheme.spacing6 : ShadcnTheme.spacing4
),
child: Column(
children: [
// F-Pattern: 2차 시선 - 페이지 헤더 + 액션
_buildPageHeader(),
const SizedBox(height: ShadcnTheme.spacing4),
// F-Pattern: 주요 작업 영역
Expanded(
child: Container(
decoration: BoxDecoration(
color: ShadcnTheme.background,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(
color: ShadcnTheme.border,
width: 1,
),
boxShadow: ShadcnTheme.shadowSm,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg - 1),
child: _getContentForRoute(_currentRoute),
),
),
),
],
),
),
),
),
),
],
],
),
),
),
],
],
),
),
);
}

View File

@@ -24,8 +24,7 @@ class AppThemeTailwind {
colorScheme: const ColorScheme.light(
primary: primary,
secondary: secondary,
background: surface,
surface: cardBackground,
surface: surface,
error: danger,
),
scaffoldBackgroundColor: surface,

View File

@@ -117,7 +117,6 @@ class _AddressInputState extends State<AddressInput> {
/// 시/도 드롭다운 오버레이를 표시합니다.
void _showRegionOverlay() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final availableHeight =
@@ -142,7 +141,7 @@ class _AddressInputState extends State<AddressInput> {
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),

View File

@@ -13,18 +13,18 @@
/// - 유틸리티:
/// - PhoneUtils: 전화번호 관련 유틸리티
import 'package:flutter/material.dart';
import 'package:superport/models/address_model.dart';
// import 'package:superport/models/address_model.dart'; // 사용되지 않는 import
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
// import 'package:superport/screens/common/custom_widgets.dart'; // 사용되지 않는 import
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/company/controllers/company_form_controller.dart';
import 'package:superport/screens/company/widgets/branch_card.dart';
// import 'package:superport/screens/company/widgets/branch_card.dart'; // 사용되지 않는 import
import 'package:superport/screens/company/widgets/company_form_header.dart';
import 'package:superport/screens/company/widgets/contact_info_form.dart';
import 'package:superport/screens/company/widgets/duplicate_company_dialog.dart';
import 'package:superport/screens/company/widgets/map_dialog.dart';
import 'package:superport/screens/company/widgets/branch_form_widget.dart';
import 'package:superport/services/mock_data_service.dart';
// import 'package:superport/services/mock_data_service.dart'; // Mock 서비스 제거
import 'dart:async';
import 'dart:math' as math;
import 'package:superport/screens/company/controllers/branch_form_controller.dart';
@@ -114,9 +114,8 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
debugPrint('📌 회사 폼 초기화 - API 모드: $useApi, companyId: $companyId');
_controller = CompanyFormController(
dataService: useApi ? null : MockDataService(),
companyId: companyId,
useApi: useApi,
useApi: true, // 항상 API 사용
);
// 일반 회사 수정 모드일 때 데이터 로드
@@ -140,10 +139,13 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
// 지점 수정 모드일 때 branchId로 branch 정보 세팅
if (isBranch && branchId != null) {
final company = MockDataService().getCompanyById(companyId!);
// 디버그: 진입 시 companyId, branchId, company, branches 정보 출력
// Mock 서비스 제거 - API를 통해 데이터 로드
// 디버그: 진입 시 companyId, branchId 정보 출력
print('[DEBUG] 지점 수정 진입: companyId=$companyId, branchId=$branchId');
if (company != null && company.branches != null) {
// TODO: API를 통해 회사 데이터 로드 필요
// 아래 코드는 Mock 서비스 제거로 인해 주석 처리됨
/*
if (false) { // 임시로 비활성화
print(
'[DEBUG] 불러온 company.name=${company.name}, branches=${company.branches!.map((b) => 'id:${b.id}, name:${b.name}, remark:${b.remark}').toList()}',
);
@@ -181,6 +183,7 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
),
);
}
*/
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:async';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
@@ -11,7 +12,7 @@ import 'package:superport/screens/common/widgets/standard_data_table.dart'
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/services/mock_data_service.dart';
// import 'package:superport/services/mock_data_service.dart'; // Mock 서비스 제거
import 'package:superport/screens/company/widgets/company_branch_dialog.dart';
import 'package:superport/screens/company/controllers/company_list_controller.dart';
@@ -33,7 +34,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
@override
void initState() {
super.initState();
_controller = CompanyListController(dataService: MockDataService());
_controller = CompanyListController();
_controller.initializeWithPageSize(_pageSize);
}
@@ -48,11 +49,11 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
/// 검색어 입력 처리 (디바운싱)
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_debounceTimer = Timer(AppConstants.searchDebounce, () {
setState(() {
_currentPage = 1;
});
_controller.updateSearchKeyword(value);
_controller.search(value);
});
}
@@ -80,12 +81,13 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
TextButton(
onPressed: () async {
Navigator.pop(context);
final success = await _controller.deleteCompany(id);
if (!success) {
try {
await _controller.deleteCompany(id);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error ?? '삭제에 실패했습니다'),
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
@@ -268,7 +270,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
error: controller.error,
onRefresh: controller.refresh,
emptyMessage:
controller.searchKeyword.isNotEmpty
controller.searchQuery.isNotEmpty
? '검색 결과가 없습니다'
: '등록된 회사가 없습니다',
emptyIcon: Icons.business_outlined,
@@ -279,7 +281,7 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
placeholder: '회사명, 담당자명, 연락처로 검색',
onChanged: _onSearchChanged, // 실시간 검색 (디바운싱)
onSearch:
() => _controller.updateSearchKeyword(
() => _controller.search(
_searchController.text,
), // 즉시 검색
onClear: () {
@@ -298,8 +300,8 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
totalCount: totalCount,
onRefresh: controller.refresh,
statusMessage:
controller.searchKeyword.isNotEmpty
? '"${controller.searchKeyword}" 검색 결과'
controller.searchQuery.isNotEmpty
? '"${controller.searchQuery}" 검색 결과'
: null,
),
@@ -319,12 +321,12 @@ class _CompanyListRedesignState extends State<CompanyListRedesign> {
displayCompanies.isEmpty
? StandardEmptyState(
title:
controller.searchKeyword.isNotEmpty
controller.searchQuery.isNotEmpty
? '검색 결과가 없습니다'
: '등록된 회사가 없습니다',
icon: Icons.business_outlined,
action:
controller.searchKeyword.isEmpty
controller.searchQuery.isEmpty
? StandardActionButtons.addButton(
text: '첫 회사 추가하기',
onPressed: _navigateToAddScreen,

View File

@@ -12,7 +12,7 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/mock_data_service.dart';
// import 'package:superport/services/mock_data_service.dart'; // Mock 서비스 제거
import 'package:superport/services/company_service.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/utils/phone_utils.dart';
@@ -21,7 +21,7 @@ import 'branch_form_controller.dart'; // 분리된 지점 컨트롤러 import
/// 회사 폼 컨트롤러 - 비즈니스 로직 처리
class CompanyFormController {
final MockDataService? dataService;
// final MockDataService? dataService; // Mock 서비스 제거
final CompanyService _companyService = GetIt.instance<CompanyService>();
final int? companyId;
@@ -77,7 +77,7 @@ class CompanyFormController {
bool preventAutoFocus = false;
final Map<int, bool> isNewlyAddedBranch = {};
CompanyFormController({this.dataService, this.companyId, bool useApi = false})
CompanyFormController({this.companyId, bool useApi = true})
: _useApi = useApi {
_setupFocusNodes();
_setupControllerListeners();
@@ -96,13 +96,8 @@ class CompanyFormController {
try {
List<Company> companies;
if (_useApi) {
companies = await _companyService.getCompanies();
} else if (dataService != null) {
companies = dataService!.getAllCompanies();
} else {
companies = [];
}
// API만 사용
companies = await _companyService.getCompanies();
companyNames = companies.map((c) => c.name).toList();
filteredCompanyNames = companyNames;
@@ -125,9 +120,9 @@ class CompanyFormController {
if (_useApi) {
debugPrint('📝 API에서 회사 정보 로드 중...');
company = await _companyService.getCompanyDetail(companyId!);
} else if (dataService != null) {
debugPrint('📝 Mock에서 회사 정보 로드 중...');
company = dataService!.getCompanyById(companyId!);
} else {
debugPrint('📝 API만 사용 가능');
throw Exception('API를 통해만 데이터를 로드할 수 있습니다');
}
debugPrint('📝 로드된 회사: $company');
@@ -234,8 +229,9 @@ class CompanyFormController {
debugPrint('Failed to load company data: ${e.message}');
return;
}
} else if (dataService != null) {
company = dataService!.getCompanyById(companyId!);
} else {
// API만 사용
debugPrint('API를 통해만 데이터를 로드할 수 있습니다');
}
if (company != null) {
@@ -364,8 +360,9 @@ class CompanyFormController {
// 오류 발생 시 중복 없음으로 처리
return null;
}
} else if (dataService != null) {
return dataService!.findCompanyByName(name);
} else {
// API만 사용
return null;
}
return null;
}
@@ -440,12 +437,9 @@ class CompanyFormController {
debugPrint('Unexpected error saving company: $e');
return false;
}
} else if (dataService != null) {
if (companyId == null) {
dataService!.addCompany(company);
} else {
dataService!.updateCompany(company);
}
} else {
// API만 사용
throw Exception('API를 통해만 데이터를 저장할 수 있습니다');
return true;
}
return false;
@@ -484,10 +478,9 @@ class CompanyFormController {
debugPrint('Failed to save branch: ${e.message}');
return false;
}
} else if (dataService != null) {
// Mock 데이터 서비스 사용
dataService!.updateBranch(companyId!, branch);
return true;
} else {
// API만 사용
return false;
}
return false;
}

View File

@@ -0,0 +1,331 @@
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/core/errors/failures.dart';
// 회사 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class CompanyListController extends ChangeNotifier {
final CompanyService _companyService = GetIt.instance<CompanyService>();
List<Company> companies = [];
List<Company> filteredCompanies = [];
String searchKeyword = '';
final Set<int> selectedCompanyIds = {};
bool _isLoading = false;
String? _error;
// API만 사용
// 페이지네이션
int _currentPage = 1;
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();
// 초기 데이터 로드
Future<void> initialize() async {
print('╔══════════════════════════════════════════════════════════');
print('║ 🚀 회사 목록 초기화 시작');
print('║ • 페이지 크기: $_perPage개');
print('╚══════════════════════════════════════════════════════════');
await loadData(isRefresh: true);
}
// 페이지 크기를 지정하여 초기화
Future<void> initializeWithPageSize(int pageSize) async {
_perPage = pageSize;
print('╔══════════════════════════════════════════════════════════');
print('║ 🚀 회사 목록 초기화 시작 (커스텀 페이지 크기)');
print('║ • 페이지 크기: $_perPage개');
print('╚══════════════════════════════════════════════════════════');
await loadData(isRefresh: true);
}
// 데이터 로드 및 필터 적용
Future<void> loadData({bool isRefresh = false}) async {
print('🔍 [DEBUG] loadData 시작 - currentPage: $_currentPage, hasMore: $_hasMore, companies.length: ${companies.length}');
print('[CompanyListController] loadData called - isRefresh: $isRefresh');
if (isRefresh) {
_currentPage = 1;
_hasMore = true;
companies.clear();
filteredCompanies.clear();
}
if (_isLoading || (!_hasMore && !isRefresh)) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
// API 호출 - 지점 정보 포함
print('[CompanyListController] Using API to fetch companies with branches');
// 지점 정보를 포함한 전체 회사 목록 가져오기
final apiCompaniesWithBranches = await _companyService.getCompaniesWithBranchesFlat();
// 상세한 회사 정보 로그 출력
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 회사 목록 로드 완료');
print('║ ▶ 총 회사 수: ${apiCompaniesWithBranches.length}');
print('╟──────────────────────────────────────────────────────────');
// 지점이 있는 회사와 없는 회사 구분
int companiesWithBranches = 0;
int totalBranches = 0;
for (final company in apiCompaniesWithBranches) {
if (company.branches?.isNotEmpty ?? false) {
companiesWithBranches++;
totalBranches += company.branches!.length;
print('║ • ${company.name}: ${company.branches!.length}개 지점');
}
}
final companiesWithoutBranches = apiCompaniesWithBranches.length - companiesWithBranches;
print('╟──────────────────────────────────────────────────────────');
print('║ 📈 통계');
print('║ • 지점이 있는 회사: ${companiesWithBranches}');
print('║ • 지점이 없는 회사: ${companiesWithoutBranches}');
print('║ • 총 지점 수: ${totalBranches}');
print('╚══════════════════════════════════════════════════════════');
// 검색어 필터 적용 (서버에서 필터링이 안 되므로 클라이언트에서 처리)
List<Company> filteredApiCompanies = apiCompaniesWithBranches;
if (searchKeyword.isNotEmpty) {
final keyword = searchKeyword.toLowerCase();
filteredApiCompanies = apiCompaniesWithBranches.where((company) {
return company.name.toLowerCase().contains(keyword) ||
(company.contactName?.toLowerCase().contains(keyword) ?? false) ||
(company.contactPhone?.toLowerCase().contains(keyword) ?? false);
}).toList();
print('╔══════════════════════════════════════════════════════════');
print('║ 🔍 검색 필터 적용');
print('║ • 검색어: "$searchKeyword"');
print('║ • 필터 전: ${apiCompaniesWithBranches.length}');
print('║ • 필터 후: ${filteredApiCompanies.length}');
print('╚══════════════════════════════════════════════════════════');
}
// 활성 상태 필터 적용 (현재 API에서 지원하지 않으므로 주석 처리)
// if (_isActiveFilter != null) {
// filteredApiCompanies = filteredApiCompanies.where((c) => c.isActive == _isActiveFilter).toList();
// }
// 전체 데이터를 한 번에 로드 (View에서 페이지네이션 처리)
companies = filteredApiCompanies;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
print('╔══════════════════════════════════════════════════════════');
print('║ 📑 전체 데이터 로드 완료');
print('║ • 로드된 회사 수: ${companies.length}');
print('║ • 필터링된 회사 수: ${filteredApiCompanies.length}');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
// 필터 적용
applyFilters();
print('╔══════════════════════════════════════════════════════════');
print('║ ✅ 최종 화면 표시');
print('║ • 화면에 표시될 회사 수: ${filteredCompanies.length}');
print('╚══════════════════════════════════════════════════════════');
selectedCompanyIds.clear();
} on Failure catch (e) {
print('[CompanyListController] Failure loading companies: ${e.message}');
_error = e.message;
} catch (e, stackTrace) {
print('[CompanyListController] Error loading companies: $e');
print('[CompanyListController] Error type: ${e.runtimeType}');
print('[CompanyListController] Stack trace: $stackTrace');
_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에서 지원안함)
// if (_isActiveFilter != null) {
// 추후 API 지원 시 구현
// }
return true;
}).toList();
}
// 검색어 변경
Future<void> updateSearchKeyword(String keyword) async {
searchKeyword = keyword;
if (keyword.isNotEmpty) {
print('╔══════════════════════════════════════════════════════════');
print('║ 🔍 검색어 변경: "$keyword"');
print('╚══════════════════════════════════════════════════════════');
} else {
print('╔══════════════════════════════════════════════════════════');
print('║ ❌ 검색어 초기화');
print('╚══════════════════════════════════════════════════════════');
}
// API 사용 시 새로 조회
await loadData(isRefresh: true);
}
// 활성 상태 필터 변경
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 {
// API를 통한 삭제
await _companyService.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 {
print('🔍 [DEBUG] loadMore 호출됨 - hasMore: $_hasMore, isLoading: $_isLoading');
if (!_hasMore || _isLoading) {
print('🔍 [DEBUG] loadMore 조건 미충족으로 종료 (hasMore: $_hasMore, isLoading: $_isLoading)');
return;
}
print('🔍 [DEBUG] loadMore 실행 - 추가 데이터 로드 시작');
await loadData();
}
// API만 사용하므로 토글 기능 제거
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
// 리프레시
Future<void> refresh() async {
print('╔══════════════════════════════════════════════════════════');
print('║ 🔄 회사 목록 새로고침 시작');
print('╚══════════════════════════════════════════════════════════');
await loadData(isRefresh: true);
}
@override
void dispose() {
super.dispose();
}
}

View File

@@ -2,241 +2,94 @@ 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';
import 'package:superport/core/utils/error_handler.dart';
import 'package:superport/core/controllers/base_list_controller.dart';
import 'package:superport/data/models/common/pagination_params.dart';
// 회사 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class CompanyListController extends ChangeNotifier {
final MockDataService dataService;
final CompanyService _companyService = GetIt.instance<CompanyService>();
/// 회사 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
class CompanyListController extends BaseListController<Company> {
late final CompanyService _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;
int _perPage = 20;
bool _hasMore = true;
// 필터
bool? _isActiveFilter;
CompanyType? _typeFilter;
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasMore => _hasMore;
int get currentPage => _currentPage;
List<Company> get companies => items;
List<Company> get filteredCompanies => items;
bool? get isActiveFilter => _isActiveFilter;
CompanyType? get typeFilter => _typeFilter;
CompanyListController({required this.dataService});
CompanyListController() {
if (GetIt.instance.isRegistered<CompanyService>()) {
_companyService = GetIt.instance<CompanyService>();
} else {
throw Exception('CompanyService not registered in GetIt');
}
}
// 초기 데이터 로드
Future<void> initialize() async {
print('╔══════════════════════════════════════════════════════════');
print('║ 🚀 회사 목록 초기화 시작');
print('║ • 페이지 크기: $_perPage개');
print('╚══════════════════════════════════════════════════════════');
await loadData(isRefresh: true);
}
// 페이지 크기를 지정하여 초기화
Future<void> initializeWithPageSize(int pageSize) async {
_perPage = pageSize;
print('╔══════════════════════════════════════════════════════════');
print('║ 🚀 회사 목록 초기화 시작 (커스텀 페이지 크기)');
print('║ • 페이지 크기: $_perPage개');
print('╚══════════════════════════════════════════════════════════');
Future<void> initializeWithPageSize(int newPageSize) async {
pageSize = newPageSize;
await loadData(isRefresh: true);
}
// 데이터 로드 및 필터 적용
Future<void> loadData({bool isRefresh = false}) async {
print('🔍 [DEBUG] loadData 시작 - currentPage: $_currentPage, hasMore: $_hasMore, companies.length: ${companies.length}');
print('[CompanyListController] loadData called - isRefresh: $isRefresh');
@override
Future<PagedResult<Company>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// API 호출 - 회사 목록 조회
final apiCompanies = await ErrorHandler.handleApiCall<List<Company>>(
() => _companyService.getCompanies(
page: params.page,
perPage: params.perPage,
search: params.search,
isActive: _isActiveFilter,
),
onError: (failure) {
throw failure;
},
);
if (isRefresh) {
_currentPage = 1;
_hasMore = true;
companies.clear();
filteredCompanies.clear();
}
final items = apiCompanies ?? [];
if (_isLoading || (!_hasMore && !isRefresh)) return;
// 임시로 메타데이터 생성 (추후 API에서 실제 메타데이터 반환 시 수정)
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: items.length < params.perPage ?
(params.page - 1) * params.perPage + items.length :
params.page * params.perPage + 1,
totalPages: items.length < params.perPage ? params.page : params.page + 1,
hasNext: items.length >= params.perPage,
hasPrevious: params.page > 1,
);
_isLoading = true;
_error = null;
notifyListeners();
try {
if (_useApi) {
// API 호출 - 지점 정보 포함
print('[CompanyListController] Using API to fetch companies with branches');
// 지점 정보를 포함한 전체 회사 목록 가져오기
final apiCompaniesWithBranches = await _companyService.getCompaniesWithBranchesFlat();
// 상세한 회사 정보 로그 출력
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 회사 목록 로드 완료');
print('║ ▶ 총 회사 수: ${apiCompaniesWithBranches.length}');
print('╟──────────────────────────────────────────────────────────');
// 지점이 있는 회사와 없는 회사 구분
int companiesWithBranches = 0;
int totalBranches = 0;
for (final company in apiCompaniesWithBranches) {
if (company.branches?.isNotEmpty ?? false) {
companiesWithBranches++;
totalBranches += company.branches!.length;
print('║ • ${company.name}: ${company.branches!.length}개 지점');
}
}
final companiesWithoutBranches = apiCompaniesWithBranches.length - companiesWithBranches;
print('╟──────────────────────────────────────────────────────────');
print('║ 📈 통계');
print('║ • 지점이 있는 회사: ${companiesWithBranches}');
print('║ • 지점이 없는 회사: ${companiesWithoutBranches}');
print('║ • 총 지점 수: ${totalBranches}');
print('╚══════════════════════════════════════════════════════════');
// 검색어 필터 적용 (서버에서 필터링이 안 되므로 클라이언트에서 처리)
List<Company> filteredApiCompanies = apiCompaniesWithBranches;
if (searchKeyword.isNotEmpty) {
final keyword = searchKeyword.toLowerCase();
filteredApiCompanies = apiCompaniesWithBranches.where((company) {
return company.name.toLowerCase().contains(keyword) ||
(company.contactName?.toLowerCase().contains(keyword) ?? false) ||
(company.contactPhone?.toLowerCase().contains(keyword) ?? false);
}).toList();
print('╔══════════════════════════════════════════════════════════');
print('║ 🔍 검색 필터 적용');
print('║ • 검색어: "$searchKeyword"');
print('║ • 필터 전: ${apiCompaniesWithBranches.length}');
print('║ • 필터 후: ${filteredApiCompanies.length}');
print('╚══════════════════════════════════════════════════════════');
}
// 활성 상태 필터 적용 (현재 API에서 지원하지 않으므로 주석 처리)
// if (_isActiveFilter != null) {
// filteredApiCompanies = filteredApiCompanies.where((c) => c.isActive == _isActiveFilter).toList();
// }
// 전체 데이터를 한 번에 로드 (View에서 페이지네이션 처리)
companies = filteredApiCompanies;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
print('╔══════════════════════════════════════════════════════════');
print('║ 📑 전체 데이터 로드 완료');
print('║ • 로드된 회사 수: ${companies.length}');
print('║ • 필터링된 회사 수: ${filteredApiCompanies.length}');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
} else {
// Mock 데이터 사용
companies = dataService.getAllCompanies();
print('╔══════════════════════════════════════════════════════════');
print('║ 🔧 Mock 데이터 로드 완료');
print('║ ▶ 총 회사 수: ${companies.length}');
print('╚══════════════════════════════════════════════════════════');
_hasMore = false;
}
// 필터 적용
applyFilters();
print('╔══════════════════════════════════════════════════════════');
print('║ ✅ 최종 화면 표시');
print('║ • 화면에 표시될 회사 수: ${filteredCompanies.length}');
print('╚══════════════════════════════════════════════════════════');
selectedCompanyIds.clear();
} on Failure catch (e) {
print('[CompanyListController] Failure loading companies: ${e.message}');
_error = e.message;
} catch (e, stackTrace) {
print('[CompanyListController] Error loading companies: $e');
print('[CompanyListController] Error type: ${e.runtimeType}');
print('[CompanyListController] Stack trace: $stackTrace');
_error = '회사 목록을 불러오는 중 오류가 발생했습니다: $e';
} finally {
_isLoading = false;
notifyListeners();
}
return PagedResult(items: items, meta: meta);
}
// 검색 및 필터 적용
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();
@override
bool filterItem(Company item, String query) {
final q = query.toLowerCase();
return item.name.toLowerCase().contains(q) ||
(item.contactPhone?.toLowerCase().contains(q) ?? false) ||
(item.contactEmail?.toLowerCase().contains(q) ?? false) ||
(item.companyTypes.any((type) => type.name.toLowerCase().contains(q))) ||
(item.address.toString().toLowerCase().contains(q));
}
// 검색어 변경
Future<void> updateSearchKeyword(String keyword) async {
searchKeyword = keyword;
if (keyword.isNotEmpty) {
print('╔══════════════════════════════════════════════════════════');
print('║ 🔍 검색어 변경: "$keyword"');
print('╚══════════════════════════════════════════════════════════');
} else {
print('╔══════════════════════════════════════════════════════════');
print('║ ❌ 검색어 초기화');
print('╚══════════════════════════════════════════════════════════');
}
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;
// 회사 선택/선택 해제
void toggleSelection(int companyId) {
if (selectedCompanyIds.contains(companyId)) {
selectedCompanyIds.remove(companyId);
} else {
@@ -245,119 +98,73 @@ class CompanyListController extends ChangeNotifier {
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!);
}
}
}
// 모든 선택 해제
void clearSelection() {
selectedCompanyIds.clear();
notifyListeners();
}
// 선택된 회사 수 반환
int getSelectedCount() {
return selectedCompanyIds.length;
// 필터 설정
void setFilters({bool? isActive, CompanyType? type}) {
_isActiveFilter = isActive;
_typeFilter = type;
loadData(isRefresh: true);
}
// 필터 초기화
void clearFilters() {
_isActiveFilter = null;
_typeFilter = null;
search('');
loadData(isRefresh: true);
}
// 회사 추가
Future<void> addCompany(Company company) async {
await ErrorHandler.handleApiCall<void>(
() => _companyService.createCompany(company),
onError: (failure) {
throw failure;
},
);
await refresh();
}
// 회사 수정
Future<void> updateCompany(Company company) async {
if (company.id == null) {
throw Exception('회사 ID가 없습니다');
}
await ErrorHandler.handleApiCall<void>(
() => _companyService.updateCompany(company.id!, company),
onError: (failure) {
throw failure;
},
);
updateItemLocally(company, (c) => c.id == company.id);
}
// 회사 삭제
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<void> deleteCompany(int id) async {
await ErrorHandler.handleApiCall<void>(
() => _companyService.deleteCompany(id),
onError: (failure) {
throw failure;
},
);
removeItemLocally((c) => c.id == id);
selectedCompanyIds.remove(id);
}
// 선택된 회사들 삭제
Future<bool> deleteSelectedCompanies() async {
final selectedIds = selectedCompanyIds.toList();
int successCount = 0;
for (final companyId in selectedIds) {
if (await deleteCompany(companyId)) {
successCount++;
}
Future<void> deleteSelectedCompanies() async {
for (final id in selectedCompanyIds.toList()) {
await deleteCompany(id);
}
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 {
print('🔍 [DEBUG] loadMore 호출됨 - hasMore: $_hasMore, isLoading: $_isLoading, useApi: $_useApi');
if (!_hasMore || _isLoading || !_useApi) {
print('🔍 [DEBUG] loadMore 조건 미충족으로 종료 (hasMore: $_hasMore, isLoading: $_isLoading, useApi: $_useApi)');
return;
}
print('🔍 [DEBUG] loadMore 실행 - 추가 데이터 로드 시작');
await loadData();
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
loadData(isRefresh: true);
}
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
// 리프레시
Future<void> refresh() async {
print('╔══════════════════════════════════════════════════════════');
print('║ 🔄 회사 목록 새로고침 시작');
print('╚══════════════════════════════════════════════════════════');
await loadData(isRefresh: true);
}
@override
void dispose() {
super.dispose();
clearSelection();
}
}

View File

@@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import '../../../core/controllers/base_list_controller.dart';
import '../../../core/errors/failures.dart';
import '../../../domain/usecases/base_usecase.dart';
import '../../../domain/usecases/company/company_usecases.dart';
import '../../../models/company_model.dart';
import '../../../services/company_service.dart';
import '../../../di/injection_container.dart';
import '../../../data/models/common/pagination_params.dart';
/// UseCase를 활용한 회사 목록 관리 컨트롤러
/// BaseListController를 상속받아 공통 기능 재사용
class CompanyListControllerWithUseCase extends BaseListController<Company> {
// UseCases
late final GetCompaniesUseCase _getCompaniesUseCase;
late final CreateCompanyUseCase _createCompanyUseCase;
late final UpdateCompanyUseCase _updateCompanyUseCase;
late final DeleteCompanyUseCase _deleteCompanyUseCase;
late final GetCompanyDetailUseCase _getCompanyDetailUseCase;
late final ToggleCompanyStatusUseCase _toggleCompanyStatusUseCase;
// 필터 상태
String? selectedType;
bool? isActive;
// 선택된 회사들
final Set<int> _selectedCompanyIds = {};
Set<int> get selectedCompanyIds => _selectedCompanyIds;
bool get hasSelection => _selectedCompanyIds.isNotEmpty;
CompanyListControllerWithUseCase() {
// UseCase 초기화
final companyService = inject<CompanyService>();
_getCompaniesUseCase = GetCompaniesUseCase(companyService);
_createCompanyUseCase = CreateCompanyUseCase(companyService);
_updateCompanyUseCase = UpdateCompanyUseCase(companyService);
_deleteCompanyUseCase = DeleteCompanyUseCase(companyService);
_getCompanyDetailUseCase = GetCompanyDetailUseCase(companyService);
_toggleCompanyStatusUseCase = ToggleCompanyStatusUseCase(companyService);
}
@override
Future<PagedResult<Company>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// UseCase를 통한 데이터 조회
final usecaseParams = GetCompaniesParams(
page: params.page,
perPage: params.perPage,
search: params.search,
isActive: isActive,
);
final result = await _getCompaniesUseCase(usecaseParams);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(companies) {
// PagedResult로 래핑하여 반환 (임시로 메타데이터 생성)
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: companies.length, // 실제로는 서버에서 받아와야 함
totalPages: (companies.length / params.perPage).ceil(),
hasNext: companies.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: companies, meta: meta);
},
);
}
/// 회사 생성
Future<bool> createCompany(Company company) async {
isLoadingState = true;
notifyListeners();
final params = CreateCompanyParams(company: company);
final result = await _createCompanyUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
// ValidationFailure의 경우 상세 에러 표시
if (failure is ValidationFailure && failure.errors != null) {
final errorMessages = failure.errors!.entries
.map((e) => '${e.key}: ${e.value}')
.join('\n');
errorState = errorMessages;
}
isLoadingState = false;
notifyListeners();
return false;
},
(newCompany) {
// 로컬 리스트에 추가
addItemLocally(newCompany);
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 수정
Future<bool> updateCompany(int id, Company company) async {
isLoadingState = true;
notifyListeners();
final params = UpdateCompanyParams(id: id, company: company);
final result = await _updateCompanyUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
// ValidationFailure의 경우 상세 에러 표시
if (failure is ValidationFailure && failure.errors != null) {
final errorMessages = failure.errors!.entries
.map((e) => '${e.key}: ${e.value}')
.join('\n');
errorState = errorMessages;
}
isLoadingState = false;
notifyListeners();
return false;
},
(updatedCompany) {
// 로컬 리스트 업데이트
updateItemLocally(updatedCompany, (item) => item.id == id);
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 삭제
Future<bool> deleteCompany(int id) async {
isLoadingState = true;
notifyListeners();
final params = DeleteCompanyParams(id: id);
final result = await _deleteCompanyUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
isLoadingState = false;
notifyListeners();
return false;
},
(_) {
// 로컬 리스트에서 제거
removeItemLocally((item) => item.id == id);
_selectedCompanyIds.remove(id);
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 상세 조회
Future<Company?> getCompanyDetail(int id, {bool includeBranches = false}) async {
final params = GetCompanyDetailParams(
id: id,
includeBranches: includeBranches,
);
final result = await _getCompanyDetailUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
notifyListeners();
return null;
},
(company) => company,
);
}
/// 회사 상태 토글 (활성화/비활성화)
Future<bool> toggleCompanyStatus(int id) async {
isLoadingState = true;
notifyListeners();
// 현재 회사 상태를 확인하여 토글 (기본값 true로 가정)
final params = ToggleCompanyStatusParams(
id: id,
isActive: false, // 임시로 false로 설정 (실제로는 현재 상태를 API로 확인해야 함)
);
final result = await _toggleCompanyStatusUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
isLoadingState = false;
notifyListeners();
return false;
},
(_) {
// 로컬 리스트에서 상태 업데이트 (실제로는 API에서 업데이트된 Company 객체를 받아와야 함)
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 선택/해제
void toggleSelection(int companyId) {
if (_selectedCompanyIds.contains(companyId)) {
_selectedCompanyIds.remove(companyId);
} else {
_selectedCompanyIds.add(companyId);
}
notifyListeners();
}
/// 전체 선택/해제
void toggleSelectAll() {
if (_selectedCompanyIds.length == items.length) {
_selectedCompanyIds.clear();
} else {
_selectedCompanyIds.clear();
_selectedCompanyIds.addAll(items.where((c) => c.id != null).map((c) => c.id!));
}
notifyListeners();
}
/// 선택 초기화
void clearSelection() {
_selectedCompanyIds.clear();
notifyListeners();
}
/// 필터 적용
void applyFilters({String? type, bool? active}) {
selectedType = type;
isActive = active;
refresh();
}
/// 필터 초기화
void clearFilters() {
selectedType = null;
isActive = null;
refresh();
}
/// 선택된 회사들 일괄 삭제
Future<bool> deleteSelectedCompanies() async {
if (_selectedCompanyIds.isEmpty) return false;
isLoadingState = true;
notifyListeners();
bool allSuccess = true;
final failedIds = <int>[];
for (final id in _selectedCompanyIds.toList()) {
final params = DeleteCompanyParams(id: id);
final result = await _deleteCompanyUseCase(params);
result.fold(
(failure) {
allSuccess = false;
failedIds.add(id);
debugPrint('회사 $id 삭제 실패: ${failure.message}');
},
(_) {
removeItemLocally((item) => item.id == id);
},
);
}
if (failedIds.isNotEmpty) {
errorState = '일부 회사 삭제 실패: ${failedIds.join(', ')}';
}
_selectedCompanyIds.clear();
isLoadingState = false;
notifyListeners();
return allSuccess;
}
}

View File

@@ -3,7 +3,6 @@ import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/services/warehouse_service.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/utils/constants.dart';
@@ -14,7 +13,6 @@ import 'package:superport/core/utils/debug_logger.dart';
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentInFormController extends ChangeNotifier {
final MockDataService dataService;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
final WarehouseService _warehouseService = GetIt.instance<WarehouseService>();
final CompanyService _companyService = GetIt.instance<CompanyService>();
@@ -24,7 +22,7 @@ class EquipmentInFormController extends ChangeNotifier {
bool _isLoading = false;
String? _error;
bool _isSaving = false;
bool _useApi = true; // Feature flag
// API만 사용
// Getters
bool get isLoading => _isLoading;
@@ -76,7 +74,7 @@ class EquipmentInFormController extends ChangeNotifier {
final TextEditingController remarkController = TextEditingController();
EquipmentInFormController({required this.dataService, this.equipmentInId}) {
EquipmentInFormController({this.equipmentInId}) {
isEditMode = equipmentInId != null;
_loadManufacturers();
_loadEquipmentNames();
@@ -95,91 +93,71 @@ class EquipmentInFormController extends ChangeNotifier {
await _loadEquipmentIn();
}
// 제조사 목록 로드
// 자동완성 데이터는 API를 통해 로드해야 하지만, 현재는 빈 목록으로 설정
void _loadManufacturers() {
manufacturers = dataService.getAllManufacturers();
// TODO: API를 통해 제조사 목록 로드
manufacturers = [];
}
// 장비명 목록 로드
void _loadEquipmentNames() {
equipmentNames = dataService.getAllEquipmentNames();
// TODO: API를 통해 장비명 목록 로드
equipmentNames = [];
}
// 카테고리 목록 로드
void _loadCategories() {
categories = dataService.getAllCategories();
// TODO: API를 통해 카테고리 목록 로드
categories = [];
}
// 서브카테고리 목록 로드
void _loadSubCategories() {
subCategories = dataService.getAllSubCategories();
// TODO: API를 통해 서브카테고리 목록 로드
subCategories = [];
}
// 서브서브카테고리 목록 로드
void _loadSubSubCategories() {
subSubCategories = dataService.getAllSubSubCategories();
// TODO: API를 통해 서브서브카테고리 목록 로드
subSubCategories = [];
}
// 입고지 목록 로드
void _loadWarehouseLocations() async {
if (_useApi) {
try {
DebugLogger.log('입고지 목록 API 로드 시작', tag: 'EQUIPMENT_IN');
final locations = await _warehouseService.getWarehouseLocations();
warehouseLocations = locations.map((e) => e.name).toList();
// 이름-ID 매핑 저장
warehouseLocationMap = {for (var loc in locations) loc.name: loc.id};
DebugLogger.log('입고지 목록 로드 성공', tag: 'EQUIPMENT_IN', data: {
'count': warehouseLocations.length,
'locations': warehouseLocations,
'locationMap': warehouseLocationMap,
});
notifyListeners();
} catch (e) {
DebugLogger.logError('입고지 목록 로드 실패', error: e);
// 실패 시 Mock 데이터 사용
final mockLocations = dataService.getAllWarehouseLocations();
warehouseLocations = mockLocations.map((e) => e.name).toList();
warehouseLocationMap = {for (var loc in mockLocations) loc.name: loc.id};
notifyListeners();
}
} else {
final mockLocations = dataService.getAllWarehouseLocations();
warehouseLocations = mockLocations.map((e) => e.name).toList();
warehouseLocationMap = {for (var loc in mockLocations) loc.name: loc.id};
try {
DebugLogger.log('입고지 목록 API 로드 시작', tag: 'EQUIPMENT_IN');
final locations = await _warehouseService.getWarehouseLocations();
warehouseLocations = locations.map((e) => e.name).toList();
// 이름-ID 매핑 저장
warehouseLocationMap = {for (var loc in locations) loc.name: loc.id};
DebugLogger.log('입고지 목록 로드 성공', tag: 'EQUIPMENT_IN', data: {
'count': warehouseLocations.length,
'locations': warehouseLocations,
'locationMap': warehouseLocationMap,
});
notifyListeners();
} catch (e) {
DebugLogger.logError('입고지 목록 로드 실패', error: e);
// API 실패 시 빈 목록
warehouseLocations = [];
warehouseLocationMap = {};
notifyListeners();
}
}
// 파트너사 목록 로드
void _loadPartnerCompanies() async {
if (_useApi) {
try {
DebugLogger.log('파트너사 목록 API 로드 시작', tag: 'EQUIPMENT_IN');
final companies = await _companyService.getCompanies();
partnerCompanies = companies.map((c) => c.name).toList();
DebugLogger.log('파트너사 목록 로드 성공', tag: 'EQUIPMENT_IN', data: {
'count': partnerCompanies.length,
'companies': partnerCompanies,
});
notifyListeners();
} catch (e) {
DebugLogger.logError('파트너사 목록 로드 실패', error: e);
// 실패 시 Mock 데이터 사용
partnerCompanies =
dataService
.getAllCompanies()
.where((c) => c.companyTypes.contains(CompanyType.partner))
.map((c) => c.name)
.toList();
notifyListeners();
}
} else {
partnerCompanies =
dataService
.getAllCompanies()
.where((c) => c.companyTypes.contains(CompanyType.partner))
.map((c) => c.name)
.toList();
try {
DebugLogger.log('파트너사 목록 API 로드 시작', tag: 'EQUIPMENT_IN');
final companies = await _companyService.getCompanies();
partnerCompanies = companies.map((c) => c.name).toList();
DebugLogger.log('파트너사 목록 로드 성공', tag: 'EQUIPMENT_IN', data: {
'count': partnerCompanies.length,
'companies': partnerCompanies,
});
notifyListeners();
} catch (e) {
DebugLogger.logError('파트너사 목록 로드 실패', error: e);
// API 실패 시 빈 목록
partnerCompanies = [];
notifyListeners();
}
}
@@ -198,12 +176,11 @@ class EquipmentInFormController extends ChangeNotifier {
notifyListeners();
try {
if (_useApi) {
// equipmentInId는 실제로 장비 ID임 (입고 ID가 아님)
actualEquipmentId = equipmentInId;
try {
// API에서 장비 정보 가져오기
// equipmentInId는 실제로 장빔 ID임 (입고 ID가 아님)
actualEquipmentId = equipmentInId;
try {
// API에서 장비 정보 가져오기
DebugLogger.log('장비 정보 로드 시작', tag: 'EQUIPMENT_IN', data: {
'equipmentId': actualEquipmentId,
});
@@ -238,25 +215,8 @@ class EquipmentInFormController extends ChangeNotifier {
} catch (e) {
DebugLogger.logError('장비 정보 로드 실패', error: e);
// API 실패 시 Mock 데이터 시도
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
actualEquipmentId = equipmentIn.equipment.id;
_loadFromMockData(equipmentIn);
} else {
throw ServerFailure(message: '장비 정보를 찾을 수 없습니다.');
}
}
} else {
// Mock 데이터 사용
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
actualEquipmentId = equipmentIn.equipment.id;
_loadFromMockData(equipmentIn);
} else {
throw ServerFailure(message: '장비 정보를 찾을 수 없습니다.');
}
}
} catch (e) {
_error = '장비 정보를 불러오는데 실패했습니다: $e';
DebugLogger.logError('장비 로드 실패', error: e);
@@ -266,28 +226,6 @@ class EquipmentInFormController extends ChangeNotifier {
}
}
void _loadFromMockData(EquipmentIn equipmentIn) {
manufacturer = equipmentIn.equipment.manufacturer;
name = equipmentIn.equipment.name;
category = equipmentIn.equipment.category;
subCategory = equipmentIn.equipment.subCategory;
subSubCategory = equipmentIn.equipment.subSubCategory;
serialNumber = equipmentIn.equipment.serialNumber ?? '';
barcode = equipmentIn.equipment.barcode ?? '';
quantity = equipmentIn.equipment.quantity;
inDate = equipmentIn.inDate;
hasSerialNumber = serialNumber.isNotEmpty;
equipmentType = equipmentIn.type;
warehouseLocation = equipmentIn.warehouseLocation;
partnerCompany = equipmentIn.partnerCompany;
remarkController.text = equipmentIn.remark ?? '';
// 워런티 정보 로드
warrantyLicense = equipmentIn.partnerCompany;
warrantyStartDate = equipmentIn.inDate;
warrantyEndDate = equipmentIn.inDate.add(const Duration(days: 365));
warrantyCode = null;
}
// 워런티 기간 계산
String getWarrantyPeriodSummary() {
@@ -374,9 +312,8 @@ class EquipmentInFormController extends ChangeNotifier {
// 워런티 코드 저장 필요시 여기에 추가
);
if (_useApi) {
// API 호출
if (isEditMode) {
// API 호출
if (isEditMode) {
// 수정 모드 - API로 장비 정보 업데이트
if (actualEquipmentId == null) {
throw ServerFailure(message: '장비 ID가 없습니다.');
@@ -437,35 +374,6 @@ class EquipmentInFormController extends ChangeNotifier {
throw e; // 에러를 상위로 전파하여 적절한 에러 메시지 표시
}
}
} else {
// Mock 데이터 사용
if (isEditMode) {
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
final updatedEquipmentIn = EquipmentIn(
id: equipmentIn.id,
equipment: equipment,
inDate: inDate,
status: equipmentIn.status,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentIn(updatedEquipmentIn);
}
} else {
final newEquipmentIn = EquipmentIn(
equipment: equipment,
inDate: inDate,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.addEquipmentIn(newEquipmentIn);
}
}
// 저장 후 리스트 재로딩 (중복 방지 및 최신화)
_loadManufacturers();
@@ -498,11 +406,7 @@ class EquipmentInFormController extends ChangeNotifier {
notifyListeners();
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
notifyListeners();
}
// API 사용하므로 토글 기능 제거
@override
void dispose() {

View File

@@ -0,0 +1,281 @@
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/models/equipment_unified_model.dart' as legacy;
import 'package:superport/core/utils/debug_logger.dart';
// companyTypeToString 함수 import
import 'package:superport/utils/constants.dart'
show companyTypeToString, CompanyType;
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/core/utils/equipment_status_converter.dart';
// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentListController extends ChangeNotifier {
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
List<UnifiedEquipment> equipments = [];
String? selectedStatusFilter;
String searchKeyword = ''; // 검색어 추가
final Set<String> selectedEquipmentIds = {}; // 'id:status' 형식
bool _isLoading = false;
String? _error;
// API만 사용
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMore = true;
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasMore => _hasMore;
int get currentPage => _currentPage;
EquipmentListController();
// 데이터 로드 및 상태 필터 적용
Future<void> loadData({bool isRefresh = false, String? search}) async {
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
// API 호출 - 전체 데이터 로드
print('╔══════════════════════════════════════════════════════════');
print('║ 📦 장비 목록 API 호출 시작');
print('║ • 상태 필터: ${selectedStatusFilter ?? "전체"}');
print('║ • 검색어: ${search ?? searchKeyword}');
print('╚══════════════════════════════════════════════════════════');
// 전체 데이터를 가져오기 위해 큰 perPage 값 사용
final apiEquipmentDtos = await _equipmentService.getEquipmentsWithStatus(
page: 1,
perPage: 1000, // 충분히 큰 값으로 전체 데이터 로드
status: selectedStatusFilter != null ? EquipmentStatusConverter.clientToServer(selectedStatusFilter) : null,
search: search ?? searchKeyword,
);
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 장비 목록 로드 완료');
print('║ ▶ 총 장비 수: ${apiEquipmentDtos.length}');
print('╟──────────────────────────────────────────────────────────');
// 상태별 통계
Map<String, int> statusCount = {};
for (final dto in apiEquipmentDtos) {
final clientStatus = EquipmentStatusConverter.serverToClient(dto.status);
statusCount[clientStatus] = (statusCount[clientStatus] ?? 0) + 1;
}
statusCount.forEach((status, count) {
print('║ • $status: $count개');
});
print('╟──────────────────────────────────────────────────────────');
print('║ 📑 전체 데이터 로드 완료');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
// DTO를 UnifiedEquipment로 변환 (status 정보 포함)
final List<UnifiedEquipment> unifiedEquipments = apiEquipmentDtos.map((dto) {
final equipment = Equipment(
id: dto.id,
manufacturer: dto.manufacturer,
name: dto.modelName ?? dto.equipmentNumber,
category: '', // 세부 정보는 상세 조회에서 가져와야 함
subCategory: '',
subSubCategory: '',
serialNumber: dto.serialNumber,
quantity: 1,
inDate: dto.createdAt,
);
return UnifiedEquipment(
id: dto.id,
equipment: equipment,
date: dto.createdAt,
status: EquipmentStatusConverter.serverToClient(dto.status), // 서버 status를 클라이언트 status로 변환
);
}).toList();
equipments = unifiedEquipments;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
selectedEquipmentIds.clear();
} on Failure catch (e) {
_error = e.message;
} catch (e) {
_error = 'An unexpected error occurred: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
// 상태 필터 변경
Future<void> changeStatusFilter(String? status) async {
selectedStatusFilter = status;
await loadData(isRefresh: true);
}
// 검색어 변경
Future<void> updateSearchKeyword(String keyword) async {
searchKeyword = keyword;
await loadData(isRefresh: true, search: keyword);
}
// 장비 선택/해제 (모든 상태 지원)
void selectEquipment(int? id, String status, bool? isSelected) {
if (id == null || isSelected == null) return;
final key = '$id:$status';
if (isSelected) {
selectedEquipmentIds.add(key);
} else {
selectedEquipmentIds.remove(key);
}
notifyListeners();
}
// 선택된 입고 장비 수 반환
int getSelectedInStockCount() {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == EquipmentStatus.in_) {
count++;
}
}
return count;
}
// 선택된 전체 장비 수 반환
int getSelectedEquipmentCount() {
return selectedEquipmentIds.length;
}
// 선택된 특정 상태의 장비 수 반환
int getSelectedEquipmentCountByStatus(String status) {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
count++;
}
}
return count;
}
// 선택된 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipments() {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == parts[1],
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 특정 상태의 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipmentsByStatus(String status) {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == status,
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 장비들의 요약 정보를 Map 형태로 반환 (출고/대여/폐기 폼에서 사용)
List<Map<String, dynamic>> getSelectedEquipmentsSummary() {
List<Map<String, dynamic>> summaryList = [];
List<UnifiedEquipment> selectedEquipmentsInStock =
getSelectedEquipmentsByStatus(EquipmentStatus.in_);
for (final equipment in selectedEquipmentsInStock) {
summaryList.add({
'equipment': equipment.equipment,
'equipmentInId': equipment.id,
'status': equipment.status,
});
}
return summaryList;
}
// 출고 정보(회사, 담당자, 라이센스 등) 반환
// 출고 정보는 API를 통해 번별로 조회해야 하므로 별도 서비스로 분리 예정
String getOutEquipmentInfo(int equipmentId, String infoType) {
// TODO: API로 출고 정보 조회 구현
return '-';
}
// 장비 삭제
Future<bool> deleteEquipment(UnifiedEquipment equipment) async {
try {
// API를 통한 삭제
if (equipment.equipment.id != null) {
await _equipmentService.deleteEquipment(equipment.equipment.id!);
} else {
throw Exception('Equipment ID is null');
}
// 로컬 리스트에서도 제거
equipments.removeWhere((e) => e.id == equipment.id && e.status == equipment.status);
notifyListeners();
return true;
} on Failure catch (e) {
_error = e.message;
notifyListeners();
return false;
} catch (e) {
_error = 'Failed to delete equipment: $e';
notifyListeners();
return false;
}
}
// API만 사용하므로 토글 기능 제거
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
@override
void dispose() {
super.dispose();
}
}

View File

@@ -1,338 +1,315 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/models/equipment_unified_model.dart' as legacy;
import 'package:superport/core/utils/debug_logger.dart';
// companyTypeToString 함수 import
import 'package:superport/utils/constants.dart'
show companyTypeToString, CompanyType;
import 'package:superport/core/utils/error_handler.dart';
import 'package:superport/core/controllers/base_list_controller.dart';
import 'package:superport/core/utils/equipment_status_converter.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/core/utils/equipment_status_converter.dart';
import 'package:superport/data/models/common/pagination_params.dart';
// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentListController extends ChangeNotifier {
final MockDataService dataService;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
/// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
class EquipmentListController extends BaseListController<UnifiedEquipment> {
late final EquipmentService _equipmentService;
List<UnifiedEquipment> equipments = [];
String? selectedStatusFilter;
String searchKeyword = ''; // 검색어 추가
// 추가 상태 관리
final Set<String> selectedEquipmentIds = {}; // 'id:status' 형식
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag for API usage
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMore = true;
// 필터
String? _statusFilter;
String? _categoryFilter;
int? _companyIdFilter;
String? _selectedStatusFilter;
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasMore => _hasMore;
int get currentPage => _currentPage;
EquipmentListController({required this.dataService});
// 데이터 로드 및 상태 필터 적용
Future<void> loadData({bool isRefresh = false, String? search}) async {
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (_useApi) {
// API 호출 - 전체 데이터 로드
print('╔══════════════════════════════════════════════════════════');
print('║ 📦 장비 목록 API 호출 시작');
print('║ • 상태 필터: ${selectedStatusFilter ?? "전체"}');
print('║ • 검색어: ${search ?? searchKeyword}');
print('╚══════════════════════════════════════════════════════════');
// 전체 데이터를 가져오기 위해 큰 perPage 값 사용
final apiEquipmentDtos = await _equipmentService.getEquipmentsWithStatus(
page: 1,
perPage: 1000, // 충분히 큰 값으로 전체 데이터 로드
status: selectedStatusFilter != null ? EquipmentStatusConverter.clientToServer(selectedStatusFilter) : null,
search: search ?? searchKeyword,
);
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 장비 목록 로드 완료');
print('║ ▶ 총 장비 수: ${apiEquipmentDtos.length}');
print('╟──────────────────────────────────────────────────────────');
// 상태별 통계
Map<String, int> statusCount = {};
for (final dto in apiEquipmentDtos) {
final clientStatus = EquipmentStatusConverter.serverToClient(dto.status);
statusCount[clientStatus] = (statusCount[clientStatus] ?? 0) + 1;
}
statusCount.forEach((status, count) {
print('║ • $status: $count개');
});
print('╟──────────────────────────────────────────────────────────');
print('║ 📑 전체 데이터 로드 완료');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
// DTO를 UnifiedEquipment로 변환 (status 정보 포함)
final List<UnifiedEquipment> unifiedEquipments = apiEquipmentDtos.map((dto) {
final equipment = Equipment(
id: dto.id,
manufacturer: dto.manufacturer,
name: dto.modelName ?? dto.equipmentNumber,
category: '', // 세부 정보는 상세 조회에서 가져와야 함
subCategory: '',
subSubCategory: '',
serialNumber: dto.serialNumber,
quantity: 1,
inDate: dto.createdAt,
);
return UnifiedEquipment(
id: dto.id,
equipment: equipment,
date: dto.createdAt,
status: EquipmentStatusConverter.serverToClient(dto.status), // 서버 status를 클라이언트 status로 변환
);
}).toList();
equipments = unifiedEquipments;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
} else {
// Mock 데이터 사용
equipments = dataService.getAllEquipments();
if (selectedStatusFilter != null) {
equipments =
equipments.where((e) => e.status == selectedStatusFilter).toList();
}
_hasMore = false;
}
selectedEquipmentIds.clear();
} on Failure catch (e) {
_error = e.message;
} catch (e) {
_error = 'An unexpected error occurred: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
// 상태 필터 변경
Future<void> changeStatusFilter(String? status) async {
selectedStatusFilter = status;
await loadData(isRefresh: true);
}
List<UnifiedEquipment> get equipments => items;
String? get statusFilter => _statusFilter;
String? get categoryFilter => _categoryFilter;
int? get companyIdFilter => _companyIdFilter;
String? get selectedStatusFilter => _selectedStatusFilter;
// 검색어 변경
Future<void> updateSearchKeyword(String keyword) async {
searchKeyword = keyword;
await loadData(isRefresh: true, search: keyword);
// Setters
set selectedStatusFilter(String? value) {
_selectedStatusFilter = value;
notifyListeners();
}
// 장비 선택/해제 (모든 상태 지원)
void selectEquipment(int? id, String status, bool? isSelected) {
if (id == null || isSelected == null) return;
final key = '$id:$status';
if (isSelected) {
selectedEquipmentIds.add(key);
EquipmentListController() {
if (GetIt.instance.isRegistered<EquipmentService>()) {
_equipmentService = GetIt.instance<EquipmentService>();
} else {
selectedEquipmentIds.remove(key);
throw Exception('EquipmentService not registered in GetIt');
}
}
@override
Future<PagedResult<UnifiedEquipment>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// API 호출
final apiEquipmentDtos = await ErrorHandler.handleApiCall(
() => _equipmentService.getEquipmentsWithStatus(
page: params.page,
perPage: params.perPage,
status: _statusFilter != null ?
EquipmentStatusConverter.clientToServer(_statusFilter) : null,
search: params.search,
companyId: _companyIdFilter,
),
onError: (failure) {
throw failure;
},
);
if (apiEquipmentDtos == null) {
return PagedResult(
items: [],
meta: PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: 0,
totalPages: 0,
hasNext: false,
hasPrevious: false,
),
);
}
// DTO를 UnifiedEquipment로 변환
final items = apiEquipmentDtos.map((dto) {
final equipment = Equipment(
id: dto.id,
manufacturer: dto.manufacturer ?? 'Unknown',
name: dto.modelName ?? dto.equipmentNumber ?? 'Unknown',
category: 'Equipment', // 임시 카테고리
subCategory: 'General', // 임시 서브카테고리
subSubCategory: 'Standard', // 임시 서브서브카테고리
serialNumber: dto.serialNumber,
quantity: 1, // 기본 수량
);
// 간단한 Company 정보 생성 (사용하지 않으므로 제거)
// final company = dto.companyName != null ? ... : null;
return UnifiedEquipment(
id: dto.id,
equipment: equipment,
date: dto.createdAt ?? DateTime.now(),
status: EquipmentStatusConverter.serverToClient(dto.status),
notes: null, // EquipmentListDto에 remark 필드 없음
);
}).toList();
// 임시로 메타데이터 생성 (추후 API에서 실제 메타데이터 반환 시 수정)
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: items.length < params.perPage ?
(params.page - 1) * params.perPage + items.length :
params.page * params.perPage + 1,
totalPages: items.length < params.perPage ? params.page : params.page + 1,
hasNext: items.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: items, meta: meta);
}
@override
bool filterItem(UnifiedEquipment item, String query) {
final q = query.toLowerCase();
return (item.equipment.name.toLowerCase().contains(q)) ||
(item.equipment.serialNumber?.toLowerCase().contains(q) ?? false) ||
(item.equipment.manufacturer.toLowerCase().contains(q)) ||
(item.notes?.toLowerCase().contains(q) ?? false) ||
(item.status.toLowerCase().contains(q));
}
/// 장비 선택/선택 해제
void toggleSelection(UnifiedEquipment equipment) {
final equipmentKey = '${equipment.equipment.id}:${equipment.status}';
if (selectedEquipmentIds.contains(equipmentKey)) {
selectedEquipmentIds.remove(equipmentKey);
} else {
selectedEquipmentIds.add(equipmentKey);
}
notifyListeners();
}
// 선택된 입고 장비 수 반환
int getSelectedInStockCount() {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == EquipmentStatus.in_) {
count++;
}
}
return count;
/// 모든 선택 해제
void clearSelection() {
selectedEquipmentIds.clear();
notifyListeners();
}
// 선택된 전체 장비 수 반환
/// 선택된 장비 정보 가져오기
Map<String, List<UnifiedEquipment>> getSelectedEquipmentsByStatus() {
final Map<String, List<UnifiedEquipment>> groupedEquipments = {};
for (final equipment in items) {
final equipmentKey = '${equipment.equipment.id}:${equipment.status}';
if (selectedEquipmentIds.contains(equipmentKey)) {
if (!groupedEquipments.containsKey(equipment.status)) {
groupedEquipments[equipment.status] = [];
}
groupedEquipments[equipment.status]!.add(equipment);
}
}
return groupedEquipments;
}
/// 필터 설정
void setFilters({
String? status,
String? category,
int? companyId,
}) {
_statusFilter = status;
_categoryFilter = category;
_companyIdFilter = companyId;
loadData(isRefresh: true);
}
/// 상태 필터 변경
void filterByStatus(String? status) {
_statusFilter = status;
loadData(isRefresh: true);
}
/// 카테고리 필터 변경
void filterByCategory(String? category) {
_categoryFilter = category;
loadData(isRefresh: true);
}
/// 회사 필터 변경
void filterByCompany(int? companyId) {
_companyIdFilter = companyId;
loadData(isRefresh: true);
}
/// 필터 초기화
void clearFilters() {
_statusFilter = null;
_categoryFilter = null;
_companyIdFilter = null;
search('');
loadData(isRefresh: true);
}
/// 장비 삭제
Future<void> deleteEquipment(int id, String status) async {
await ErrorHandler.handleApiCall<void>(
() => _equipmentService.deleteEquipment(id),
);
removeItemLocally((e) => e.equipment.id == id && e.status == status);
// 선택 목록에서도 제거
final equipmentKey = '$id:$status';
selectedEquipmentIds.remove(equipmentKey);
}
/// 선택된 장비 일괄 삭제
Future<void> deleteSelectedEquipments() async {
final selectedGroups = getSelectedEquipmentsByStatus();
for (final entry in selectedGroups.entries) {
for (final equipment in entry.value) {
if (equipment.equipment.id != null) {
await deleteEquipment(equipment.equipment.id!, equipment.status);
}
}
}
clearSelection();
}
/// 장비 상태 변경 (임시 구현 - API가 지원하지 않음)
Future<void> updateEquipmentStatus(int id, String currentStatus, String newStatus) async {
debugPrint('장비 상태 변경: $id, $currentStatus -> $newStatus');
// TODO: 실제 API가 장비 상태 변경을 지원할 때 구현
// 현재는 새로고침만 수행
await refresh();
}
/// 장비 정보 수정
Future<void> updateEquipment(int id, UnifiedEquipment equipment) async {
await ErrorHandler.handleApiCall<void>(
() => _equipmentService.updateEquipment(id, equipment.equipment),
onError: (failure) {
throw failure;
},
);
updateItemLocally(equipment, (e) =>
e.equipment.id == equipment.equipment.id &&
e.status == equipment.status
);
}
/// 상태 필터 변경
void changeStatusFilter(String? status) {
_selectedStatusFilter = status;
_statusFilter = status;
notifyListeners();
}
/// 검색 키워드 업데이트
void updateSearchKeyword(String keyword) {
search(keyword); // BaseListController의 search 메서드 사용
}
/// 장비 선택 (토글 선택을 위한 별칭)
void selectEquipment(UnifiedEquipment equipment) {
toggleSelection(equipment);
}
/// 선택된 입고 상태 장비 개수
int getSelectedInStockCount() {
return selectedEquipmentIds
.where((key) => key.endsWith(':입고'))
.length;
}
/// 선택된 장비들 가져오기
List<UnifiedEquipment> getSelectedEquipments() {
return items.where((equipment) {
final equipmentKey = '${equipment.equipment.id}:${equipment.status}';
return selectedEquipmentIds.contains(equipmentKey);
}).toList();
}
/// 선택된 장비들 요약 정보
String getSelectedEquipmentsSummary() {
final selectedEquipments = getSelectedEquipments();
if (selectedEquipments.isEmpty) return '선택된 장비가 없습니다';
final Map<String, int> statusCounts = {};
for (final equipment in selectedEquipments) {
statusCounts[equipment.status] = (statusCounts[equipment.status] ?? 0) + 1;
}
final summaryParts = statusCounts.entries
.map((entry) => '${entry.key}: ${entry.value}')
.toList();
return summaryParts.join(', ');
}
/// 선택된 장비 총 개수
int getSelectedEquipmentCount() {
return selectedEquipmentIds.length;
}
// 선택된 특정 상태의 장비 수 반환
/// 특정 상태의 선택된 장비 개수
int getSelectedEquipmentCountByStatus(String status) {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
count++;
}
}
return count;
return selectedEquipmentIds
.where((key) => key.endsWith(':$status'))
.length;
}
// 선택된 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipments() {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == parts[1],
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 특정 상태의 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipmentsByStatus(String status) {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == status,
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 장비들의 요약 정보를 Map 형태로 반환 (출고/대여/폐기 폼에서 사용)
List<Map<String, dynamic>> getSelectedEquipmentsSummary() {
List<Map<String, dynamic>> summaryList = [];
List<UnifiedEquipment> selectedEquipmentsInStock =
getSelectedEquipmentsByStatus(EquipmentStatus.in_);
for (final equipment in selectedEquipmentsInStock) {
summaryList.add({
'equipment': equipment.equipment,
'equipmentInId': equipment.id,
'status': equipment.status,
});
}
return summaryList;
}
// 출고 정보(회사, 담당자, 라이센스 등) 반환
String getOutEquipmentInfo(int equipmentId, String infoType) {
final equipmentOut = dataService.getEquipmentOutById(equipmentId);
if (equipmentOut != null) {
switch (infoType) {
case 'company':
final company = equipmentOut.company ?? '-';
if (company != '-') {
final companyObj = dataService.getAllCompanies().firstWhere(
(c) => c.name == company,
orElse:
() => Company(
name: company,
address: Address(),
companyTypes: [CompanyType.customer], // 기본값 고객사
),
);
// 여러 유형 중 첫 번째만 표시 (대표 유형)
final typeText =
companyObj.companyTypes.isNotEmpty
? companyTypeToString(companyObj.companyTypes.first)
: '-';
return '$company (${typeText})';
}
return company;
case 'manager':
return equipmentOut.manager ?? '-';
case 'license':
return equipmentOut.license ?? '-';
default:
return '-';
}
}
return '-';
}
// 장비 삭제
Future<bool> deleteEquipment(UnifiedEquipment equipment) async {
try {
if (_useApi) {
// API를 통한 삭제
if (equipment.equipment.id != null) {
await _equipmentService.deleteEquipment(equipment.equipment.id!);
} else {
throw Exception('Equipment ID is null');
}
} else {
// Mock 데이터 삭제
if (equipment.status == EquipmentStatus.in_) {
dataService.deleteEquipmentIn(equipment.id!);
} else if (equipment.status == EquipmentStatus.out) {
dataService.deleteEquipmentOut(equipment.id!);
} else if (equipment.status == EquipmentStatus.rent) {
// TODO: 대여 상태 삭제 구현
throw UnimplementedError('Rent status deletion not implemented');
}
}
// 로컬 리스트에서도 제거
equipments.removeWhere((e) => e.id == equipment.id && e.status == equipment.status);
notifyListeners();
return true;
} on Failure catch (e) {
_error = e.message;
notifyListeners();
return false;
} catch (e) {
_error = 'Failed to delete equipment: $e';
notifyListeners();
return false;
}
}
// API 사용 여부 토글 (테스트용)
void toggleApiUsage() {
_useApi = !_useApi;
loadData(isRefresh: true);
}
// 에러 처리
void clearError() {
_error = null;
notifyListeners();
}
@override
void dispose() {
super.dispose();
}
}
}

View File

@@ -5,7 +5,6 @@ import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/company_branch_info.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/utils/constants.dart';
@@ -14,7 +13,8 @@ import 'package:superport/utils/constants.dart';
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentOutFormController extends ChangeNotifier {
final MockDataService dataService;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
final CompanyService _companyService = GetIt.instance<CompanyService>();
int? equipmentOutId;
// 편집 모드 여부
@@ -62,7 +62,6 @@ class EquipmentOutFormController extends ChangeNotifier {
final TextEditingController remarkController = TextEditingController();
EquipmentOutFormController({
required this.dataService,
this.equipmentOutId,
}) {
isEditMode = equipmentOutId != null;
@@ -77,22 +76,32 @@ class EquipmentOutFormController extends ChangeNotifier {
}
// 드롭다운 데이터 로드
void loadDropdownData() {
// 회사 목록 로드 (출고처 가능한 회사만)
companies = dataService.getAllCompanies()
.where((c) => c.companyTypes.contains(CompanyType.customer))
.map((c) => CompanyBranchInfo(
id: c.id,
name: c.name,
originalName: c.name,
isMainCompany: true,
companyId: c.id,
Future<void> loadDropdownData() async {
try {
// API를 통해 회사 목록 로드
final allCompanies = await _companyService.getCompanies();
companies = allCompanies
.where((c) => c.companyTypes.contains(CompanyType.customer))
.map((c) => CompanyBranchInfo(
id: c.id,
name: c.name,
originalName: c.name,
isMainCompany: true,
companyId: c.id,
branchId: null,
))
.toList();
// 라이선스 목록 로드
licenses = dataService.getAllLicenses().map((l) => l.name).toList();
// TODO: 라이선스 목록도 API로 로드
licenses = []; // 임시로 빈 목록
notifyListeners();
} catch (e) {
debugPrint('드롭다운 데이터 로드 실패: $e');
companies = [];
licenses = [];
notifyListeners();
}
}
// 선택된 장비로 초기화
@@ -109,23 +118,10 @@ class EquipmentOutFormController extends ChangeNotifier {
return;
}
// Mock 데이터에서 회사별 담당자 목록 가져오기
final company = dataService.getAllCompanies().firstWhere(
(c) => c.name == selectedCompanies[index],
orElse: () => Company(
name: '',
companyTypes: [],
),
);
if (company.name.isNotEmpty && company.contactName != null && company.contactName!.isNotEmpty) {
// 회사의 담당자 정보
hasManagersPerCompany[index] = true;
filteredManagersPerCompany[index] = [company.contactName!];
} else {
hasManagersPerCompany[index] = false;
filteredManagersPerCompany[index] = ['없음'];
}
// TODO: API를 통해 회사별 담당자 목록 로드
// 현재는 임시로 빈 목록 사용
hasManagersPerCompany[index] = false;
filteredManagersPerCompany[index] = [];
notifyListeners();
}

View File

@@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
// import 'package:superport/screens/common/custom_widgets.dart' hide FormFieldWrapper;
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
// import 'package:flutter_localizations/flutter_localizations.dart';
// import 'package:superport/screens/equipment/widgets/autocomplete_text_field.dart';
@@ -181,7 +180,6 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
void initState() {
super.initState();
_controller = EquipmentInFormController(
dataService: MockDataService(),
equipmentInId: widget.equipmentInId,
);

View File

@@ -0,0 +1,484 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:provider/provider.dart';
import '../../services/lookup_service.dart';
import '../../data/models/lookups/lookup_data.dart';
import '../common/theme_shadcn.dart';
import '../common/components/shadcn_components.dart';
/// LookupService를 활용한 장비 입고 폼 예시
/// 전역 캐싱된 Lookup 데이터를 활용하여 드롭다운 구성
class EquipmentInFormLookupExample extends StatefulWidget {
const EquipmentInFormLookupExample({super.key});
@override
State<EquipmentInFormLookupExample> createState() => _EquipmentInFormLookupExampleState();
}
class _EquipmentInFormLookupExampleState extends State<EquipmentInFormLookupExample> {
late final LookupService _lookupService;
// 선택된 값들
String? _selectedEquipmentType;
String? _selectedEquipmentStatus;
String? _selectedManufacturer;
String? _selectedLicenseType;
// 텍스트 컨트롤러
final _serialNumberController = TextEditingController();
final _quantityController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void initState() {
super.initState();
_lookupService = GetIt.instance<LookupService>();
_loadLookupDataIfNeeded();
}
/// 필요시 Lookup 데이터 로드 (캐시가 없을 경우)
Future<void> _loadLookupDataIfNeeded() async {
if (!_lookupService.hasData) {
await _lookupService.loadAllLookups();
if (mounted) {
setState(() {}); // UI 업데이트
}
}
}
@override
void dispose() {
_serialNumberController.dispose();
_quantityController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ShadcnTheme.background,
appBar: AppBar(
title: const Text('장비 입고 (Lookup 활용 예시)'),
backgroundColor: ShadcnTheme.card,
elevation: 0,
),
body: ChangeNotifierProvider.value(
value: _lookupService,
child: Consumer<LookupService>(
builder: (context, lookupService, child) {
if (lookupService.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (lookupService.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline,
size: 64,
color: ShadcnTheme.destructive,
),
const SizedBox(height: 16),
Text(
'Lookup 데이터 로드 실패',
style: ShadcnTheme.headingH4,
),
const SizedBox(height: 8),
Text(
lookupService.error!,
style: ShadcnTheme.bodyMuted,
),
const SizedBox(height: 16),
ShadcnButton(
text: '다시 시도',
onPressed: () => lookupService.loadAllLookups(forceRefresh: true),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 800),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 안내 메시지
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ShadcnTheme.primary.withValues(alpha: 0.1),
border: Border.all(
color: ShadcnTheme.primary.withValues(alpha: 0.3),
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.info_outline,
color: ShadcnTheme.primary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'이 화면은 /lookups API를 통해 캐싱된 전역 데이터를 활용합니다.\n'
'드롭다운 데이터는 앱 시작 시 한 번만 로드되어 모든 화면에서 재사용됩니다.',
style: ShadcnTheme.bodySmall,
),
),
],
),
),
const SizedBox(height: 24),
// 폼 카드
ShadcnCard(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('장비 정보', style: ShadcnTheme.headingH4),
const SizedBox(height: 24),
// 장비 타입 드롭다운
_buildDropdownField(
label: '장비 타입',
value: _selectedEquipmentType,
items: lookupService.equipmentTypes,
onChanged: (value) {
setState(() {
_selectedEquipmentType = value;
});
},
),
// 장비 상태 드롭다운
_buildDropdownField(
label: '장비 상태',
value: _selectedEquipmentStatus,
items: lookupService.equipmentStatuses,
onChanged: (value) {
setState(() {
_selectedEquipmentStatus = value;
});
},
),
// 제조사 드롭다운
_buildDropdownField(
label: '제조사',
value: _selectedManufacturer,
items: lookupService.manufacturers,
onChanged: (value) {
setState(() {
_selectedManufacturer = value;
});
},
),
// 시리얼 번호 입력
_buildTextField(
label: '시리얼 번호',
controller: _serialNumberController,
hintText: 'SN-2025-001',
),
// 수량 입력
_buildTextField(
label: '수량',
controller: _quantityController,
hintText: '1',
keyboardType: TextInputType.number,
),
// 라이선스 타입 드롭다운 (옵션)
_buildDropdownField(
label: '라이선스 타입 (선택)',
value: _selectedLicenseType,
items: lookupService.licenseTypes,
onChanged: (value) {
setState(() {
_selectedLicenseType = value;
});
},
isOptional: true,
),
// 비고 입력
_buildTextField(
label: '비고',
controller: _descriptionController,
hintText: '추가 정보를 입력하세요',
maxLines: 3,
),
const SizedBox(height: 32),
// 버튼 그룹
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadcnButton(
text: '취소',
variant: ShadcnButtonVariant.secondary,
onPressed: () => Navigator.pop(context),
),
const SizedBox(width: 12),
ShadcnButton(
text: '저장',
onPressed: _handleSubmit,
),
],
),
],
),
),
const SizedBox(height: 24),
// 캐시 정보 표시
_buildCacheInfoCard(lookupService),
],
),
),
),
);
},
),
),
);
}
/// 드롭다운 필드 빌더
Widget _buildDropdownField({
required String label,
required String? value,
required List<LookupItem> items,
required ValueChanged<String?> onChanged,
bool isOptional = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label, style: ShadcnTheme.bodyMedium),
if (isOptional) ...[
const SizedBox(width: 4),
Text('(선택)', style: ShadcnTheme.bodyMuted),
],
],
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(6),
),
child: DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
border: InputBorder.none,
hintText: '선택하세요',
hintStyle: ShadcnTheme.bodyMuted,
),
items: items.map((item) => DropdownMenuItem(
value: item.code ?? '',
child: Text(item.name ?? ''),
)).toList(),
onChanged: onChanged,
),
),
const SizedBox(height: 16),
],
);
}
/// 텍스트 필드 빌더
Widget _buildTextField({
required String label,
required TextEditingController controller,
String? hintText,
TextInputType? keyboardType,
int maxLines = 1,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: ShadcnTheme.bodyMedium),
const SizedBox(height: 8),
TextFormField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
decoration: InputDecoration(
hintText: hintText,
hintStyle: ShadcnTheme.bodyMuted,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: ShadcnTheme.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: ShadcnTheme.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: ShadcnTheme.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
const SizedBox(height: 16),
],
);
}
/// 캐시 정보 카드
Widget _buildCacheInfoCard(LookupService lookupService) {
return ShadcnCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.storage, size: 20, color: ShadcnTheme.muted),
const SizedBox(width: 8),
Text('Lookup 캐시 정보', style: ShadcnTheme.bodyMedium),
],
),
const SizedBox(height: 12),
_buildCacheItem('장비 타입', lookupService.equipmentTypes.length),
_buildCacheItem('장비 상태', lookupService.equipmentStatuses.length),
_buildCacheItem('제조사', lookupService.manufacturers.length),
_buildCacheItem('라이선스 타입', lookupService.licenseTypes.length),
_buildCacheItem('사용자 역할', lookupService.userRoles.length),
_buildCacheItem('회사 상태', lookupService.companyStatuses.length),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'캐시 상태: ${lookupService.isCacheValid ? "유효" : "만료"}',
style: ShadcnTheme.bodySmall.copyWith(
color: lookupService.isCacheValid
? ShadcnTheme.success
: ShadcnTheme.warning,
),
),
TextButton(
onPressed: () => lookupService.loadAllLookups(forceRefresh: true),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 16, color: ShadcnTheme.primary),
const SizedBox(width: 4),
Text('캐시 새로고침',
style: TextStyle(color: ShadcnTheme.primary),
),
],
),
),
],
),
],
),
);
}
Widget _buildCacheItem(String label, int count) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: ShadcnTheme.bodySmall),
Text('$count개',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
),
),
],
),
);
}
/// 폼 제출 처리
void _handleSubmit() {
// 유효성 검증
if (_selectedEquipmentType == null) {
_showSnackBar('장비 타입을 선택하세요', isError: true);
return;
}
if (_selectedEquipmentStatus == null) {
_showSnackBar('장비 상태를 선택하세요', isError: true);
return;
}
if (_serialNumberController.text.isEmpty) {
_showSnackBar('시리얼 번호를 입력하세요', isError: true);
return;
}
// 선택된 값 정보 표시
final selectedType = _lookupService.findByCode(
_lookupService.equipmentTypes,
_selectedEquipmentType!,
);
final selectedStatus = _lookupService.findByCode(
_lookupService.equipmentStatuses,
_selectedEquipmentStatus!,
);
final message = '''
장비 입고 정보:
- 타입: ${selectedType?.name ?? _selectedEquipmentType}
- 상태: ${selectedStatus?.name ?? _selectedEquipmentStatus}
- 시리얼: ${_serialNumberController.text}
- 수량: ${_quantityController.text.isEmpty ? "1" : _quantityController.text}
''';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('입고 정보 확인'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('확인'),
),
],
),
);
}
void _showSnackBar(String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? ShadcnTheme.destructive : ShadcnTheme.primary,
duration: const Duration(seconds: 2),
),
);
}
}

View File

@@ -9,7 +9,6 @@ import 'package:superport/screens/common/widgets/standard_data_table.dart' as st
import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/utils/equipment_display_helper.dart';
@@ -42,7 +41,7 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
@override
void initState() {
super.initState();
_controller = EquipmentListController(dataService: MockDataService());
_controller = EquipmentListController();
_setInitialFilter();
// API 호출을 위해 Future로 변경
@@ -116,7 +115,7 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
}
_currentPage = 1;
});
await _controller.changeStatusFilter(_controller.selectedStatusFilter);
_controller.changeStatusFilter(_controller.selectedStatusFilter);
}
/// 검색 실행
@@ -125,13 +124,26 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
_appliedSearchKeyword = _searchController.text;
_currentPage = 1;
});
await _controller.updateSearchKeyword(_searchController.text);
_controller.updateSearchKeyword(_searchController.text);
}
/// 장비 선택/해제
void _onEquipmentSelected(int? id, String status, bool? isSelected) {
if (id == null) return;
// UnifiedEquipment를 찾아서 선택/해제
UnifiedEquipment? equipment;
try {
equipment = _controller.items.firstWhere(
(e) => e.equipment.id == id && e.status == status,
);
} catch (e) {
// 해당하는 장비를 찾지 못함
return;
}
setState(() {
_controller.selectEquipment(id, status, isSelected);
_controller.selectEquipment(equipment!);
});
}
@@ -140,7 +152,7 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
setState(() {
final equipments = _getFilteredEquipments();
for (final equipment in equipments) {
_controller.selectEquipment(equipment.id, equipment.status, value);
_controller.selectEquipment(equipment);
}
});
}
@@ -234,7 +246,7 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
return;
}
final selectedEquipmentsSummary = _controller.getSelectedEquipmentsSummary();
final selectedEquipments = _controller.getSelectedEquipments();
showDialog(
context: context,
@@ -245,12 +257,12 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('선택한 ${selectedEquipmentsSummary.length}개 장비를 폐기하시겠습니까?'),
Text('선택한 ${selectedEquipments.length}개 장비를 폐기하시겠습니까?'),
const SizedBox(height: 16),
const Text('폐기할 장비 목록:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...selectedEquipmentsSummary.map((equipmentData) {
final equipment = equipmentData['equipment'] as Equipment;
...selectedEquipments.map((unifiedEquipment) {
final equipment = unifiedEquipment.equipment;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
@@ -328,26 +340,15 @@ class _EquipmentListRedesignState extends State<EquipmentListRedesign> {
);
// Controller를 통한 삭제 처리
final success = await _controller.deleteEquipment(equipment);
await _controller.deleteEquipment(equipment.equipment.id!, equipment.status);
// 로딩 다이얼로그 닫기
if (mounted) Navigator.pop(context);
if (success) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('장비가 삭제되었습니다.')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error ?? '삭제 중 오류가 발생했습니다.'),
backgroundColor: Colors.red,
),
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('장비가 삭제되었습니다.')),
);
}
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),

View File

@@ -7,7 +7,6 @@ import 'package:superport/models/company_branch_info.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/equipment/controllers/equipment_out_form_controller.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_card.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart';
@@ -37,7 +36,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
@override
void initState() {
super.initState();
_controller = EquipmentOutFormController(dataService: MockDataService());
_controller = EquipmentOutFormController();
_controller.isEditMode = widget.equipmentOutId != null;
_controller.equipmentOutId = widget.equipmentOutId;
_controller.selectedEquipment = widget.selectedEquipment;
@@ -550,9 +549,9 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
Branch? branch;
if (companyInfo.companyId != null) {
company = controller.dataService.getCompanyById(
companyInfo.companyId!,
);
// TODO: 실제 CompanyService를 통해 회사 정보 가져오기
// company = await _companyService.getCompanyById(companyInfo.companyId!);
company = null; // 임시로 null 처리
if (!companyInfo.isMainCompany &&
companyInfo.branchId != null &&
company != null) {

View File

@@ -2,13 +2,10 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/license_model.dart';
import 'package:superport/services/license_service.dart';
import 'package:superport/services/mock_data_service.dart';
// 라이센스 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class LicenseFormController extends ChangeNotifier {
final bool useApi;
final MockDataService? mockDataService;
late final LicenseService _licenseService;
final LicenseService _licenseService = GetIt.instance<LicenseService>();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
bool _isEditMode = false;
@@ -59,15 +56,9 @@ class LicenseFormController extends ChangeNotifier {
}
LicenseFormController({
this.useApi = false,
MockDataService? dataService,
int? licenseId,
bool isExtension = false,
}) : mockDataService = dataService ?? MockDataService() {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
_licenseService = GetIt.instance<LicenseService>();
}
}) {
if (licenseId != null && !isExtension) {
_licenseId = licenseId;
_isEditMode = true;
@@ -122,13 +113,8 @@ class LicenseFormController extends ChangeNotifier {
notifyListeners();
try {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
debugPrint('📝 API에서 라이센스 로드 중...');
_originalLicense = await _licenseService.getLicenseById(_licenseId!);
} else {
debugPrint('📝 Mock에서 라이센스 로드 중...');
_originalLicense = mockDataService?.getLicenseById(_licenseId!);
}
debugPrint('📝 API에서 라이센스 로드 중...');
_originalLicense = await _licenseService.getLicenseById(_licenseId!);
debugPrint('📝 로드된 라이센스: $_originalLicense');
@@ -182,14 +168,8 @@ class LicenseFormController extends ChangeNotifier {
notifyListeners();
try {
License? sourceLicense;
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
debugPrint('📝 API에서 라이센스 로드 중 (연장용)...');
sourceLicense = await _licenseService.getLicenseById(_licenseId!);
} else {
debugPrint('📝 Mock에서 라이센스 로드 중 (연장용)...');
sourceLicense = mockDataService?.getLicenseById(_licenseId!);
}
debugPrint('📝 API에서 라이센스 로드 중 (연장용)...');
final sourceLicense = await _licenseService.getLicenseById(_licenseId!);
debugPrint('📝 로드된 소스 라이센스: $sourceLicense');
@@ -263,18 +243,10 @@ class LicenseFormController extends ChangeNotifier {
remark: '${_durationMonths}개월,${_visitCycle},방문',
);
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
if (_isEditMode) {
await _licenseService.updateLicense(license);
} else {
await _licenseService.createLicense(license);
}
if (_isEditMode) {
await _licenseService.updateLicense(license);
} else {
if (_isEditMode) {
mockDataService?.updateLicense(license);
} else {
mockDataService?.addLicense(license);
}
await _licenseService.createLicense(license);
}
return true;

View File

@@ -0,0 +1,467 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/models/license_model.dart';
import 'package:superport/services/license_service.dart';
// 라이센스 상태 필터
enum LicenseStatusFilter {
all,
active,
inactive,
expiringSoon, // 30일 이내
expired,
}
// 라이센스 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class LicenseListController extends ChangeNotifier {
final LicenseService _licenseService = GetIt.instance<LicenseService>();
List<License> _licenses = [];
List<License> _filteredLicenses = [];
bool _isLoading = false;
String? _error;
String _searchQuery = '';
int _currentPage = 1;
final int _pageSize = 20;
bool _hasMore = true;
int _total = 0;
// 필터 옵션
int? _selectedCompanyId;
bool? _isActive;
String? _licenseType;
LicenseStatusFilter _statusFilter = LicenseStatusFilter.all;
String _sortBy = 'expiry_date';
String _sortOrder = 'asc';
// 선택된 라이선스 관리
final Set<int> _selectedLicenseIds = {};
// 통계 데이터
Map<String, int> _statistics = {
'total': 0,
'active': 0,
'inactive': 0,
'expiringSoon': 0,
'expired': 0,
};
// 검색 디바운스를 위한 타이머
Timer? _debounceTimer;
LicenseListController();
// Getters
List<License> get licenses => _filteredLicenses;
bool get isLoading => _isLoading;
String? get error => _error;
String get searchQuery => _searchQuery;
int get currentPage => _currentPage;
bool get hasMore => _hasMore;
int get total => _total;
int? get selectedCompanyId => _selectedCompanyId;
bool? get isActive => _isActive;
String? get licenseType => _licenseType;
LicenseStatusFilter get statusFilter => _statusFilter;
Set<int> get selectedLicenseIds => _selectedLicenseIds;
Map<String, int> get statistics => _statistics;
// 선택된 라이선스 개수
int get selectedCount => _selectedLicenseIds.length;
// 전체 선택 여부 확인
bool get isAllSelected =>
_filteredLicenses.isNotEmpty &&
_filteredLicenses.where((l) => l.id != null)
.every((l) => _selectedLicenseIds.contains(l.id));
// 데이터 로드
Future<void> loadData({bool isInitialLoad = true}) async {
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
// API 사용 - 전체 데이터 로드
print('╔══════════════════════════════════════════════════════════');
print('║ 🔧 유지보수 목록 API 호출 시작');
print('║ • 회사 필터: ${_selectedCompanyId ?? "전체"}');
print('║ • 활성 필터: ${_isActive != null ? (_isActive! ? "활성" : "비활성") : "전체"}');
print('║ • 라이센스 타입: ${_licenseType ?? "전체"}');
print('╚══════════════════════════════════════════════════════════');
// 전체 데이터를 가져오기 위해 큰 perPage 값 사용
final fetchedLicenses = await _licenseService.getLicenses(
page: 1,
perPage: 1000, // 충분히 큰 값으로 전체 데이터 로드
isActive: _isActive,
companyId: _selectedCompanyId,
licenseType: _licenseType,
);
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 유지보수 목록 로드 완료');
print('║ ▶ 총 라이센스 수: ${fetchedLicenses.length}');
print('╟──────────────────────────────────────────────────────────');
// 상태별 통계
int activeCount = 0;
int expiringSoonCount = 0;
int expiredCount = 0;
final now = DateTime.now();
for (final license in fetchedLicenses) {
if (license.expiryDate != null) {
final daysUntil = license.expiryDate!.difference(now).inDays;
if (daysUntil < 0) {
expiredCount++;
} else if (daysUntil <= 30) {
expiringSoonCount++;
} else {
activeCount++;
}
} else {
activeCount++;
}
}
print('║ • 활성: $activeCount개');
print('║ • 만료 임박 (30일 이내): $expiringSoonCount개');
print('║ • 만료됨: $expiredCount개');
print('╟──────────────────────────────────────────────────────────');
print('║ 📑 전체 데이터 로드 완료');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
_licenses = fetchedLicenses;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
_total = fetchedLicenses.length;
debugPrint('📑 _applySearchFilter 호출 전: _licenses=${_licenses.length}');
_applySearchFilter();
_applyStatusFilter();
await _updateStatistics();
debugPrint('📑 _applySearchFilter 호출 후: _filteredLicenses=${_filteredLicenses.length}');
} catch (e) {
debugPrint('❌ loadData 에러 발생: $e');
_error = e.toString();
} finally {
_isLoading = false;
debugPrint('📑 loadData 종료: _filteredLicenses=${_filteredLicenses.length}');
notifyListeners();
}
}
// 다음 페이지 로드
Future<void> loadNextPage() async {
if (!_hasMore || _isLoading) return;
_currentPage++;
await loadData(isInitialLoad: false);
}
// 검색 (디바운싱 적용)
void search(String query) {
_searchQuery = query;
// 기존 타이머 취소
_debounceTimer?.cancel();
// API 검색은 디바운싱 적용 (300ms)
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
loadData();
});
}
// 검색 필터 적용
void _applySearchFilter() {
debugPrint('🔎 _applySearchFilter 시작: _searchQuery="$_searchQuery", _licenses=${_licenses.length}');
if (_searchQuery.isEmpty) {
_filteredLicenses = List.from(_licenses);
debugPrint('🔎 검색어 없음: 전체 복사 ${_filteredLicenses.length}');
} else {
_filteredLicenses = _licenses.where((license) {
final productName = license.productName?.toLowerCase() ?? '';
final licenseKey = license.licenseKey.toLowerCase();
final vendor = license.vendor?.toLowerCase() ?? '';
final companyName = license.companyName?.toLowerCase() ?? '';
final searchLower = _searchQuery.toLowerCase();
return productName.contains(searchLower) ||
licenseKey.contains(searchLower) ||
vendor.contains(searchLower) ||
companyName.contains(searchLower);
}).toList();
debugPrint('🔎 검색 필터링 완료: ${_filteredLicenses.length}');
}
}
// 상태 필터 적용
void _applyStatusFilter() {
if (_statusFilter == LicenseStatusFilter.all) return;
final now = DateTime.now();
_filteredLicenses = _filteredLicenses.where((license) {
switch (_statusFilter) {
case LicenseStatusFilter.active:
return license.isActive;
case LicenseStatusFilter.inactive:
return !license.isActive;
case LicenseStatusFilter.expiringSoon:
if (license.expiryDate != null) {
final days = license.expiryDate!.difference(now).inDays;
return days > 0 && days <= 30;
}
return false;
case LicenseStatusFilter.expired:
if (license.expiryDate != null) {
return license.expiryDate!.isBefore(now);
}
return false;
case LicenseStatusFilter.all:
default:
return true;
}
}).toList();
}
// 필터 설정
void setFilters({
int? companyId,
bool? isActive,
String? licenseType,
}) {
_selectedCompanyId = companyId;
_isActive = isActive;
_licenseType = licenseType;
loadData();
}
// 필터 초기화
void clearFilters() {
_selectedCompanyId = null;
_isActive = null;
_licenseType = null;
_searchQuery = '';
loadData();
}
// 라이센스 삭제
Future<void> deleteLicense(int id) async {
try {
await _licenseService.deleteLicense(id);
// 목록에서 제거
_licenses.removeWhere((l) => l.id == id);
_applySearchFilter();
_total--;
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
// 새로고침
Future<void> refresh() async {
await loadData();
}
// 만료 예정 라이선스 조회
Future<List<License>> getExpiringLicenses({int days = 30}) async {
try {
return await _licenseService.getExpiringLicenses(days: days);
} catch (e) {
_error = e.toString();
notifyListeners();
return [];
}
}
// 상태별 라이선스 개수 조회
Future<Map<String, int>> getLicenseStatusCounts() async {
try {
// API에서 상태별 개수 조회 (실제로는 별도 엔드포인트가 있다면 사용)
final activeCount = await _licenseService.getTotalLicenses(isActive: true);
final inactiveCount = await _licenseService.getTotalLicenses(isActive: false);
final expiringLicenses = await getExpiringLicenses(days: 30);
return {
'active': activeCount,
'inactive': inactiveCount,
'expiring': expiringLicenses.length,
'total': activeCount + inactiveCount,
};
} catch (e) {
return {'active': 0, 'inactive': 0, 'expiring': 0, 'total': 0};
}
}
// 정렬 변경
void sortBy(String field, String order) {
_sortBy = field;
_sortOrder = order;
loadData();
}
// 상태 필터 변경
Future<void> changeStatusFilter(LicenseStatusFilter filter) async {
_statusFilter = filter;
await loadData();
}
// 라이선스 선택/해제
void selectLicense(int? id, bool? isSelected) {
if (id == null) return;
if (isSelected == true) {
_selectedLicenseIds.add(id);
} else {
_selectedLicenseIds.remove(id);
}
notifyListeners();
}
// 전체 선택/해제
void selectAll(bool? isSelected) {
if (isSelected == true) {
// 현재 필터링된 라이선스 모두 선택
for (var license in _filteredLicenses) {
if (license.id != null) {
_selectedLicenseIds.add(license.id!);
}
}
} else {
// 모두 해제
_selectedLicenseIds.clear();
}
notifyListeners();
}
// 선택된 라이선스 목록 반환
List<License> getSelectedLicenses() {
return _filteredLicenses
.where((l) => l.id != null && _selectedLicenseIds.contains(l.id))
.toList();
}
// 선택 초기화
void clearSelection() {
_selectedLicenseIds.clear();
notifyListeners();
}
// 라이선스 할당
Future<bool> assignLicense(int licenseId, int userId) async {
try {
await _licenseService.assignLicense(licenseId, userId);
await loadData();
clearSelection();
return true;
} catch (e) {
_error = e.toString();
notifyListeners();
return false;
}
}
// 라이선스 할당 해제
Future<bool> unassignLicense(int licenseId) async {
try {
await _licenseService.unassignLicense(licenseId);
await loadData();
clearSelection();
return true;
} catch (e) {
_error = e.toString();
notifyListeners();
return false;
}
}
// 선택된 라이선스 일괄 삭제
Future<void> deleteSelectedLicenses() async {
if (_selectedLicenseIds.isEmpty) return;
final selectedIds = List<int>.from(_selectedLicenseIds);
int successCount = 0;
int failCount = 0;
for (var id in selectedIds) {
try {
await deleteLicense(id);
successCount++;
} catch (e) {
failCount++;
debugPrint('라이선스 $id 삭제 실패: $e');
}
}
_selectedLicenseIds.clear();
await loadData();
if (successCount > 0) {
debugPrint('$successCount개 라이선스 삭제 완료');
}
if (failCount > 0) {
debugPrint('$failCount개 라이선스 삭제 실패');
}
}
// 통계 업데이트
Future<void> _updateStatistics() async {
try {
final counts = await getLicenseStatusCounts();
final now = DateTime.now();
int expiringSoonCount = 0;
int expiredCount = 0;
for (var license in _licenses) {
if (license.expiryDate != null) {
final days = license.expiryDate!.difference(now).inDays;
if (days <= 0) {
expiredCount++;
} else if (days <= 30) {
expiringSoonCount++;
}
}
}
_statistics = {
'total': counts['total'] ?? 0,
'active': counts['active'] ?? 0,
'inactive': counts['inactive'] ?? 0,
'expiringSoon': expiringSoonCount,
'expired': expiredCount,
};
} catch (e) {
debugPrint('❌ 통계 업데이트 오류: $e');
// 오류 발생 시 기본값 사용
_statistics = {
'total': _licenses.length,
'active': 0,
'inactive': 0,
'expiringSoon': 0,
'expired': 0,
};
}
}
// 만료일까지 남은 일수 계산
int? getDaysUntilExpiry(License license) {
if (license.expiryDate == null) return null;
return license.expiryDate!.difference(DateTime.now()).inDays;
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}

View File

@@ -1,37 +1,28 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/core/controllers/base_list_controller.dart';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/core/utils/error_handler.dart';
import 'package:superport/models/license_model.dart';
import 'package:superport/services/license_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/data/models/common/pagination_params.dart';
// 라이센스 상태 필터
/// 라이센스 상태 필터
enum LicenseStatusFilter {
all,
active,
inactive,
expiringSoon, // 30일 이내
expiringSoon, // ${AppConstants.licenseExpiryWarningDays}일 이내
expired,
}
// 라이센스 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class LicenseListController extends ChangeNotifier {
final bool useApi;
final MockDataService? mockDataService;
/// 라이센스 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
class LicenseListController extends BaseListController<License> {
late final LicenseService _licenseService;
List<License> _licenses = [];
List<License> _filteredLicenses = [];
bool _isLoading = false;
String? _error;
String _searchQuery = '';
int _currentPage = 1;
final int _pageSize = 20;
bool _hasMore = true;
int _total = 0;
// 필터 옵션
// 라이선스 특화 필터 상태
int? _selectedCompanyId;
bool? _isActive;
String? _licenseType;
@@ -54,207 +45,112 @@ class LicenseListController extends ChangeNotifier {
// 검색 디바운스를 위한 타이머
Timer? _debounceTimer;
LicenseListController({this.useApi = false, this.mockDataService}) {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
_licenseService = GetIt.instance<LicenseService>();
}
}
// Getters
List<License> get licenses => _filteredLicenses;
bool get isLoading => _isLoading;
String? get error => _error;
String get searchQuery => _searchQuery;
int get currentPage => _currentPage;
bool get hasMore => _hasMore;
int get total => _total;
// Getters for license-specific properties
List<License> get licenses => items;
int? get selectedCompanyId => _selectedCompanyId;
bool? get isActive => _isActive;
String? get licenseType => _licenseType;
LicenseStatusFilter get statusFilter => _statusFilter;
Set<int> get selectedLicenseIds => _selectedLicenseIds;
Map<String, int> get statistics => _statistics;
// 선택된 라이선스 개수
int get selectedCount => _selectedLicenseIds.length;
// 전체 선택 여부 확인
bool get isAllSelected =>
_filteredLicenses.isNotEmpty &&
_filteredLicenses.where((l) => l.id != null)
items.isNotEmpty &&
items.where((l) => l.id != null)
.every((l) => _selectedLicenseIds.contains(l.id));
// 데이터 로드
Future<void> loadData({bool isInitialLoad = true}) async {
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
// API 사용 - 전체 데이터 로드
print('╔══════════════════════════════════════════════════════════');
print('║ 🔧 유지보수 목록 API 호출 시작');
print('║ • 회사 필터: ${_selectedCompanyId ?? "전체"}');
print('║ • 활성 필터: ${_isActive != null ? (_isActive! ? "활성" : "비활성") : "전체"}');
print('║ • 라이센스 타입: ${_licenseType ?? "전체"}');
print('╚══════════════════════════════════════════════════════════');
// 전체 데이터를 가져오기 위해 큰 perPage 값 사용
final fetchedLicenses = await _licenseService.getLicenses(
page: 1,
perPage: 1000, // 충분히 큰 값으로 전체 데이터 로드
isActive: _isActive,
companyId: _selectedCompanyId,
licenseType: _licenseType,
);
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 유지보수 목록 로드 완료');
print('║ ▶ 총 라이센스 수: ${fetchedLicenses.length}');
print('╟──────────────────────────────────────────────────────────');
// 상태별 통계
int activeCount = 0;
int expiringSoonCount = 0;
int expiredCount = 0;
final now = DateTime.now();
for (final license in fetchedLicenses) {
if (license.expiryDate != null) {
final daysUntil = license.expiryDate!.difference(now).inDays;
if (daysUntil < 0) {
expiredCount++;
} else if (daysUntil <= 30) {
expiringSoonCount++;
} else {
activeCount++;
}
} else {
activeCount++;
}
}
print('║ • 활성: $activeCount개');
print('║ • 만료 임박 (30일 이내): $expiringSoonCount개');
print('║ • 만료됨: $expiredCount개');
print('╟──────────────────────────────────────────────────────────');
print('║ 📑 전체 데이터 로드 완료');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
_licenses = fetchedLicenses;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
_total = fetchedLicenses.length;
} else {
// Mock 데이터 사용
final allLicenses = mockDataService?.getAllLicenses() ?? [];
// 필터링 적용
var filtered = allLicenses;
if (_selectedCompanyId != null) {
filtered = filtered.where((l) => l.companyId == _selectedCompanyId).toList();
}
// 페이지네이션 적용
final startIndex = (_currentPage - 1) * _pageSize;
final endIndex = startIndex + _pageSize;
if (startIndex < filtered.length) {
final pageLicenses = filtered.sublist(
startIndex,
endIndex > filtered.length ? filtered.length : endIndex,
);
if (isInitialLoad) {
_licenses = pageLicenses;
} else {
_licenses.addAll(pageLicenses);
}
_hasMore = endIndex < filtered.length;
} else {
_hasMore = false;
}
_total = filtered.length;
}
debugPrint('📑 _applySearchFilter 호출 전: _licenses=${_licenses.length}');
_applySearchFilter();
_applyStatusFilter();
await _updateStatistics();
debugPrint('📑 _applySearchFilter 호출 후: _filteredLicenses=${_filteredLicenses.length}');
} catch (e) {
debugPrint('❌ loadData 에러 발생: $e');
_error = e.toString();
} finally {
_isLoading = false;
debugPrint('📑 loadData 종료: _filteredLicenses=${_filteredLicenses.length}');
notifyListeners();
LicenseListController() {
if (GetIt.instance.isRegistered<LicenseService>()) {
_licenseService = GetIt.instance<LicenseService>();
} else {
throw Exception('LicenseService not registered in GetIt');
}
}
// 다음 페이지 로드
Future<void> loadNextPage() async {
if (!_hasMore || _isLoading) return;
_currentPage++;
await loadData(isInitialLoad: false);
@override
Future<PagedResult<License>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// API 호출
final fetchedLicenses = await ErrorHandler.handleApiCall(
() => _licenseService.getLicenses(
page: params.page,
perPage: params.perPage,
isActive: _isActive,
companyId: _selectedCompanyId,
licenseType: _licenseType,
),
onError: (failure) {
throw failure;
},
);
if (fetchedLicenses == null) {
return PagedResult(
items: [],
meta: PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: 0,
totalPages: 0,
hasNext: false,
hasPrevious: false,
),
);
}
// 통계 업데이트
await _updateStatistics(fetchedLicenses);
// 임시로 메타데이터 생성 (추후 API에서 실제 메타데이터 반환 시 수정)
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: fetchedLicenses.length < params.perPage ?
(params.page - 1) * params.perPage + fetchedLicenses.length :
params.page * params.perPage + 1,
totalPages: fetchedLicenses.length < params.perPage ? params.page : params.page + 1,
hasNext: fetchedLicenses.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: fetchedLicenses, meta: meta);
}
// 검색 (디바운싱 적용)
@override
bool filterItem(License item, String query) {
final q = query.toLowerCase();
return (item.productName?.toLowerCase().contains(q) ?? false) ||
(item.licenseKey.toLowerCase().contains(q)) ||
(item.vendor?.toLowerCase().contains(q) ?? false) ||
(item.companyName?.toLowerCase().contains(q) ?? false);
}
/// BaseListController의 검색을 오버라이드하여 디바운싱 적용
@override
void search(String query) {
_searchQuery = query;
// 기존 타이머 취소
_debounceTimer?.cancel();
// Mock 데이터는 즉시 검색
if (!useApi) {
_applySearchFilter();
notifyListeners();
return;
}
// API 검색은 디바운싱 적용 (300ms)
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
loadData();
// 디바운싱 적용 (300ms)
_debounceTimer = Timer(AppConstants.licenseSearchDebounce, () {
super.search(query);
_applyStatusFilter();
});
}
// 검색 필터 적용
void _applySearchFilter() {
debugPrint('🔎 _applySearchFilter 시작: _searchQuery="$_searchQuery", _licenses=${_licenses.length}');
if (_searchQuery.isEmpty) {
_filteredLicenses = List.from(_licenses);
debugPrint('🔎 검색어 없음: 전체 복사 ${_filteredLicenses.length}');
} else {
_filteredLicenses = _licenses.where((license) {
final productName = license.productName?.toLowerCase() ?? '';
final licenseKey = license.licenseKey.toLowerCase();
final vendor = license.vendor?.toLowerCase() ?? '';
final companyName = license.companyName?.toLowerCase() ?? '';
final searchLower = _searchQuery.toLowerCase();
return productName.contains(searchLower) ||
licenseKey.contains(searchLower) ||
vendor.contains(searchLower) ||
companyName.contains(searchLower);
}).toList();
debugPrint('🔎 검색 필터링 완료: ${_filteredLicenses.length}');
}
}
// 상태 필터 적용
/// 상태 필터 적용 (BaseListController의 filtering과 추가로 동작)
void _applyStatusFilter() {
if (_statusFilter == LicenseStatusFilter.all) return;
final now = DateTime.now();
_filteredLicenses = _filteredLicenses.where((license) {
final currentItems = List<License>.from(items);
// 상태 필터 적용
final filteredByStatus = currentItems.where((license) {
switch (_statusFilter) {
case LicenseStatusFilter.active:
return license.isActive;
@@ -276,9 +172,13 @@ class LicenseListController extends ChangeNotifier {
return true;
}
}).toList();
// 직접 필터링된 결과를 적용 (BaseListController의 private 필드에 접근할 수 없으므로)
// 대신 notifyListeners를 통해 UI 업데이트
notifyListeners();
}
// 필터 설정
/// 필터 설정
void setFilters({
int? companyId,
bool? isActive,
@@ -287,135 +187,48 @@ class LicenseListController extends ChangeNotifier {
_selectedCompanyId = companyId;
_isActive = isActive;
_licenseType = licenseType;
loadData();
loadData(isRefresh: true);
}
// 필터 초기화
/// 필터 초기화
void clearFilters() {
_selectedCompanyId = null;
_isActive = null;
_licenseType = null;
_searchQuery = '';
loadData();
_statusFilter = LicenseStatusFilter.all;
search(''); // BaseListController의 search 호출
}
// 라이센스 삭제
Future<void> deleteLicense(int id) async {
try {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
await _licenseService.deleteLicense(id);
} else {
mockDataService?.deleteLicense(id);
}
// 목록에서 제거
_licenses.removeWhere((l) => l.id == id);
_applySearchFilter();
_total--;
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
/// 상태 필터 변경
Future<void> changeStatusFilter(LicenseStatusFilter filter) async {
_statusFilter = filter;
_applyStatusFilter();
}
// 새로고침
Future<void> refresh() async {
await loadData();
}
// 만료 예정 라이선스 조회
Future<List<License>> getExpiringLicenses({int days = 30}) async {
try {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
return await _licenseService.getExpiringLicenses(days: days);
} else {
// Mock 데이터에서 만료 예정 라이선스 필터링
final now = DateTime.now();
final allLicenses = mockDataService?.getAllLicenses() ?? [];
return allLicenses.where((license) {
// 실제 License 모델에서 만료일 확인
if (license.expiryDate != null) {
final daysUntilExpiry = license.expiryDate!.difference(now).inDays;
return daysUntilExpiry > 0 && daysUntilExpiry <= days;
}
return false;
}).toList();
}
} catch (e) {
_error = e.toString();
notifyListeners();
return [];
}
}
// 상태별 라이선스 개수 조회
Future<Map<String, int>> getLicenseStatusCounts() async {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
try {
// API에서 상태별 개수 조회 (실제로는 별도 엔드포인트가 있다면 사용)
final activeCount = await _licenseService.getTotalLicenses(isActive: true);
final inactiveCount = await _licenseService.getTotalLicenses(isActive: false);
final expiringLicenses = await getExpiringLicenses(days: 30);
return {
'active': activeCount,
'inactive': inactiveCount,
'expiring': expiringLicenses.length,
'total': activeCount + inactiveCount,
};
} catch (e) {
return {'active': 0, 'inactive': 0, 'expiring': 0, 'total': 0};
}
} else {
// Mock 데이터에서 계산
final allLicenses = mockDataService?.getAllLicenses() ?? [];
final now = DateTime.now();
int activeCount = 0;
int expiredCount = 0;
int expiringCount = 0;
for (var license in allLicenses) {
if (license.isActive) {
activeCount++;
if (license.expiryDate != null) {
final daysUntilExpiry = license.expiryDate!.difference(now).inDays;
if (daysUntilExpiry <= 0) {
expiredCount++;
} else if (daysUntilExpiry <= 30) {
expiringCount++;
}
}
}
}
return {
'active': activeCount,
'inactive': allLicenses.length - activeCount,
'expiring': expiringCount,
'expired': expiredCount,
'total': allLicenses.length,
};
}
}
// 정렬 변경
/// 정렬 변경
void sortBy(String field, String order) {
_sortBy = field;
_sortOrder = order;
loadData();
loadData(isRefresh: true);
}
// 상태 필터 변경
Future<void> changeStatusFilter(LicenseStatusFilter filter) async {
_statusFilter = filter;
await loadData();
/// 라이선스 삭제 (BaseListController의 기본 기능 활용)
Future<void> deleteLicense(int id) async {
await ErrorHandler.handleApiCall<void>(
() => _licenseService.deleteLicense(id),
onError: (failure) {
throw failure;
},
);
// BaseListController의 removeItemLocally 활용
removeItemLocally((l) => l.id == id);
// 선택 목록에서도 제거
_selectedLicenseIds.remove(id);
}
// 라이선스 선택/해제
/// 라이선스 선택/해제
void selectLicense(int? id, bool? isSelected) {
if (id == null) return;
@@ -427,11 +240,11 @@ class LicenseListController extends ChangeNotifier {
notifyListeners();
}
// 전체 선택/해제
/// 전체 선택/해제
void selectAll(bool? isSelected) {
if (isSelected == true) {
// 현재 필터링된 라이선스 모두 선택
for (var license in _filteredLicenses) {
for (var license in items) {
if (license.id != null) {
_selectedLicenseIds.add(license.id!);
}
@@ -443,131 +256,126 @@ class LicenseListController extends ChangeNotifier {
notifyListeners();
}
// 선택된 라이선스 목록 반환
/// 선택된 라이선스 목록 반환
List<License> getSelectedLicenses() {
return _filteredLicenses
return items
.where((l) => l.id != null && _selectedLicenseIds.contains(l.id))
.toList();
}
// 선택 초기화
/// 선택 초기화 (BaseListController에도 있지만 라이선스 특화)
@override
void clearSelection() {
_selectedLicenseIds.clear();
notifyListeners();
}
// 라이선스 할당
Future<bool> assignLicense(int licenseId, int userId) async {
try {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
await _licenseService.assignLicense(licenseId, userId);
await loadData();
clearSelection();
return true;
}
return false;
} catch (e) {
_error = e.toString();
notifyListeners();
return false;
}
}
// 라이선스 할당 해제
Future<bool> unassignLicense(int licenseId) async {
try {
if (useApi && GetIt.instance.isRegistered<LicenseService>()) {
await _licenseService.unassignLicense(licenseId);
await loadData();
clearSelection();
return true;
}
return false;
} catch (e) {
_error = e.toString();
notifyListeners();
return false;
}
}
// 선택된 라이선스 일괄 삭제
/// 선택된 라이선스 일괄 삭제
Future<void> deleteSelectedLicenses() async {
if (_selectedLicenseIds.isEmpty) return;
final selectedIds = List<int>.from(_selectedLicenseIds);
int successCount = 0;
int failCount = 0;
for (var id in selectedIds) {
try {
await deleteLicense(id);
successCount++;
} catch (e) {
failCount++;
debugPrint('라이선스 $id 삭제 실패: $e');
}
}
_selectedLicenseIds.clear();
await loadData();
if (successCount > 0) {
debugPrint('$successCount개 라이선스 삭제 완료');
}
if (failCount > 0) {
debugPrint('$failCount개 라이선스 삭제 실패');
for (final id in _selectedLicenseIds.toList()) {
await deleteLicense(id);
}
clearSelection();
}
// 통계 업데이트
Future<void> _updateStatistics() async {
try {
final counts = await getLicenseStatusCounts();
final now = DateTime.now();
int expiringSoonCount = 0;
int expiredCount = 0;
for (var license in _licenses) {
if (license.expiryDate != null) {
final days = license.expiryDate!.difference(now).inDays;
if (days <= 0) {
expiredCount++;
} else if (days <= 30) {
expiringSoonCount++;
}
/// 라이선스 생성
Future<void> createLicense(License license) async {
await ErrorHandler.handleApiCall<void>(
() => _licenseService.createLicense(license),
onError: (failure) {
throw failure;
},
);
await refresh();
}
/// 라이선스 수정
Future<void> updateLicense(License license) async {
await ErrorHandler.handleApiCall<void>(
() => _licenseService.updateLicense(license),
onError: (failure) {
throw failure;
},
);
updateItemLocally(license, (l) => l.id == license.id);
}
/// 라이선스 활성화/비활성화 토글
Future<void> toggleLicenseStatus(int id) async {
final license = items.firstWhere((l) => l.id == id);
final updatedLicense = license.copyWith(isActive: !license.isActive);
await updateLicense(updatedLicense);
}
/// 통계 데이터 업데이트
Future<void> _updateStatistics(List<License> licenses) async {
final now = DateTime.now();
_statistics = {
'total': licenses.length,
'active': licenses.where((l) => l.isActive).length,
'inactive': licenses.where((l) => !l.isActive).length,
'expiringSoon': licenses.where((l) {
if (l.expiryDate != null) {
final days = l.expiryDate!.difference(now).inDays;
return days > 0 && days <= 30;
}
}
return false;
}).length,
'expired': licenses.where((l) {
if (l.expiryDate != null) {
return l.expiryDate!.isBefore(now);
}
return false;
}).length,
};
}
/// 라이선스 만료일별 그룹핑
Map<String, List<License>> getLicensesByExpiryPeriod() {
final now = DateTime.now();
final Map<String, List<License>> grouped = {
'이미 만료': [],
'${AppConstants.licenseExpiryWarningDays}일 이내': [],
'${AppConstants.licenseExpiryCautionDays}일 이내': [],
'${AppConstants.licenseExpiryInfoDays}일 이내': [],
'${AppConstants.licenseExpiryInfoDays}일 이후': [],
};
for (final license in items) {
if (license.expiryDate == null) continue;
_statistics = {
'total': counts['total'] ?? 0,
'active': counts['active'] ?? 0,
'inactive': counts['inactive'] ?? 0,
'expiringSoon': expiringSoonCount,
'expired': expiredCount,
};
} catch (e) {
debugPrint('❌ 통계 업데이트 오류: $e');
// 오류 발생 시 기본값 사용
_statistics = {
'total': _licenses.length,
'active': 0,
'inactive': 0,
'expiringSoon': 0,
'expired': 0,
};
final days = license.expiryDate!.difference(now).inDays;
if (days < 0) {
grouped['이미 만료']!.add(license);
} else if (days <= AppConstants.licenseExpiryWarningDays) {
grouped['${AppConstants.licenseExpiryWarningDays}일 이내']!.add(license);
} else if (days <= AppConstants.licenseExpiryCautionDays) {
grouped['${AppConstants.licenseExpiryCautionDays}일 이내']!.add(license);
} else if (days <= AppConstants.licenseExpiryInfoDays) {
grouped['${AppConstants.licenseExpiryInfoDays}일 이내']!.add(license);
} else {
grouped['${AppConstants.licenseExpiryInfoDays}일 이후']!.add(license);
}
}
return grouped;
}
// 만료까지 남은 일수 계산
int? getDaysUntilExpiry(License license) {
if (license.expiryDate == null) return null;
return license.expiryDate!.difference(DateTime.now()).inDays;
/// 만료까지 남은 날짜 계산
int getDaysUntilExpiry(DateTime? expiryDate) {
if (expiryDate == null) return 999; // 만료일이 없으면 큰 숫자 반환
final now = DateTime.now();
return expiryDate.difference(now).inDays;
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}
}

View File

@@ -0,0 +1,283 @@
import 'package:flutter/material.dart';
import '../../../core/controllers/base_list_controller.dart';
import '../../../core/utils/error_handler.dart';
import '../../../data/models/common/pagination_params.dart';
import '../../../data/models/license/license_dto.dart';
import '../../../domain/usecases/license/license_usecases.dart';
/// UseCase 패턴을 적용한 라이선스 목록 컨트롤러
class LicenseListControllerWithUseCase extends BaseListController<LicenseDto> {
final GetLicensesUseCase getLicensesUseCase;
final CreateLicenseUseCase createLicenseUseCase;
final UpdateLicenseUseCase updateLicenseUseCase;
final DeleteLicenseUseCase deleteLicenseUseCase;
final CheckLicenseExpiryUseCase checkLicenseExpiryUseCase;
// 선택된 항목들
final Set<int> _selectedLicenseIds = {};
Set<int> get selectedLicenseIds => _selectedLicenseIds;
// 필터 옵션
String? _filterByCompany;
String? _filterByExpiry;
DateTime? _filterStartDate;
DateTime? _filterEndDate;
String? get filterByCompany => _filterByCompany;
String? get filterByExpiry => _filterByExpiry;
DateTime? get filterStartDate => _filterStartDate;
DateTime? get filterEndDate => _filterEndDate;
// 만료 임박 라이선스 정보
LicenseExpiryResult? _expiryResult;
LicenseExpiryResult? get expiryResult => _expiryResult;
LicenseListControllerWithUseCase({
required this.getLicensesUseCase,
required this.createLicenseUseCase,
required this.updateLicenseUseCase,
required this.deleteLicenseUseCase,
required this.checkLicenseExpiryUseCase,
});
@override
Future<PagedResult<LicenseDto>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
try {
// 필터 파라미터 구성
final filters = <String, dynamic>{};
if (_filterByCompany != null) filters['company_id'] = _filterByCompany;
if (_filterByExpiry != null) filters['expiry'] = _filterByExpiry;
if (_filterStartDate != null) filters['start_date'] = _filterStartDate!.toIso8601String();
if (_filterEndDate != null) filters['end_date'] = _filterEndDate!.toIso8601String();
final updatedParams = params.copyWith(filters: filters);
final getParams = GetLicensesParams.fromPaginationParams(updatedParams);
final result = await getLicensesUseCase(getParams);
return result.fold(
(failure) => throw Exception(failure.message),
(licenseResponse) {
// PagedResult로 래핑하여 반환
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: licenseResponse.items.length, // 실제로는 서버에서 받아와야 함
totalPages: (licenseResponse.items.length / params.perPage).ceil(),
hasNext: licenseResponse.items.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: licenseResponse.items, meta: meta);
},
);
} catch (e) {
throw Exception('데이터 로드 실패: $e');
}
}
/// 만료 임박 라이선스 체크
Future<void> checkExpiringLicenses() async {
try {
final params = CheckLicenseExpiryParams(
companyId: _filterByCompany != null ? int.tryParse(_filterByCompany!) : null,
);
final result = await checkLicenseExpiryUseCase(params);
result.fold(
(failure) => errorState = failure.message,
(expiryResult) {
_expiryResult = expiryResult;
notifyListeners();
},
);
} catch (e) {
errorState = '라이선스 만료 체크 실패: $e';
}
}
/// 라이선스 생성
Future<void> createLicense({
required int equipmentId,
required int companyId,
required String licenseType,
required DateTime startDate,
required DateTime expiryDate,
String? description,
double? cost,
}) async {
try {
isLoadingState = true;
final params = CreateLicenseParams(
equipmentId: equipmentId,
companyId: companyId,
licenseType: licenseType,
startDate: startDate,
expiryDate: expiryDate,
description: description,
cost: cost,
);
final result = await createLicenseUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(license) async {
await refresh();
await checkExpiringLicenses();
},
);
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
/// 라이선스 수정
Future<void> updateLicense({
required int id,
int? equipmentId,
int? companyId,
String? licenseType,
DateTime? startDate,
DateTime? expiryDate,
String? description,
double? cost,
String? status,
}) async {
try {
isLoadingState = true;
final params = UpdateLicenseParams(
id: id,
equipmentId: equipmentId,
companyId: companyId,
licenseType: licenseType,
startDate: startDate,
expiryDate: expiryDate,
description: description,
cost: cost,
status: status,
);
final result = await updateLicenseUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(license) async {
updateItemLocally(license, (item) => item.id == license.id);
await checkExpiringLicenses();
},
);
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
/// 라이선스 삭제
Future<void> deleteLicense(int id) async {
try {
isLoadingState = true;
final result = await deleteLicenseUseCase(id);
await result.fold(
(failure) async => errorState = failure.message,
(_) async {
removeItemLocally((item) => item.id == id);
_selectedLicenseIds.remove(id);
await checkExpiringLicenses();
},
);
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
/// 필터 설정
void setFilters({
String? company,
String? expiry,
DateTime? startDate,
DateTime? endDate,
}) {
_filterByCompany = company;
_filterByExpiry = expiry;
_filterStartDate = startDate;
_filterEndDate = endDate;
refresh();
}
/// 필터 초기화
void clearFilters() {
_filterByCompany = null;
_filterByExpiry = null;
_filterStartDate = null;
_filterEndDate = null;
refresh();
}
/// 라이선스 선택 토글
void toggleLicenseSelection(int id) {
if (_selectedLicenseIds.contains(id)) {
_selectedLicenseIds.remove(id);
} else {
_selectedLicenseIds.add(id);
}
notifyListeners();
}
/// 모든 라이선스 선택
void selectAll() {
_selectedLicenseIds.clear();
_selectedLicenseIds.addAll(items.map((e) => e.id));
notifyListeners();
}
/// 선택 해제
void clearSelection() {
_selectedLicenseIds.clear();
notifyListeners();
}
/// 선택된 라이선스 일괄 삭제
Future<void> deleteSelectedLicenses() async {
if (_selectedLicenseIds.isEmpty) return;
try {
isLoadingState = true;
for (final id in _selectedLicenseIds.toList()) {
final result = await deleteLicenseUseCase(id);
result.fold(
(failure) => print('Failed to delete license $id: ${failure.message}'),
(_) => removeItemLocally((item) => item.id == id),
);
}
_selectedLicenseIds.clear();
await checkExpiringLicenses();
notifyListeners();
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
@override
void dispose() {
_selectedLicenseIds.clear();
_expiryResult = null;
super.dispose();
}
}

View File

@@ -5,7 +5,6 @@ import 'package:superport/screens/license/controllers/license_form_controller.da
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
import 'package:superport/screens/common/custom_widgets.dart' hide FormFieldWrapper;
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/validators.dart';
import 'package:intl/intl.dart';
import 'package:superport/core/config/environment.dart' as env;
@@ -51,8 +50,6 @@ class _MaintenanceFormScreenState extends State<MaintenanceFormScreen> {
debugPrint('📌 라이선스 폼 초기화 - API 모드: $useApi');
_controller = LicenseFormController(
useApi: useApi,
dataService: useApi ? null : MockDataService(),
licenseId: widget.maintenanceId,
isExtension: widget.isExtension,
);

View File

@@ -11,7 +11,6 @@ import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/license/controllers/license_list_controller.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/core/config/environment.dart' as env;
import 'package:intl/intl.dart';
@@ -25,7 +24,7 @@ class LicenseListRedesign extends StatefulWidget {
class _LicenseListRedesignState extends State<LicenseListRedesign> {
late final LicenseListController _controller;
final MockDataService _dataService = MockDataService();
// MockDataService 제거 - 실제 API 사용
final TextEditingController _searchController = TextEditingController();
final ScrollController _horizontalScrollController = ScrollController();
int _currentPage = 1;
@@ -45,10 +44,7 @@ class _LicenseListRedesignState extends State<LicenseListRedesign> {
// 실제 API 사용 여부에 따라 컨트롤러 초기화
final useApi = env.Environment.useApi;
_controller = LicenseListController(
useApi: useApi,
mockDataService: useApi ? null : _dataService,
);
_controller = LicenseListController();
debugPrint('📌 Controller 모드: ${useApi ? "Real API" : "Mock Data"}');
debugPrint('==========================================\n');
@@ -611,7 +607,7 @@ class _LicenseListRedesignState extends State<LicenseListRedesign> {
final displayIndex = entry.key;
final license = entry.value;
final index = (_currentPage - 1) * _pageSize + displayIndex;
final daysRemaining = _controller.getDaysUntilExpiry(license);
final daysRemaining = _controller.getDaysUntilExpiry(license.expiryDate);
return Container(
padding: const EdgeInsets.symmetric(

View File

@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:dartz/dartz.dart';
import '../../../core/errors/failures.dart';
import '../../../domain/usecases/base_usecase.dart';
import '../../../domain/usecases/auth/login_usecase.dart';
import '../../../domain/usecases/auth/check_auth_status_usecase.dart';
import '../../../services/auth_service.dart';
import '../../../services/health_check_service.dart';
import '../../../di/injection_container.dart';
/// UseCase를 활용한 로그인 화면 컨트롤러
/// 비즈니스 로직을 UseCase로 분리하여 테스트 용이성과 재사용성 향상
class LoginControllerWithUseCase extends ChangeNotifier {
// UseCases
late final LoginUseCase _loginUseCase;
late final CheckAuthStatusUseCase _checkAuthStatusUseCase;
// Services
final HealthCheckService _healthCheckService = HealthCheckService();
// UI Controllers
final TextEditingController idController = TextEditingController();
final TextEditingController pwController = TextEditingController();
// Focus Nodes
final FocusNode idFocus = FocusNode();
final FocusNode pwFocus = FocusNode();
// State
bool saveId = false;
bool _isLoading = false;
String? _errorMessage;
// Getters
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
LoginControllerWithUseCase() {
// UseCase 초기화
final authService = inject<AuthService>();
_loginUseCase = LoginUseCase(authService);
_checkAuthStatusUseCase = CheckAuthStatusUseCase(authService);
// 초기 인증 상태 확인
_checkInitialAuthStatus();
}
/// 초기 인증 상태 확인
Future<void> _checkInitialAuthStatus() async {
final result = await _checkAuthStatusUseCase(const NoParams());
result.fold(
(failure) => debugPrint('인증 상태 확인 실패: ${failure.message}'),
(isAuthenticated) {
if (isAuthenticated) {
debugPrint('이미 로그인된 상태입니다.');
}
},
);
}
/// 아이디 저장 체크박스 상태 변경
void setSaveId(bool value) {
saveId = value;
notifyListeners();
}
/// 에러 메시지 초기화
void clearError() {
_errorMessage = null;
notifyListeners();
}
/// 로그인 처리
Future<bool> login() async {
// 입력값 검증 (UI 레벨)
if (idController.text.trim().isEmpty) {
_errorMessage = '아이디 또는 이메일을 입력해주세요.';
notifyListeners();
return false;
}
if (pwController.text.isEmpty) {
_errorMessage = '비밀번호를 입력해주세요.';
notifyListeners();
return false;
}
// 로딩 시작
_isLoading = true;
_errorMessage = null;
notifyListeners();
// 입력값이 이메일인지 username인지 판단
final inputValue = idController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
final isEmail = emailRegex.hasMatch(inputValue);
try {
// UseCase 실행
final params = LoginParams(
email: isEmail ? inputValue : '$inputValue@superport.kr', // username인 경우 임시 도메인 추가
password: pwController.text,
);
debugPrint('[LoginController] 로그인 시도: ${params.email}');
final result = await _loginUseCase(params).timeout(
const Duration(seconds: 10),
onTimeout: () async {
debugPrint('[LoginController] 로그인 요청 타임아웃');
return Left(NetworkFailure(
message: '요청 시간이 초과되었습니다. 네트워크 연결을 확인해주세요.',
));
},
);
return result.fold(
(failure) {
debugPrint('[LoginController] 로그인 실패: ${failure.message}');
// 실패 타입에 따른 메시지 처리
if (failure is ValidationFailure) {
_errorMessage = failure.message;
} else if (failure is AuthenticationFailure) {
_errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
} else if (failure is NetworkFailure) {
_errorMessage = '네트워크 연결을 확인해주세요.';
} else if (failure is ServerFailure) {
_errorMessage = '서버 오류가 발생했습니다.\n잠시 후 다시 시도해주세요.';
} else {
_errorMessage = failure.message;
}
_isLoading = false;
notifyListeners();
return false;
},
(loginResponse) {
debugPrint('[LoginController] 로그인 성공');
_isLoading = false;
notifyListeners();
return true;
},
);
} catch (e) {
debugPrint('[LoginController] 예상치 못한 에러: $e');
_errorMessage = '로그인 중 오류가 발생했습니다.';
_isLoading = false;
notifyListeners();
return false;
}
}
/// 헬스체크 실행
Future<bool> performHealthCheck() async {
debugPrint('[LoginController] 헬스체크 시작');
_isLoading = true;
notifyListeners();
try {
final healthResult = await _healthCheckService.checkHealth();
_isLoading = false;
notifyListeners();
// HealthCheckService가 Map을 반환하는 경우 적절히 변환
final isHealthy = healthResult is bool ? healthResult :
(healthResult is Map && healthResult['status'] == 'healthy');
if (isHealthy == false) {
_errorMessage = '서버와 연결할 수 없습니다.\n잠시 후 다시 시도해주세요.';
notifyListeners();
return false;
}
return true;
} catch (e) {
debugPrint('[LoginController] 헬스체크 실패: $e');
_errorMessage = '서버 상태 확인 중 오류가 발생했습니다.';
_isLoading = false;
notifyListeners();
return false;
}
}
@override
void dispose() {
idController.dispose();
pwController.dispose();
idFocus.dispose();
pwFocus.dispose();
super.dispose();
}
}

View File

@@ -4,11 +4,15 @@ import 'package:intl/intl.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/overview/controllers/overview_controller.dart';
import 'package:superport/services/mock_data_service.dart';
// MockDataService 제거 - 실제 API 사용
import 'package:superport/services/auth_service.dart';
import 'package:superport/services/health_check_service.dart';
import 'package:superport/core/widgets/auth_guard.dart';
import 'package:superport/data/models/auth/auth_user.dart';
/// shadcn/ui 스타일로 재설계된 대시보드 화면
class OverviewScreenRedesign extends StatefulWidget {
const OverviewScreenRedesign({Key? key}) : super(key: key);
const OverviewScreenRedesign({super.key});
@override
State<OverviewScreenRedesign> createState() => _OverviewScreenRedesignState();
@@ -16,21 +20,44 @@ class OverviewScreenRedesign extends StatefulWidget {
class _OverviewScreenRedesignState extends State<OverviewScreenRedesign> {
late final OverviewController _controller;
late final HealthCheckService _healthCheckService;
Map<String, dynamic>? _healthStatus;
bool _isHealthCheckLoading = false;
@override
void initState() {
super.initState();
_controller = OverviewController();
_healthCheckService = HealthCheckService();
_loadData();
_checkHealthStatus();
// 주기적인 헬스체크 시작 (30초마다)
_healthCheckService.startPeriodicHealthCheck();
}
Future<void> _loadData() async {
await _controller.loadDashboardData();
}
Future<void> _checkHealthStatus() async {
setState(() {
_isHealthCheckLoading = true;
});
final result = await _healthCheckService.checkHealth();
if (mounted) {
setState(() {
_healthStatus = result;
_isHealthCheckLoading = false;
});
}
}
@override
void dispose() {
_controller.dispose();
_healthCheckService.stopPeriodicHealthCheck();
super.dispose();
}
@@ -70,7 +97,13 @@ class _OverviewScreenRedesignState extends State<OverviewScreenRedesign> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('안녕하세요, 관리자님! 👋', style: ShadcnTheme.headingH3),
FutureBuilder<AuthUser?>(
future: context.read<AuthService>().getCurrentUser(),
builder: (context, snapshot) {
final userName = snapshot.data?.name ?? '사용자';
return Text('안녕하세요, $userName님! 👋', style: ShadcnTheme.headingH3);
},
),
const SizedBox(height: 8),
Text(
'오늘의 포트 운영 현황을 확인해보세요.',
@@ -333,51 +366,79 @@ class _OverviewScreenRedesignState extends State<OverviewScreenRedesign> {
}
Widget _buildRightColumn() {
return Column(
children: [
// 빠른 작업
ShadcnCard(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('빠른 작업', style: ShadcnTheme.headingH4),
const SizedBox(height: 16),
_buildQuickActionButton(Icons.add_box, '장비 입고', '새 장비 등록'),
const SizedBox(height: 12),
_buildQuickActionButton(
Icons.local_shipping,
'장비 출고',
'장비 대여 처리',
),
const SizedBox(height: 12),
_buildQuickActionButton(
Icons.business_center,
'회사 등록',
'새 회사 추가',
),
],
return FutureBuilder<AuthUser?>(
future: context.read<AuthService>().getCurrentUser(),
builder: (context, snapshot) {
final userRole = snapshot.data?.role?.toLowerCase() ?? '';
final isAdminOrManager = userRole == 'admin' || userRole == 'manager';
return Column(
children: [
// 빠른 작업 (Admin과 Manager만 표시)
if (isAdminOrManager) ...[
ShadcnCard(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('빠른 작업', style: ShadcnTheme.headingH4),
const SizedBox(height: 16),
_buildQuickActionButton(Icons.add_box, '장비 입고', '새 장비 등록'),
const SizedBox(height: 12),
_buildQuickActionButton(
Icons.local_shipping,
'장비 출고',
'장비 대여 처리',
),
const SizedBox(height: 12),
_buildQuickActionButton(
Icons.business_center,
'회사 등록',
'새 회사 추가',
),
],
),
),
),
const SizedBox(height: 24),
],
const SizedBox(height: 24),
// 시스템 상태
ShadcnCard(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('시스템 상태', style: ShadcnTheme.headingH4),
const SizedBox(height: 16),
_buildStatusItem('서버 상태', '정상'),
_buildStatusItem('데이터베이스', '정상'),
_buildStatusItem('네트워크', '정상'),
_buildStatusItem('백업', '완료'),
],
),
),
],
// 시스템 상태 (실시간 헬스체크)
ShadcnCard(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('시스템 상태', style: ShadcnTheme.headingH4),
IconButton(
icon: _isHealthCheckLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(ShadcnTheme.primary),
),
)
: Icon(Icons.refresh, size: 20, color: ShadcnTheme.muted),
onPressed: _isHealthCheckLoading ? null : _checkHealthStatus,
tooltip: '새로고침',
),
],
),
const SizedBox(height: 16),
_buildHealthStatusItem('서버 상태', _getServerStatus()),
_buildHealthStatusItem('데이터베이스', _getDatabaseStatus()),
_buildHealthStatusItem('API 응답', _getApiResponseTime()),
_buildHealthStatusItem('최종 체크', _getLastCheckTime()),
],
),
),
],
);
},
);
}
@@ -664,4 +725,151 @@ class _OverviewScreenRedesignState extends State<OverviewScreenRedesign> {
),
);
}
/// 헬스 상태 아이템 빌더
Widget _buildHealthStatusItem(String label, Map<String, dynamic> statusInfo) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: ShadcnTheme.bodyMedium),
Row(
children: [
if (statusInfo['icon'] != null) ...[
Icon(
statusInfo['icon'] as IconData,
size: 16,
color: statusInfo['color'] as Color,
),
const SizedBox(width: 4),
],
ShadcnBadge(
text: statusInfo['text'] as String,
variant: statusInfo['variant'] as ShadcnBadgeVariant,
size: ShadcnBadgeSize.small,
),
],
),
],
),
);
}
/// 서버 상태 가져오기
Map<String, dynamic> _getServerStatus() {
if (_healthStatus == null) {
return {
'text': '확인 중',
'variant': ShadcnBadgeVariant.secondary,
'icon': Icons.pending,
'color': ShadcnTheme.muted,
};
}
final isHealthy = _healthStatus!['success'] == true &&
_healthStatus!['data']?['status'] == 'healthy';
return {
'text': isHealthy ? '정상' : '오류',
'variant': isHealthy ? ShadcnBadgeVariant.success : ShadcnBadgeVariant.destructive,
'icon': isHealthy ? Icons.check_circle : Icons.error,
'color': isHealthy ? ShadcnTheme.success : ShadcnTheme.destructive,
};
}
/// 데이터베이스 상태 가져오기
Map<String, dynamic> _getDatabaseStatus() {
if (_healthStatus == null) {
return {
'text': '확인 중',
'variant': ShadcnBadgeVariant.secondary,
'icon': Icons.pending,
'color': ShadcnTheme.muted,
};
}
final dbStatus = _healthStatus!['data']?['database']?['status'] ?? 'unknown';
final isConnected = dbStatus == 'connected';
return {
'text': isConnected ? '연결됨' : '연결 안됨',
'variant': isConnected ? ShadcnBadgeVariant.success : ShadcnBadgeVariant.warning,
'icon': isConnected ? Icons.storage : Icons.cloud_off,
'color': isConnected ? ShadcnTheme.success : ShadcnTheme.warning,
};
}
/// API 응답 시간 가져오기
Map<String, dynamic> _getApiResponseTime() {
if (_healthStatus == null) {
return {
'text': '측정 중',
'variant': ShadcnBadgeVariant.secondary,
'icon': Icons.timer,
'color': ShadcnTheme.muted,
};
}
final responseTime = _healthStatus!['data']?['responseTime'] ?? 0;
final timeMs = responseTime is num ? responseTime : 0;
ShadcnBadgeVariant variant;
Color color;
if (timeMs < 100) {
variant = ShadcnBadgeVariant.success;
color = ShadcnTheme.success;
} else if (timeMs < 500) {
variant = ShadcnBadgeVariant.warning;
color = ShadcnTheme.warning;
} else {
variant = ShadcnBadgeVariant.destructive;
color = ShadcnTheme.destructive;
}
return {
'text': '${timeMs}ms',
'variant': variant,
'icon': Icons.speed,
'color': color,
};
}
/// 마지막 체크 시간 가져오기
Map<String, dynamic> _getLastCheckTime() {
if (_healthStatus == null) {
return {
'text': '없음',
'variant': ShadcnBadgeVariant.secondary,
'icon': Icons.access_time,
'color': ShadcnTheme.muted,
};
}
final timestamp = _healthStatus!['data']?['timestamp'];
if (timestamp != null) {
try {
final date = DateTime.parse(timestamp);
final formatter = DateFormat('HH:mm:ss');
return {
'text': formatter.format(date),
'variant': ShadcnBadgeVariant.outline,
'icon': Icons.access_time,
'color': ShadcnTheme.foreground,
};
} catch (e) {
// 파싱 실패
}
}
// 현재 시간 사용
final now = DateTime.now();
final formatter = DateFormat('HH:mm:ss');
return {
'text': formatter.format(now),
'variant': ShadcnBadgeVariant.outline,
'icon': Icons.access_time,
'color': ShadcnTheme.foreground,
};
}
}

View File

@@ -3,21 +3,21 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/services/user_service.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/models/user_phone_field.dart';
// 사용자 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserFormController extends ChangeNotifier {
final MockDataService dataService;
final UserService _userService = GetIt.instance<UserService>();
final CompanyService _companyService = GetIt.instance<CompanyService>();
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// 상태 변수
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag
// API만 사용
// 폼 필드
bool isEditMode = false;
@@ -50,7 +50,7 @@ class UserFormController extends ChangeNotifier {
bool get isCheckingUsername => _isCheckingUsername;
bool? get isUsernameAvailable => _isUsernameAvailable;
UserFormController({required this.dataService, this.userId}) {
UserFormController({this.userId}) {
isEditMode = userId != null;
if (isEditMode) {
loadUser();
@@ -61,15 +61,29 @@ class UserFormController extends ChangeNotifier {
}
// 회사 목록 로드
void loadCompanies() {
companies = dataService.getAllCompanies();
notifyListeners();
Future<void> loadCompanies() async {
try {
final result = await _companyService.getCompanies();
companies = result;
notifyListeners();
} catch (e) {
debugPrint('회사 목록 로드 실패: $e');
companies = [];
notifyListeners();
}
}
// 회사 ID에 따라 지점 목록 로드
void loadBranches(int companyId) {
final company = dataService.getCompanyById(companyId);
branches = company?.branches ?? [];
final company = companies.firstWhere(
(c) => c.id == companyId,
orElse: () => Company(
id: companyId,
name: '알 수 없는 회사',
branches: [],
),
);
branches = company.branches ?? [];
// 지점 변경 시 이전 선택 지점이 새 회사에 없으면 초기화
if (branchId != null && !branches.any((b) => b.id == branchId)) {
branchId = null;
@@ -86,13 +100,7 @@ class UserFormController extends ChangeNotifier {
notifyListeners();
try {
User? user;
if (_useApi) {
user = await _userService.getUser(userId!);
} else {
user = dataService.getUserById(userId!);
}
final user = await _userService.getUser(userId!);
if (user != null) {
name = user.name;
@@ -155,15 +163,8 @@ class UserFormController extends ChangeNotifier {
notifyListeners();
try {
if (_useApi) {
final isDuplicate = await _userService.checkDuplicateUsername(value);
_isUsernameAvailable = !isDuplicate;
} else {
// Mock 데이터에서 중복 확인
final users = dataService.getAllUsers();
final exists = users.any((u) => u.username == value && u.id != userId);
_isUsernameAvailable = !exists;
}
final isDuplicate = await _userService.checkDuplicateUsername(value);
_isUsernameAvailable = !isDuplicate;
_lastCheckedUsername = value;
} catch (e) {
_isUsernameAvailable = null;
@@ -217,81 +218,32 @@ class UserFormController extends ChangeNotifier {
}
}
if (_useApi) {
if (isEditMode && userId != null) {
// 사용자 수정
await _userService.updateUser(
userId!,
name: name,
email: email.isNotEmpty ? email : null,
phone: phoneNumber,
companyId: companyId,
branchId: branchId,
role: role,
position: position.isNotEmpty ? position : null,
password: password.isNotEmpty ? password : null,
);
} else {
// 사용자 생성
await _userService.createUser(
username: username,
email: email,
password: password,
name: name,
role: role,
companyId: companyId!,
branchId: branchId,
phone: phoneNumber,
position: position.isNotEmpty ? position : null,
);
}
if (isEditMode && userId != null) {
// 사용자 수정
await _userService.updateUser(
userId!,
name: name,
email: email.isNotEmpty ? email : null,
phone: phoneNumber,
companyId: companyId,
branchId: branchId,
role: role,
position: position.isNotEmpty ? position : null,
password: password.isNotEmpty ? password : null,
);
} else {
// Mock 데이터 사용
List<Map<String, String>> phoneNumbersList = [];
for (var phoneField in phoneFields) {
if (phoneField.number.isNotEmpty) {
phoneNumbersList.add({
'type': phoneField.type,
'number': phoneField.number,
});
}
}
if (isEditMode && userId != null) {
final user = dataService.getUserById(userId!);
if (user != null) {
final updatedUser = User(
id: user.id,
companyId: companyId!,
branchId: branchId,
name: name,
role: role,
position: position.isNotEmpty ? position : null,
email: email.isNotEmpty ? email : null,
phoneNumbers: phoneNumbersList,
username: username.isNotEmpty ? username : null,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: DateTime.now(),
);
dataService.updateUser(updatedUser);
}
} else {
final newUser = User(
companyId: companyId!,
branchId: branchId,
name: name,
role: role,
position: position.isNotEmpty ? position : null,
email: email.isNotEmpty ? email : null,
phoneNumbers: phoneNumbersList,
username: username,
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
dataService.addUser(newUser);
}
// 사용자 생성
await _userService.createUser(
username: username,
email: email,
password: password,
name: name,
role: role,
companyId: companyId!,
branchId: branchId,
phone: phoneNumber,
position: position.isNotEmpty ? position : null,
);
}
onResult(null);
@@ -314,9 +266,6 @@ class UserFormController extends ChangeNotifier {
super.dispose();
}
// API/Mock 모드 전환
void toggleApiMode() {
_useApi = !_useApi;
notifyListeners();
}
// API 모드만 사용 (Mock 데이터 제거됨)
// void toggleApiMode() 메서드 제거
}

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/user_service.dart';
/// 담당자 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserListController extends ChangeNotifier {
final UserService _userService = GetIt.instance<UserService>();
// 상태 변수
List<User> _users = [];
bool _isLoading = false;
String? _error;
// API만 사용
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMoreData = true;
bool _isLoadingMore = false;
// 검색/필터
String _searchQuery = '';
int? _filterCompanyId;
String? _filterRole;
bool? _filterIsActive;
// Getters
List<User> get users => _users;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
String? get error => _error;
bool get hasMoreData => _hasMoreData;
String get searchQuery => _searchQuery;
int? get filterCompanyId => _filterCompanyId;
String? get filterRole => _filterRole;
bool? get filterIsActive => _filterIsActive;
UserListController();
/// 사용자 목록 초기 로드
Future<void> loadUsers({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
_hasMoreData = true;
_users.clear();
}
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
final newUsers = await _userService.getUsers(
page: _currentPage,
perPage: _perPage,
isActive: _filterIsActive,
companyId: _filterCompanyId,
role: _filterRole,
);
if (newUsers.isEmpty || newUsers.length < _perPage) {
_hasMoreData = false;
}
if (_currentPage == 1) {
_users = newUsers;
} else {
_users.addAll(newUsers);
}
_currentPage++;
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 다음 페이지 로드 (무한 스크롤용)
Future<void> loadMore() async {
if (!_hasMoreData || _isLoadingMore || _isLoading) return;
_isLoadingMore = true;
notifyListeners();
try {
await loadUsers();
} finally {
_isLoadingMore = false;
notifyListeners();
}
}
/// 검색 쿼리 설정
void setSearchQuery(String query) {
_searchQuery = query;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 필터 설정
void setFilters({
int? companyId,
String? role,
bool? isActive,
}) {
_filterCompanyId = companyId;
_filterRole = role;
_filterIsActive = isActive;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 필터 초기화
void clearFilters() {
_filterCompanyId = null;
_filterRole = null;
_filterIsActive = null;
_searchQuery = '';
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
}
/// 사용자 삭제
Future<void> deleteUser(int id, VoidCallback onDeleted, Function(String) onError) async {
try {
await _userService.deleteUser(id);
// 목록에서 삭제된 사용자 제거
_users.removeWhere((user) => user.id == id);
notifyListeners();
onDeleted();
} catch (e) {
onError('사용자 삭제 실패: ${e.toString()}');
}
}
/// 사용자 상태 변경 (활성/비활성)
Future<void> changeUserStatus(int id, bool isActive, Function(String) onError) async {
try {
final updatedUser = await _userService.changeUserStatus(id, isActive);
// 목록에서 해당 사용자 업데이트
final index = _users.indexWhere((u) => u.id == id);
if (index != -1) {
_users[index] = updatedUser;
notifyListeners();
}
} catch (e) {
onError('상태 변경 실패: ${e.toString()}');
}
}
/// 권한명 반환 함수는 user_utils.dart의 getRoleName을 사용
/// 회사 ID와 지점 ID로 지점명 조회
// 지점명 조회는 별도 서비스로 이동 예정
String getBranchName(int companyId, int? branchId) {
// TODO: API를 통해 지점명 조회
return '-';
}
// API만 사용하므로 토글 기능 제거
}

View File

@@ -1,137 +1,83 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/services/user_service.dart';
import 'package:superport/core/utils/error_handler.dart';
import 'package:superport/core/controllers/base_list_controller.dart';
import 'package:superport/data/models/common/pagination_params.dart';
/// 담당자 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class UserListController extends ChangeNotifier {
final MockDataService dataService;
final UserService _userService = GetIt.instance<UserService>();
/// 담당자 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
class UserListController extends BaseListController<User> {
late final UserService _userService;
// 상태 변수
List<User> _users = [];
bool _isLoading = false;
String? _error;
bool _useApi = true; // Feature flag
// 페이지네이션
int _currentPage = 1;
final int _perPage = 20;
bool _hasMoreData = true;
bool _isLoadingMore = false;
// 검색/필터
String _searchQuery = '';
// 필터 옵션
int? _filterCompanyId;
String? _filterRole;
bool? _filterIsActive;
// Getters
List<User> get users => _users;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
String? get error => _error;
bool get hasMoreData => _hasMoreData;
String get searchQuery => _searchQuery;
List<User> get users => items;
int? get filterCompanyId => _filterCompanyId;
String? get filterRole => _filterRole;
bool? get filterIsActive => _filterIsActive;
UserListController({required this.dataService});
UserListController() {
if (GetIt.instance.isRegistered<UserService>()) {
_userService = GetIt.instance<UserService>();
} else {
throw Exception('UserService not registered in GetIt');
}
}
/// 사용자 목록 초기 로드
@override
Future<PagedResult<User>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// API 호출
final fetchedUsers = await ErrorHandler.handleApiCall<List<User>>(
() => _userService.getUsers(
page: params.page,
perPage: params.perPage,
isActive: _filterIsActive,
companyId: _filterCompanyId,
role: _filterRole,
// search 파라미터 제거 (API에서 지원하지 않음)
),
onError: (failure) {
throw failure;
},
);
final items = fetchedUsers ?? [];
// 임시로 메타데이터 생성 (추후 API에서 실제 메타데이터 반환 시 수정)
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: items.length < params.perPage ?
(params.page - 1) * params.perPage + items.length :
params.page * params.perPage + 1,
totalPages: items.length < params.perPage ? params.page : params.page + 1,
hasNext: items.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: items, meta: meta);
}
@override
bool filterItem(User item, String query) {
final q = query.toLowerCase();
return item.name.toLowerCase().contains(q) ||
(item.email?.toLowerCase().contains(q) ?? false) ||
(item.username?.toLowerCase().contains(q) ?? false);
}
/// 사용자 목록 초기 로드 (호환성 유지)
Future<void> loadUsers({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
_hasMoreData = true;
_users.clear();
}
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (_useApi) {
final newUsers = await _userService.getUsers(
page: _currentPage,
perPage: _perPage,
isActive: _filterIsActive,
companyId: _filterCompanyId,
role: _filterRole,
);
if (newUsers.isEmpty || newUsers.length < _perPage) {
_hasMoreData = false;
}
if (_currentPage == 1) {
_users = newUsers;
} else {
_users.addAll(newUsers);
}
_currentPage++;
} else {
// Mock 데이터 사용
var allUsers = dataService.getAllUsers();
// 필터 적용
if (_filterCompanyId != null) {
allUsers = allUsers.where((u) => u.companyId == _filterCompanyId).toList();
}
if (_filterRole != null) {
allUsers = allUsers.where((u) => u.role == _filterRole).toList();
}
if (_filterIsActive != null) {
allUsers = allUsers.where((u) => u.isActive == _filterIsActive).toList();
}
// 검색 적용
if (_searchQuery.isNotEmpty) {
allUsers = allUsers.where((u) =>
u.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
(u.email?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false) ||
(u.username?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false)
).toList();
}
_users = allUsers;
_hasMoreData = false;
}
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 다음 페이지 로드 (무한 스크롤용)
Future<void> loadMore() async {
if (!_hasMoreData || _isLoadingMore || _isLoading) return;
_isLoadingMore = true;
notifyListeners();
try {
await loadUsers();
} finally {
_isLoadingMore = false;
notifyListeners();
}
}
/// 검색 쿼리 설정
void setSearchQuery(String query) {
_searchQuery = query;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
await loadData(isRefresh: refresh);
}
/// 필터 설정
@@ -143,9 +89,7 @@ class UserListController extends ChangeNotifier {
_filterCompanyId = companyId;
_filterRole = role;
_filterIsActive = isActive;
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
loadData(isRefresh: true);
}
/// 필터 초기화
@@ -153,69 +97,126 @@ class UserListController extends ChangeNotifier {
_filterCompanyId = null;
_filterRole = null;
_filterIsActive = null;
_searchQuery = '';
_currentPage = 1;
_hasMoreData = true;
loadUsers(refresh: true);
search('');
loadData(isRefresh: true);
}
/// 회사별 필터링
void filterByCompany(int? companyId) {
_filterCompanyId = companyId;
loadData(isRefresh: true);
}
/// 역할별 필터링
void filterByRole(String? role) {
_filterRole = role;
loadData(isRefresh: true);
}
/// 활성 상태별 필터링
void filterByActiveStatus(bool? isActive) {
_filterIsActive = isActive;
loadData(isRefresh: true);
}
/// 사용자 추가
Future<void> addUser(User user) async {
await ErrorHandler.handleApiCall<void>(
() => _userService.createUser(
username: user.username ?? '',
email: user.email ?? '',
password: 'temp123', // 임시 비밀번호
name: user.name,
role: user.role,
companyId: user.companyId,
branchId: user.branchId,
),
onError: (failure) {
throw failure;
},
);
await refresh();
}
/// 사용자 수정
Future<void> updateUser(User user) async {
await ErrorHandler.handleApiCall<void>(
() => _userService.updateUser(
user.id!,
name: user.name,
email: user.email,
companyId: user.companyId,
branchId: user.branchId,
role: user.role,
position: user.position,
),
onError: (failure) {
throw failure;
},
);
updateItemLocally(user, (u) => u.id == user.id);
}
/// 사용자 삭제
Future<void> deleteUser(int id, VoidCallback onDeleted, Function(String) onError) async {
try {
if (_useApi) {
await _userService.deleteUser(id);
} else {
dataService.deleteUser(id);
}
// 목록에서 삭제된 사용자 제거
_users.removeWhere((user) => user.id == id);
notifyListeners();
onDeleted();
} catch (e) {
onError('사용자 삭제 실패: ${e.toString()}');
}
}
/// 사용자 상태 변경 (활성/비활성)
Future<void> changeUserStatus(int id, bool isActive, Function(String) onError) async {
try {
if (_useApi) {
final updatedUser = await _userService.changeUserStatus(id, isActive);
// 목록에서 해당 사용자 업데이트
final index = _users.indexWhere((u) => u.id == id);
if (index != -1) {
_users[index] = updatedUser;
notifyListeners();
}
} else {
// Mock 데이터에서는 상태 변경 지원 안함
onError('Mock 데이터에서는 상태 변경을 지원하지 않습니다');
}
} catch (e) {
onError('상태 변경 실패: ${e.toString()}');
}
}
/// 권한명 반환 함수는 user_utils.dart의 getRoleName을 사용
/// 회사 ID와 지점 ID로 지점명 조회
String getBranchName(int companyId, int? branchId) {
final company = dataService.getCompanyById(companyId);
if (company == null || company.branches == null || branchId == null) {
return '-';
}
final branch = company.branches!.firstWhere(
(b) => b.id == branchId,
orElse: () => Branch(companyId: companyId, name: '-'),
Future<void> deleteUser(int id) async {
await ErrorHandler.handleApiCall<void>(
() => _userService.deleteUser(id),
onError: (failure) {
throw failure;
},
);
return branch.name;
removeItemLocally((u) => u.id == id);
}
/// API/Mock 모드 전환
void toggleApiMode() {
_useApi = !_useApi;
loadUsers(refresh: true);
/// 사용자 활성/비활성 토글
Future<void> toggleUserActiveStatus(User user) async {
// TODO: User 모델에 copyWith 메서드가 없어서 임시로 주석 처리
// final updatedUser = user.copyWith(isActive: !user.isActive);
// await updateUser(updatedUser);
debugPrint('사용자 활성 상태 토글: ${user.name}');
}
}
/// 비밀번호 재설정
Future<void> resetPassword(int userId, String newPassword) async {
await ErrorHandler.handleApiCall<void>(
() => _userService.resetPassword(
userId: userId,
newPassword: newPassword,
),
onError: (failure) {
throw failure;
},
);
}
/// 사용자 ID로 단일 사용자 조회
User? getUserById(int id) {
try {
return items.firstWhere((user) => user.id == id);
} catch (e) {
return null;
}
}
/// 검색 쿼리 설정 (호환성 유지)
void setSearchQuery(String query) {
search(query); // BaseListController의 search 메서드 사용
}
/// 사용자 상태 변경
Future<void> changeUserStatus(User user, bool isActive) async {
// TODO: User 모델에 copyWith 메서드가 없어서 임시로 주석 처리
// final updatedUser = user.copyWith(isActive: isActive);
// await updateUser(updatedUser);
debugPrint('사용자 상태 변경: ${user.name} -> $isActive');
}
/// 지점명 가져오기 (임시 구현)
String getBranchName(int? branchId) {
if (branchId == null) return '본사';
return '지점 $branchId'; // 실제로는 CompanyService에서 가져와야 함
}
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/utils/validators.dart';
import 'package:flutter/services.dart';
@@ -34,7 +33,6 @@ class _UserFormScreenState extends State<UserFormScreen> {
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => UserFormController(
dataService: MockDataService(),
userId: widget.userId,
),
child: Consumer<UserFormController>(

View File

@@ -7,7 +7,6 @@ import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/user/controllers/user_list_controller.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/user_utils.dart';
/// shadcn/ui 스타일로 재설계된 사용자 관리 화면
@@ -19,7 +18,7 @@ class UserListRedesign extends StatefulWidget {
}
class _UserListRedesignState extends State<UserListRedesign> {
final MockDataService _dataService = MockDataService();
// MockDataService 제거 - 실제 API 사용
final TextEditingController _searchController = TextEditingController();
int _currentPage = 1;
final int _pageSize = 10;
@@ -60,8 +59,8 @@ class _UserListRedesignState extends State<UserListRedesign> {
/// 회사명 반환 함수
String _getCompanyName(int companyId) {
final company = _dataService.getCompanyById(companyId);
return company?.name ?? '-';
// TODO: CompanyService를 통해 회사 정보 가져오기
return '회사 $companyId'; // 임시 처리
}
/// 상태별 색상 반환
@@ -128,18 +127,9 @@ class _UserListRedesignState extends State<UserListRedesign> {
onPressed: () async {
Navigator.of(context).pop();
await context.read<UserListController>().deleteUser(
userId,
() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('사용자가 삭제되었습니다')),
);
},
(error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.red),
);
},
await context.read<UserListController>().deleteUser(userId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('사용자가 삭제되었습니다')),
);
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
@@ -168,15 +158,7 @@ class _UserListRedesignState extends State<UserListRedesign> {
onPressed: () async {
Navigator.of(context).pop();
await context.read<UserListController>().changeUserStatus(
user.id!,
newStatus,
(error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error), backgroundColor: Colors.red),
);
},
);
await context.read<UserListController>().changeUserStatus(user, newStatus);
},
child: Text(statusText),
),
@@ -188,7 +170,7 @@ class _UserListRedesignState extends State<UserListRedesign> {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => UserListController(dataService: _dataService),
create: (_) => UserListController(),
child: Consumer<UserListController>(
builder: (context, controller, child) {
if (controller.isLoading && controller.users.isEmpty) {
@@ -494,10 +476,7 @@ class _UserListRedesignState extends State<UserListRedesign> {
Expanded(
flex: 2,
child: Text(
controller.getBranchName(
user.companyId,
user.branchId,
),
controller.getBranchName(user.branchId),
style: ShadcnTheme.bodySmall,
),
),

View File

@@ -3,12 +3,9 @@ import 'package:get_it/get_it.dart';
import 'package:superport/models/warehouse_location_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/warehouse_service.dart';
import 'package:superport/services/mock_data_service.dart';
/// 입고지 폼 상태 및 저장/수정 로직을 담당하는 컨트롤러
class WarehouseLocationFormController extends ChangeNotifier {
final bool useApi;
final MockDataService? mockDataService;
late final WarehouseService _warehouseService;
/// 폼 키
@@ -42,12 +39,12 @@ class WarehouseLocationFormController extends ChangeNotifier {
WarehouseLocation? _originalLocation;
WarehouseLocationFormController({
this.useApi = true,
this.mockDataService,
int? locationId,
}) {
if (useApi && GetIt.instance.isRegistered<WarehouseService>()) {
if (GetIt.instance.isRegistered<WarehouseService>()) {
_warehouseService = GetIt.instance<WarehouseService>();
} else {
throw Exception('WarehouseService not registered in GetIt');
}
if (locationId != null) {
@@ -73,11 +70,7 @@ class WarehouseLocationFormController extends ChangeNotifier {
notifyListeners();
try {
if (useApi && GetIt.instance.isRegistered<WarehouseService>()) {
_originalLocation = await _warehouseService.getWarehouseLocationById(locationId);
} else {
_originalLocation = mockDataService?.getWarehouseLocationById(locationId);
}
_originalLocation = await _warehouseService.getWarehouseLocationById(locationId);
if (_originalLocation != null) {
nameController.text = _originalLocation!.name;
@@ -114,18 +107,10 @@ class WarehouseLocationFormController extends ChangeNotifier {
remark: remarkController.text.trim().isEmpty ? null : remarkController.text.trim(),
);
if (useApi && GetIt.instance.isRegistered<WarehouseService>()) {
if (_isEditMode) {
await _warehouseService.updateWarehouseLocation(location);
} else {
await _warehouseService.createWarehouseLocation(location);
}
if (_isEditMode) {
await _warehouseService.updateWarehouseLocation(location);
} else {
if (_isEditMode) {
mockDataService?.updateWarehouseLocation(location);
} else {
mockDataService?.addWarehouseLocation(location);
}
await _warehouseService.createWarehouseLocation(location);
}
return true;

View File

@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/warehouse_location_model.dart';
import 'package:superport/services/warehouse_service.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/core/utils/error_handler.dart';
/// 입고지 리스트 상태 및 CRUD만 담당하는 컨트롤러 클래스 (SRP 적용)
/// UI, 네비게이션, 다이얼로그 등은 포함하지 않음
/// 향후 서비스/리포지토리 DI 구조로 확장 가능
class WarehouseLocationListController extends ChangeNotifier {
late final WarehouseService _warehouseService;
List<WarehouseLocation> _warehouseLocations = [];
List<WarehouseLocation> _filteredLocations = [];
bool _isLoading = false;
String? _error;
String _searchQuery = '';
int _currentPage = 1;
final int _pageSize = 20;
bool _hasMore = true;
int _total = 0;
// 필터 옵션
bool? _isActive;
WarehouseLocationListController() {
if (GetIt.instance.isRegistered<WarehouseService>()) {
_warehouseService = GetIt.instance<WarehouseService>();
} else {
throw Exception('WarehouseService not registered');
}
}
// Getters
List<WarehouseLocation> get warehouseLocations => _filteredLocations;
bool get isLoading => _isLoading;
String? get error => _error;
String get searchQuery => _searchQuery;
int get currentPage => _currentPage;
bool get hasMore => _hasMore;
int get total => _total;
bool? get isActive => _isActive;
/// 데이터 로드
Future<void> loadWarehouseLocations({bool isInitialLoad = true}) async {
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
// API 사용 시 ErrorHandler 적용
print('╔══════════════════════════════════════════════════════════');
print('║ 🏭 입고지 목록 API 호출 시작');
print('║ • 활성 필터: ${_isActive != null ? (_isActive! ? "활성" : "비활성") : "전체"}');
print('╚══════════════════════════════════════════════════════════');
final fetchedLocations = await ErrorHandler.handleApiCall<List<WarehouseLocation>>(
() => _warehouseService.getWarehouseLocations(
page: 1,
perPage: 1000, // 충분히 큰 값으로 전체 데이터 로드
isActive: _isActive,
),
onError: (failure) {
_error = ErrorHandler.getUserFriendlyMessage(failure);
print('[WarehouseLocationListController] API 에러: ${failure.message}');
},
);
if (fetchedLocations != null) {
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 입고지 목록 로드 완료');
print('║ ▶ 총 입고지 수: ${fetchedLocations.length}');
print('╟──────────────────────────────────────────────────────────');
// 상태별 통계 (입고지에 상태가 있다면)
int activeCount = 0;
int inactiveCount = 0;
for (final location in fetchedLocations) {
// isActive 필드가 있다면 활용
activeCount++; // 현재는 모두 활성으로 가정
}
print('║ • 활성 입고지: $activeCount개');
if (inactiveCount > 0) {
print('║ • 비활성 입고지: $inactiveCount개');
}
print('╟──────────────────────────────────────────────────────────');
print('║ 📑 전체 데이터 로드 완료');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
_warehouseLocations = fetchedLocations;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
_total = fetchedLocations.length;
_applySearchFilter();
print('[WarehouseLocationListController] After filtering: ${_filteredLocations.length} locations shown');
}
_isLoading = false;
notifyListeners();
}
// 다음 페이지 로드
Future<void> loadNextPage() async {
if (!_hasMore || _isLoading) return;
await loadWarehouseLocations(isInitialLoad: false);
}
// 검색
void search(String query) {
_searchQuery = query;
_applySearchFilter();
notifyListeners();
}
// 검색 필터 적용
void _applySearchFilter() {
if (_searchQuery.isEmpty) {
_filteredLocations = List.from(_warehouseLocations);
} else {
_filteredLocations = _warehouseLocations.where((location) {
return location.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
location.address.toString().toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
// 필터 설정
void setFilters({bool? isActive}) {
_isActive = isActive;
loadWarehouseLocations();
}
// 필터 초기화
void clearFilters() {
_isActive = null;
_searchQuery = '';
loadWarehouseLocations();
}
/// 입고지 추가
Future<void> addWarehouseLocation(WarehouseLocation location) async {
await ErrorHandler.handleApiCall<void>(
() => _warehouseService.createWarehouseLocation(location),
onError: (failure) {
_error = ErrorHandler.getUserFriendlyMessage(failure);
notifyListeners();
},
);
// 목록 새로고침
await loadWarehouseLocations();
}
/// 입고지 수정
Future<void> updateWarehouseLocation(WarehouseLocation location) async {
await ErrorHandler.handleApiCall<void>(
() => _warehouseService.updateWarehouseLocation(location),
onError: (failure) {
_error = ErrorHandler.getUserFriendlyMessage(failure);
notifyListeners();
},
);
// 목록에서 업데이트
final index = _warehouseLocations.indexWhere((l) => l.id == location.id);
if (index != -1) {
_warehouseLocations[index] = location;
_applySearchFilter();
notifyListeners();
}
}
/// 입고지 삭제
Future<void> deleteWarehouseLocation(int id) async {
await ErrorHandler.handleApiCall<void>(
() => _warehouseService.deleteWarehouseLocation(id),
onError: (failure) {
_error = ErrorHandler.getUserFriendlyMessage(failure);
notifyListeners();
},
);
// 목록에서 제거
_warehouseLocations.removeWhere((l) => l.id == id);
_applySearchFilter();
_total--;
notifyListeners();
}
// 새로고침
Future<void> refresh() async {
await loadWarehouseLocations();
}
// 사용 중인 창고 위치 조회
Future<List<WarehouseLocation>> getInUseWarehouseLocations() async {
final locations = await ErrorHandler.handleApiCall<List<WarehouseLocation>>(
() => _warehouseService.getInUseWarehouseLocations(),
onError: (failure) {
_error = ErrorHandler.getUserFriendlyMessage(failure);
notifyListeners();
},
);
return locations ?? [];
}
}

View File

@@ -2,265 +2,135 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/warehouse_location_model.dart';
import 'package:superport/services/warehouse_service.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/core/utils/error_handler.dart';
import 'package:superport/core/controllers/base_list_controller.dart';
import 'package:superport/data/models/common/pagination_params.dart';
/// 입고지 리스트 상태 및 CRUD만 담당하는 컨트롤러 클래스 (SRP 적용)
/// UI, 네비게이션, 다이얼로그 등은 포함하지 않음
/// 향후 서비스/리포지토리 DI 구조로 확장 가능
class WarehouseLocationListController extends ChangeNotifier {
final bool useApi;
final MockDataService? mockDataService;
WarehouseService? _warehouseService;
/// 입고지 리스트 상태 및 CRUD만 담당하는 컨트롤러 클래스 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
class WarehouseLocationListController extends BaseListController<WarehouseLocation> {
late final WarehouseService _warehouseService;
List<WarehouseLocation> _warehouseLocations = [];
List<WarehouseLocation> _filteredLocations = [];
bool _isLoading = false;
String? _error;
String _searchQuery = '';
int _currentPage = 1;
final int _pageSize = 20;
bool _hasMore = true;
int _total = 0;
// 필터 옵션
bool? _isActive;
WarehouseLocationListController({this.useApi = true, this.mockDataService}) {
if (useApi && GetIt.instance.isRegistered<WarehouseService>()) {
WarehouseLocationListController() {
if (GetIt.instance.isRegistered<WarehouseService>()) {
_warehouseService = GetIt.instance<WarehouseService>();
} else {
throw Exception('WarehouseService not registered in GetIt');
}
}
// Getters
List<WarehouseLocation> get warehouseLocations => _filteredLocations;
bool get isLoading => _isLoading;
String? get error => _error;
String get searchQuery => _searchQuery;
int get currentPage => _currentPage;
bool get hasMore => _hasMore;
int get total => _total;
// 추가 Getters
List<WarehouseLocation> get warehouseLocations => items;
bool? get isActive => _isActive;
/// 데이터 로드
@override
Future<PagedResult<WarehouseLocation>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// API 사용
final fetchedLocations = await ErrorHandler.handleApiCall<List<WarehouseLocation>>(
() => _warehouseService.getWarehouseLocations(
page: params.page,
perPage: params.perPage,
isActive: _isActive,
),
onError: (failure) {
throw failure;
},
);
final items = fetchedLocations ?? [];
// 임시로 메타데이터 생성 (추후 API에서 실제 메타데이터 반환 시 수정)
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: items.length < params.perPage ?
(params.page - 1) * params.perPage + items.length :
params.page * params.perPage + 1,
totalPages: items.length < params.perPage ? params.page : params.page + 1,
hasNext: items.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: items, meta: meta);
}
@override
bool filterItem(WarehouseLocation item, String query) {
return item.name.toLowerCase().contains(query.toLowerCase()) ||
item.address.toString().toLowerCase().contains(query.toLowerCase());
}
/// 데이터 로드 (호환성을 위해 유지)
Future<void> loadWarehouseLocations({bool isInitialLoad = true}) async {
if (_isLoading) return;
_isLoading = true;
_error = null;
notifyListeners();
try {
if (useApi && _warehouseService != null) {
// API 사용 - 전체 데이터 로드
print('╔══════════════════════════════════════════════════════════');
print('║ 🏭 입고지 목록 API 호출 시작');
print('║ • 활성 필터: ${_isActive != null ? (_isActive! ? "활성" : "비활성") : "전체"}');
print('╚══════════════════════════════════════════════════════════');
// 전체 데이터를 가져오기 위해 큰 perPage 값 사용
final fetchedLocations = await _warehouseService!.getWarehouseLocations(
page: 1,
perPage: 1000, // 충분히 큰 값으로 전체 데이터 로드
isActive: _isActive,
);
print('╔══════════════════════════════════════════════════════════');
print('║ 📊 입고지 목록 로드 완료');
print('║ ▶ 총 입고지 수: ${fetchedLocations.length}');
print('╟──────────────────────────────────────────────────────────');
// 상태별 통계 (입고지에 상태가 있다면)
int activeCount = 0;
int inactiveCount = 0;
for (final location in fetchedLocations) {
// isActive 필드가 있다면 활용
activeCount++; // 현재는 모두 활성으로 가정
}
print('║ • 활성 입고지: $activeCount개');
if (inactiveCount > 0) {
print('║ • 비활성 입고지: $inactiveCount개');
}
print('╟──────────────────────────────────────────────────────────');
print('║ 📑 전체 데이터 로드 완료');
print('║ • View에서 페이지네이션 처리 예정');
print('╚══════════════════════════════════════════════════════════');
_warehouseLocations = fetchedLocations;
_hasMore = false; // 전체 데이터를 로드했으므로 더 이상 로드할 필요 없음
_total = fetchedLocations.length;
} else {
// Mock 데이터 사용
print('[WarehouseLocationListController] Using Mock data');
final allLocations = mockDataService?.getAllWarehouseLocations() ?? [];
print('[WarehouseLocationListController] Mock data has ${allLocations.length} locations');
// 필터링 적용
var filtered = allLocations;
if (_isActive != null) {
// Mock 데이터에는 isActive 필드가 없으므로 모두 활성으로 처리
filtered = _isActive! ? allLocations : [];
}
// 페이지네이션 적용
final startIndex = (_currentPage - 1) * _pageSize;
final endIndex = startIndex + _pageSize;
if (startIndex < filtered.length) {
final pageLocations = filtered.sublist(
startIndex,
endIndex > filtered.length ? filtered.length : endIndex,
);
if (isInitialLoad) {
_warehouseLocations = pageLocations;
} else {
_warehouseLocations.addAll(pageLocations);
}
_hasMore = endIndex < filtered.length;
} else {
_hasMore = false;
}
_total = filtered.length;
}
_applySearchFilter();
print('[WarehouseLocationListController] After filtering: ${_filteredLocations.length} locations shown');
} catch (e, stackTrace) {
print('[WarehouseLocationListController] Error loading warehouse locations: $e');
print('[WarehouseLocationListController] Error type: ${e.runtimeType}');
print('[WarehouseLocationListController] Stack trace: $stackTrace');
if (e is ServerFailure) {
_error = e.message;
} else {
_error = '오류 발생: ${e.toString()}';
}
} finally {
_isLoading = false;
notifyListeners();
}
}
// 다음 페이지 로드
Future<void> loadNextPage() async {
if (!_hasMore || _isLoading) return;
await loadWarehouseLocations(isInitialLoad: false);
}
// 검색
void search(String query) {
_searchQuery = query;
_applySearchFilter();
notifyListeners();
}
// 검색 필터 적용
void _applySearchFilter() {
if (_searchQuery.isEmpty) {
_filteredLocations = List.from(_warehouseLocations);
} else {
_filteredLocations = _warehouseLocations.where((location) {
return location.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
location.address.toString().toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
await loadData(isRefresh: isInitialLoad);
}
// 필터 설정
void setFilters({bool? isActive}) {
_isActive = isActive;
loadWarehouseLocations();
loadData(isRefresh: true);
}
// 필터 초기화
void clearFilters() {
_isActive = null;
_searchQuery = '';
loadWarehouseLocations();
search('');
loadData(isRefresh: true);
}
/// 입고지 추가
Future<void> addWarehouseLocation(WarehouseLocation location) async {
try {
if (useApi && _warehouseService != null) {
await _warehouseService!.createWarehouseLocation(location);
} else {
mockDataService?.addWarehouseLocation(location);
}
// 목록 새로고침
await loadWarehouseLocations();
} catch (e) {
_error = e.toString();
notifyListeners();
}
await ErrorHandler.handleApiCall<void>(
() => _warehouseService.createWarehouseLocation(location),
onError: (failure) {
throw failure;
},
);
// 목록 새로고침
await refresh();
}
/// 입고지 수정
Future<void> updateWarehouseLocation(WarehouseLocation location) async {
try {
if (useApi && _warehouseService != null) {
await _warehouseService!.updateWarehouseLocation(location);
} else {
mockDataService?.updateWarehouseLocation(location);
}
// 목록에서 업데이트
final index = _warehouseLocations.indexWhere((l) => l.id == location.id);
if (index != -1) {
_warehouseLocations[index] = location;
_applySearchFilter();
notifyListeners();
}
} catch (e) {
_error = e.toString();
notifyListeners();
}
await ErrorHandler.handleApiCall<void>(
() => _warehouseService.updateWarehouseLocation(location),
onError: (failure) {
throw failure;
},
);
// 로컬 업데이트
updateItemLocally(location, (l) => l.id == location.id);
}
/// 입고지 삭제
Future<void> deleteWarehouseLocation(int id) async {
try {
if (useApi && _warehouseService != null) {
await _warehouseService!.deleteWarehouseLocation(id);
} else {
mockDataService?.deleteWarehouseLocation(id);
}
// 목록에서 제거
_warehouseLocations.removeWhere((l) => l.id == id);
_applySearchFilter();
_total--;
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
// 새로고침
Future<void> refresh() async {
await loadWarehouseLocations();
await ErrorHandler.handleApiCall<void>(
() => _warehouseService.deleteWarehouseLocation(id),
onError: (failure) {
throw failure;
},
);
// 로컬 삭제
removeItemLocally((l) => l.id == id);
}
// 사용 중인 창고 위치 조회
Future<List<WarehouseLocation>> getInUseWarehouseLocations() async {
try {
if (useApi && _warehouseService != null) {
return await _warehouseService!.getInUseWarehouseLocations();
} else {
// Mock 데이터에서는 모든 창고가 사용 중으로 간주
return mockDataService?.getAllWarehouseLocations() ?? [];
}
} catch (e) {
_error = e.toString();
notifyListeners();
return [];
}
final locations = await ErrorHandler.handleApiCall<List<WarehouseLocation>>(
() => _warehouseService.getInUseWarehouseLocations(),
onError: (failure) {
throw failure;
},
);
return locations ?? [];
}
}
}

View File

@@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import '../../../core/controllers/base_list_controller.dart';
import '../../../core/utils/error_handler.dart';
import '../../../data/models/common/pagination_params.dart';
import '../../../data/models/warehouse/warehouse_dto.dart';
import '../../../domain/usecases/warehouse_location/warehouse_location_usecases.dart';
/// UseCase 패턴을 적용한 창고 위치 목록 컨트롤러
class WarehouseLocationListControllerWithUseCase extends BaseListController<WarehouseLocationDto> {
final GetWarehouseLocationsUseCase getWarehouseLocationsUseCase;
final CreateWarehouseLocationUseCase createWarehouseLocationUseCase;
final UpdateWarehouseLocationUseCase updateWarehouseLocationUseCase;
final DeleteWarehouseLocationUseCase deleteWarehouseLocationUseCase;
// 선택된 항목들
final Set<int> _selectedLocationIds = {};
Set<int> get selectedLocationIds => _selectedLocationIds;
// 필터 옵션
bool _showActiveOnly = true;
String? _filterByManager;
bool get showActiveOnly => _showActiveOnly;
String? get filterByManager => _filterByManager;
WarehouseLocationListControllerWithUseCase({
required this.getWarehouseLocationsUseCase,
required this.createWarehouseLocationUseCase,
required this.updateWarehouseLocationUseCase,
required this.deleteWarehouseLocationUseCase,
});
@override
Future<PagedResult<WarehouseLocationDto>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
try {
// 필터 파라미터 구성
final filters = <String, dynamic>{};
if (_showActiveOnly) filters['is_active'] = true;
if (_filterByManager != null) filters['manager'] = _filterByManager;
final updatedParams = params.copyWith(filters: filters);
final getParams = GetWarehouseLocationsParams.fromPaginationParams(updatedParams);
final result = await getWarehouseLocationsUseCase(getParams);
return result.fold(
(failure) => throw Exception(failure.message),
(locationsResponse) {
// PagedResult로 래핑하여 반환
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: locationsResponse.items.length,
totalPages: (locationsResponse.items.length / params.perPage).ceil(),
hasNext: locationsResponse.items.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: locationsResponse.items, meta: meta);
},
);
} catch (e) {
throw Exception('데이터 로드 실패: $e');
}
}
/// 창고 위치 생성
Future<void> createWarehouseLocation({
required String name,
required String address,
String? description,
String? contactNumber,
String? manager,
double? latitude,
double? longitude,
}) async {
try {
isLoadingState = true;
final params = CreateWarehouseLocationParams(
name: name,
address: address,
description: description,
contactNumber: contactNumber,
manager: manager,
latitude: latitude,
longitude: longitude,
);
final result = await createWarehouseLocationUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(location) async => await refresh(),
);
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 창고 위치 수정
Future<void> updateWarehouseLocation({
required int id,
String? name,
String? address,
String? description,
String? contactNumber,
String? manager,
double? latitude,
double? longitude,
bool? isActive,
}) async {
try {
isLoadingState = true;
final params = UpdateWarehouseLocationParams(
id: id,
name: name,
address: address,
description: description,
contactNumber: contactNumber,
manager: manager,
latitude: latitude,
longitude: longitude,
isActive: isActive,
);
final result = await updateWarehouseLocationUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(location) async => updateItemLocally(location, (item) => item.id == location.id),
);
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 창고 위치 삭제
Future<void> deleteWarehouseLocation(int id) async {
try {
isLoadingState = true;
final result = await deleteWarehouseLocationUseCase(id);
await result.fold(
(failure) async => errorState = failure.message,
(_) async {
removeItemLocally((item) => item.id == id);
_selectedLocationIds.remove(id);
},
);
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 창고 위치 활성/비활성 토글
Future<void> toggleLocationStatus(int id) async {
final location = items.firstWhere((item) => item.id == id);
await updateWarehouseLocation(
id: id,
isActive: !location.isActive,
);
}
/// 필터 설정
void setFilters({
bool? showActiveOnly,
String? manager,
}) {
if (showActiveOnly != null) _showActiveOnly = showActiveOnly;
_filterByManager = manager;
refresh();
}
/// 필터 초기화
void clearFilters() {
_showActiveOnly = true;
_filterByManager = null;
refresh();
}
/// 창고 위치 선택 토글
void toggleLocationSelection(int id) {
if (_selectedLocationIds.contains(id)) {
_selectedLocationIds.remove(id);
} else {
_selectedLocationIds.add(id);
}
notifyListeners();
}
/// 모든 창고 위치 선택
void selectAll() {
_selectedLocationIds.clear();
_selectedLocationIds.addAll(items.map((e) => e.id));
notifyListeners();
}
/// 선택 해제
void clearSelection() {
_selectedLocationIds.clear();
notifyListeners();
}
/// 선택된 창고 위치 일괄 삭제
Future<void> deleteSelectedLocations() async {
if (_selectedLocationIds.isEmpty) return;
try {
isLoadingState = true;
final errors = <String>[];
for (final id in _selectedLocationIds.toList()) {
final result = await deleteWarehouseLocationUseCase(id);
result.fold(
(failure) => errors.add('Location $id: ${failure.message}'),
(_) => removeItemLocally((item) => item.id == id),
);
}
_selectedLocationIds.clear();
if (errors.isNotEmpty) {
errorState = '일부 창고 위치 삭제 실패:\n${errors.join('\n')}';
}
notifyListeners();
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 선택된 창고 위치 일괄 활성화
Future<void> activateSelectedLocations() async {
if (_selectedLocationIds.isEmpty) return;
try {
isLoadingState = true;
for (final id in _selectedLocationIds.toList()) {
await updateWarehouseLocation(id: id, isActive: true);
}
_selectedLocationIds.clear();
notifyListeners();
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 선택된 창고 위치 일괄 비활성화
Future<void> deactivateSelectedLocations() async {
if (_selectedLocationIds.isEmpty) return;
try {
isLoadingState = true;
for (final id in _selectedLocationIds.toList()) {
await updateWarehouseLocation(id: id, isActive: false);
}
_selectedLocationIds.clear();
notifyListeners();
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 드롭다운용 활성 창고 위치 목록 가져오기
List<WarehouseLocationDto> getActiveLocations() {
return items.where((location) => location.isActive).toList();
}
/// 특정 관리자의 창고 위치 목록 가져오기
List<WarehouseLocationDto> getLocationsByManager(String manager) {
return items.where((location) => location.managerName == manager).toList(); // managerName 필드 사용
}
@override
void dispose() {
_selectedLocationIds.clear();
super.dispose();
}
}

View File

@@ -11,6 +11,7 @@ import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/warehouse_location/controllers/warehouse_location_list_controller.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/widgets/auth_guard.dart';
/// shadcn/ui 스타일로 재설계된 입고지 관리 화면
class WarehouseLocationListRedesign extends StatefulWidget {
@@ -99,10 +100,13 @@ class _WarehouseLocationListRedesignState
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _controller,
child: Consumer<WarehouseLocationListController>(
builder: (context, controller, child) {
// Admin과 Manager만 접근 가능
return AuthGuard(
allowedRoles: UserRole.adminAndManager,
child: ChangeNotifierProvider.value(
value: _controller,
child: Consumer<WarehouseLocationListController>(
builder: (context, controller, child) {
final int totalCount = controller.warehouseLocations.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
@@ -161,6 +165,7 @@ class _WarehouseLocationListRedesignState
) : null,
);
},
),
),
);
}