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

@@ -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) {