사용하지 않는 파일 정리 전 백업 (Phase 10 완료 후 상태)

This commit is contained in:
JiWoong Sul
2025-08-29 15:11:59 +09:00
parent a740ff10c8
commit d916b281a7
333 changed files with 53617 additions and 22574 deletions

View File

@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import '../../../data/models/equipment/equipment_dto.dart';
import '../../../data/models/company/company_dto.dart';
import '../../../data/models/model_dto.dart';
import '../../../domain/usecases/equipment/create_equipment_usecase.dart';
import '../../../domain/usecases/equipment/update_equipment_usecase.dart';
import '../../../domain/usecases/equipment/get_equipment_detail_usecase.dart';
import '../../../domain/usecases/company/get_companies_usecase.dart';
import '../../../domain/usecases/model_usecase.dart';
import '../../../core/errors/failures.dart';
/// 장비 폼 컨트롤러 (생성/수정)
/// Phase 8 - Clean Architecture 패턴, 복합 FK 지원
@injectable
class EquipmentFormController extends ChangeNotifier {
final CreateEquipmentUseCase _createEquipmentUseCase;
final UpdateEquipmentUseCase _updateEquipmentUseCase;
final GetEquipmentDetailUseCase _getEquipmentDetailUseCase;
final GetCompaniesUseCase _getCompaniesUseCase;
final ModelUseCase _modelUseCase;
EquipmentFormController(
this._createEquipmentUseCase,
this._updateEquipmentUseCase,
this._getEquipmentDetailUseCase,
this._getCompaniesUseCase,
this._modelUseCase,
);
// 상태 관리
bool _isLoading = false;
bool _isLoadingCompanies = false;
bool _isLoadingModels = false;
bool _isSaving = false;
String? _error;
// 폼 데이터
EquipmentDto? _currentEquipment;
int? _equipmentId; // 수정 모드일 때 사용
// 드롭다운 데이터
List<CompanyDto> _companies = [];
List<ModelDto> _models = [];
List<ModelDto> _filteredModels = [];
// 선택된 값
int? _selectedCompanyId;
int? _selectedModelId;
// 폼 컨트롤러들
final TextEditingController serialNumberController = TextEditingController();
final TextEditingController barcodeController = TextEditingController();
final TextEditingController purchasePriceController = TextEditingController();
final TextEditingController warrantyNumberController = TextEditingController();
final TextEditingController remarkController = TextEditingController();
// 날짜 필드들
DateTime? _purchasedAt;
DateTime _warrantyStartedAt = DateTime.now();
DateTime _warrantyEndedAt = DateTime.now().add(const Duration(days: 365));
// Getters
bool get isLoading => _isLoading;
bool get isLoadingCompanies => _isLoadingCompanies;
bool get isLoadingModels => _isLoadingModels;
bool get isSaving => _isSaving;
String? get error => _error;
EquipmentDto? get currentEquipment => _currentEquipment;
bool get isEditMode => _equipmentId != null;
List<CompanyDto> get companies => _companies;
List<ModelDto> get filteredModels => _filteredModels;
int? get selectedCompanyId => _selectedCompanyId;
int? get selectedModelId => _selectedModelId;
DateTime? get purchasedAt => _purchasedAt;
DateTime get warrantyStartedAt => _warrantyStartedAt;
DateTime get warrantyEndedAt => _warrantyEndedAt;
/// 초기화 (생성 모드)
Future<void> initializeForCreate() async {
_equipmentId = null;
_currentEquipment = null;
_clearForm();
await _loadInitialData();
}
/// 초기화 (수정 모드)
Future<void> initializeForEdit(int equipmentId) async {
_equipmentId = equipmentId;
_isLoading = true;
_error = null;
notifyListeners();
try {
await _loadInitialData();
await _loadEquipmentDetail(equipmentId);
} catch (e) {
_error = '장비 정보를 불러오는데 실패했습니다: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 초기 데이터 로드 (회사, 모델)
Future<void> _loadInitialData() async {
await Future.wait([
_loadCompanies(),
_loadModels(),
]);
}
/// 회사 목록 로드
Future<void> _loadCompanies() async {
_isLoadingCompanies = true;
notifyListeners();
try {
final params = GetCompaniesParams(page: 1, perPage: 1000); // 모든 회사 가져오기
final result = await _getCompaniesUseCase(params);
result.fold(
(failure) {
_error = _getErrorMessage(failure);
},
(paginatedResponse) {
_companies = paginatedResponse.items
.cast<CompanyDto>() // 타입 캐스팅 추가
.where((company) => company.isActive)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
},
);
} catch (e) {
_error = '회사 목록을 불러오는데 실패했습니다: $e';
} finally {
_isLoadingCompanies = false;
notifyListeners();
}
}
/// 모델 목록 로드
Future<void> _loadModels() async {
_isLoadingModels = true;
notifyListeners();
try {
_models = await _modelUseCase.getModels();
_filteredModels = _models;
} catch (e) {
_error = '모델 목록을 불러오는데 실패했습니다: $e';
} finally {
_isLoadingModels = false;
notifyListeners();
}
}
/// 장비 상세 정보 로드 (수정 모드)
Future<void> _loadEquipmentDetail(int equipmentId) async {
final result = await _getEquipmentDetailUseCase(equipmentId);
result.fold(
(failure) {
_error = _getErrorMessage(failure);
},
(equipment) {
_currentEquipment = equipment;
_populateForm(equipment);
},
);
}
/// 폼 필드에 데이터 채우기 (수정 모드)
void _populateForm(EquipmentDto equipment) {
_selectedCompanyId = equipment.companiesId;
_selectedModelId = equipment.modelsId;
serialNumberController.text = equipment.serialNumber;
barcodeController.text = equipment.barcode ?? '';
purchasePriceController.text = equipment.purchasePrice.toString();
warrantyNumberController.text = equipment.warrantyNumber;
remarkController.text = equipment.remark ?? '';
_purchasedAt = equipment.purchasedAt;
_warrantyStartedAt = equipment.warrantyStartedAt;
_warrantyEndedAt = equipment.warrantyEndedAt;
// 선택된 회사에 따라 모델 필터링
_filterModelsByCompany(_selectedCompanyId);
notifyListeners();
}
/// 회사 선택
void selectCompany(int? companyId) {
_selectedCompanyId = companyId;
_selectedModelId = null; // 모델 선택 초기화
_filterModelsByCompany(companyId);
notifyListeners();
}
/// 모델 선택
void selectModel(int? modelId) {
_selectedModelId = modelId;
notifyListeners();
}
/// 회사별 모델 필터링
void _filterModelsByCompany(int? companyId) {
if (companyId == null) {
_filteredModels = _models;
} else {
// 실제로는 vendor로 필터링해야 하지만,
// 현재 구조에서는 모든 모델을 보여주고 사용자가 선택하도록 함
_filteredModels = _models;
}
notifyListeners();
}
/// 구매일 선택
void setPurchasedAt(DateTime? date) {
_purchasedAt = date;
notifyListeners();
}
/// 워런티 시작일 선택
void setWarrantyStartedAt(DateTime date) {
_warrantyStartedAt = date;
// 시작일이 종료일보다 늦으면 종료일을 1년 후로 설정
if (_warrantyStartedAt.isAfter(_warrantyEndedAt)) {
_warrantyEndedAt = _warrantyStartedAt.add(const Duration(days: 365));
}
notifyListeners();
}
/// 워런티 종료일 선택
void setWarrantyEndedAt(DateTime date) {
_warrantyEndedAt = date;
notifyListeners();
}
/// 폼 유효성 검증
String? validateForm() {
if (_selectedCompanyId == null) {
return '회사를 선택해주세요.';
}
if (_selectedModelId == null) {
return '모델을 선택해주세요.';
}
if (serialNumberController.text.trim().isEmpty) {
return '시리얼 번호를 입력해주세요.';
}
if (warrantyNumberController.text.trim().isEmpty) {
return '워런티 번호를 입력해주세요.';
}
if (_warrantyStartedAt.isAfter(_warrantyEndedAt)) {
return '워런티 시작일이 종료일보다 늦을 수 없습니다.';
}
return null;
}
/// 장비 저장 (생성 또는 수정)
Future<bool> saveEquipment() async {
final validationError = validateForm();
if (validationError != null) {
_error = validationError;
notifyListeners();
return false;
}
_isSaving = true;
_error = null;
notifyListeners();
try {
if (isEditMode) {
return await _updateEquipment();
} else {
return await _createEquipment();
}
} catch (e) {
_error = '저장 중 오류가 발생했습니다: $e';
return false;
} finally {
_isSaving = false;
notifyListeners();
}
}
/// 장비 생성
Future<bool> _createEquipment() async {
final request = EquipmentRequestDto(
companiesId: _selectedCompanyId!,
modelsId: _selectedModelId!,
serialNumber: serialNumberController.text.trim(),
barcode: barcodeController.text.trim().isNotEmpty
? barcodeController.text.trim()
: null,
purchasedAt: _purchasedAt,
purchasePrice: int.tryParse(purchasePriceController.text) ?? 0,
warrantyNumber: warrantyNumberController.text.trim(),
warrantyStartedAt: _warrantyStartedAt,
warrantyEndedAt: _warrantyEndedAt,
remark: remarkController.text.trim().isNotEmpty
? remarkController.text.trim()
: null,
);
final result = await _createEquipmentUseCase(request);
return result.fold(
(failure) {
_error = _getErrorMessage(failure);
return false;
},
(equipment) {
_currentEquipment = equipment;
return true;
},
);
}
/// 장비 수정
Future<bool> _updateEquipment() async {
final request = EquipmentUpdateRequestDto(
companiesId: _selectedCompanyId!,
modelsId: _selectedModelId!,
serialNumber: serialNumberController.text.trim(),
barcode: barcodeController.text.trim().isNotEmpty
? barcodeController.text.trim()
: null,
purchasedAt: _purchasedAt,
purchasePrice: int.tryParse(purchasePriceController.text) ?? 0,
warrantyNumber: warrantyNumberController.text.trim(),
warrantyStartedAt: _warrantyStartedAt,
warrantyEndedAt: _warrantyEndedAt,
remark: remarkController.text.trim().isNotEmpty
? remarkController.text.trim()
: null,
);
final result = await _updateEquipmentUseCase(UpdateEquipmentParams(
id: _equipmentId!,
request: request,
));
return result.fold(
(failure) {
_error = _getErrorMessage(failure);
return false;
},
(equipment) {
_currentEquipment = equipment;
return true;
},
);
}
/// 폼 초기화
void _clearForm() {
_selectedCompanyId = null;
_selectedModelId = null;
serialNumberController.clear();
barcodeController.clear();
purchasePriceController.text = '0';
warrantyNumberController.clear();
remarkController.clear();
_purchasedAt = null;
_warrantyStartedAt = DateTime.now();
_warrantyEndedAt = DateTime.now().add(const Duration(days: 365));
_error = null;
}
/// 에러 메시지 변환
String _getErrorMessage(Failure failure) {
switch (failure.runtimeType) {
case ServerFailure:
return (failure as ServerFailure).message;
case NetworkFailure:
return '네트워크 연결을 확인해주세요.';
case ValidationFailure:
return (failure as ValidationFailure).message;
default:
return '알 수 없는 오류가 발생했습니다.';
}
}
/// 에러 초기화
void clearError() {
_error = null;
notifyListeners();
}
@override
void dispose() {
serialNumberController.dispose();
barcodeController.dispose();
purchasePriceController.dispose();
warrantyNumberController.dispose();
remarkController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../../data/models/equipment_history_dto.dart';
import '../../../domain/usecases/equipment_history_usecase.dart';
import '../../../utils/constants.dart';
class EquipmentHistoryController extends ChangeNotifier {
final EquipmentHistoryUseCase _useCase;
EquipmentHistoryController({
EquipmentHistoryUseCase? useCase,
}) : _useCase = useCase ?? GetIt.instance<EquipmentHistoryUseCase>();
// 상태 변수 (백엔드 스키마 기반)
List<EquipmentHistoryDto> _historyList = [];
bool _isLoading = false;
String? _error;
// 페이지네이션
int _currentPage = 1;
int _pageSize = PaginationConstants.defaultPageSize;
int _totalCount = 0;
// 필터 (백엔드 실제 필드만)
String _searchQuery = '';
String? _transactionType;
int? _warehouseId;
int? _equipmentId;
DateTime? _startDate;
DateTime? _endDate;
// Getters (백엔드 실제 데이터만)
List<EquipmentHistoryDto> get historyList => _historyList;
bool get isLoading => _isLoading;
String? get error => _error;
int get currentPage => _currentPage;
int get totalPages => (_totalCount / _pageSize).ceil();
int get totalCount => _totalCount;
String get searchQuery => _searchQuery;
// 간단한 통계 (백엔드 실제 데이터 기반)
int get totalTransactions => _historyList.length;
int get totalInQuantity => _historyList
.where((item) => item.transactionType == 'I')
.fold(0, (sum, item) => sum + item.quantity);
int get totalOutQuantity => _historyList
.where((item) => item.transactionType == 'O')
.fold(0, (sum, item) => sum + item.quantity);
// 재고 이력 조회
Future<void> loadHistory({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
_historyList.clear();
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final result = await _useCase.getEquipmentHistories(
page: _currentPage,
pageSize: _pageSize,
transactionType: _transactionType,
warehousesId: _warehouseId,
equipmentsId: _equipmentId,
startDate: _startDate?.toIso8601String(),
endDate: _endDate?.toIso8601String(),
);
// result는 EquipmentHistoryListResponse 타입으로 Either가 아님
if (refresh) {
_historyList = result.items;
} else {
_historyList.addAll(result.items);
}
_totalCount = result.totalCount;
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
// 백엔드 스키마 기반 단순 재고 조회 (EquipmentHistoryDto 기반)
// 특정 장비의 현재 재고량 계산 (입고량 - 출고량)
int calculateEquipmentStock(int equipmentId) {
final equipmentHistories = _historyList.where((h) => h.equipmentsId == equipmentId);
int totalIn = equipmentHistories
.where((h) => h.transactionType == 'I')
.fold(0, (sum, h) => sum + h.quantity);
int totalOut = equipmentHistories
.where((h) => h.transactionType == 'O')
.fold(0, (sum, h) => sum + h.quantity);
return totalIn - totalOut;
}
// 백엔드 스키마 기반 단순 필터링
void setFilters({
String? transactionType,
int? warehouseId,
int? equipmentId,
DateTime? startDate,
DateTime? endDate,
String? searchQuery,
}) {
_transactionType = transactionType;
_warehouseId = warehouseId;
_equipmentId = equipmentId;
_startDate = startDate;
_endDate = endDate;
if (searchQuery != null) _searchQuery = searchQuery;
_currentPage = 1;
loadHistory(refresh: true);
}
void clearFilters() {
_transactionType = null;
_warehouseId = null;
_equipmentId = null;
_startDate = null;
_endDate = null;
_searchQuery = '';
_currentPage = 1;
loadHistory(refresh: true);
}
// 백엔드 스키마 기반 단순 통계
Map<String, int> getStockSummary() {
int totalIn = _historyList
.where((h) => h.transactionType == 'I')
.fold(0, (sum, h) => sum + h.quantity);
int totalOut = _historyList
.where((h) => h.transactionType == 'O')
.fold(0, (sum, h) => sum + h.quantity);
return {
'totalStock': totalIn - totalOut,
'totalIn': totalIn,
'totalOut': totalOut,
};
}
// 입고 처리 (백엔드 스키마 기반)
Future<bool> createStockIn({
required int equipmentsId,
required int warehousesId,
required int quantity,
DateTime? transactedAt,
String? remark,
}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final request = EquipmentHistoryRequestDto(
equipmentsId: equipmentsId,
warehousesId: warehousesId,
transactionType: 'I', // 입고
quantity: quantity,
transactedAt: transactedAt ?? DateTime.now(),
remark: remark,
);
await _useCase.createEquipmentHistory(request);
await loadHistory(refresh: true);
return true;
} catch (e) {
_error = e.toString();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
// 출고 처리 (백엔드 스키마 기반)
Future<bool> createStockOut({
required int equipmentsId,
required int warehousesId,
required int quantity,
DateTime? transactedAt,
String? remark,
}) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final request = EquipmentHistoryRequestDto(
equipmentsId: equipmentsId,
warehousesId: warehousesId,
transactionType: 'O', // 출고
quantity: quantity,
transactedAt: transactedAt ?? DateTime.now(),
remark: remark,
);
await _useCase.createEquipmentHistory(request);
await loadHistory(refresh: true);
return true;
} catch (e) {
_error = e.toString();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
// createHistory 메서드 (별칭)
Future<bool> createHistory(EquipmentHistoryRequestDto request) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
await _useCase.createEquipmentHistory(request);
await loadHistory(refresh: true);
return true;
} catch (e) {
_error = e.toString();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
// searchEquipmentHistories 메서드
Future<List<EquipmentHistoryDto>> searchEquipmentHistories({
String? query,
String? transactionType,
int? equipmentId,
int? warehouseId,
}) async {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 100,
transactionType: transactionType,
equipmentsId: equipmentId,
warehousesId: warehouseId,
);
List<EquipmentHistoryDto> histories = result.items;
// 간단한 텍스트 검색 (remark 필드 기반)
if (query != null && query.isNotEmpty) {
histories = histories.where((h) =>
h.remark?.toLowerCase().contains(query.toLowerCase()) ?? false
).toList();
}
return histories;
} catch (e) {
_error = e.toString();
notifyListeners();
return [];
}
}
// setSearchQuery 메서드
void setSearchQuery(String query) {
_searchQuery = query;
setFilters(searchQuery: query);
}
// loadInventoryStatus 메서드 (단순 재고 상태 로드)
Future<void> loadInventoryStatus() async {
await loadHistory(refresh: true);
}
// loadWarehouseStock 메서드 (창고별 재고 로드)
Future<void> loadWarehouseStock({int? warehouseId}) async {
setFilters(warehouseId: warehouseId);
}
// getAvailableStock 메서드
Future<int> getAvailableStock(int equipmentId, {int? warehouseId}) async {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 1000,
equipmentsId: equipmentId,
warehousesId: warehouseId,
);
int totalIn = result.items
.where((h) => h.transactionType == 'I')
.fold(0, (sum, h) => sum + h.quantity);
int totalOut = result.items
.where((h) => h.transactionType == 'O')
.fold(0, (sum, h) => sum + h.quantity);
return totalIn - totalOut;
} catch (e) {
return 0;
}
}
// getAvailableEquipments 메서드 (사용 가능한 장비 목록)
Future<List<int>> getAvailableEquipments({int? warehouseId}) async {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 1000,
warehousesId: warehouseId,
);
// 재고가 있는 장비 ID들만 반환
Map<int, int> stockMap = {};
for (var history in result.items) {
stockMap[history.equipmentsId] = (stockMap[history.equipmentsId] ?? 0) +
(history.transactionType == 'I' ? history.quantity : -history.quantity);
}
return stockMap.entries
.where((entry) => entry.value > 0)
.map((entry) => entry.key)
.toList();
} catch (e) {
return [];
}
}
// 페이지네이션 메서드들
Future<void> previousPage() async {
if (_currentPage > 1) {
_currentPage--;
await loadHistory();
}
}
Future<void> nextPage() async {
if (_currentPage < totalPages) {
_currentPage++;
await loadHistory();
}
}
// 에러 클리어
void clearError() {
_error = null;
notifyListeners();
}
@override
void dispose() {
_historyList.clear();
super.dispose();
}
}

View File

@@ -2,21 +2,20 @@ 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/warehouse_service.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/core/utils/debug_logger.dart';
import 'package:superport/core/utils/equipment_status_converter.dart';
import 'package:superport/core/services/lookups_service.dart';
import 'package:superport/screens/equipment/controllers/equipment_history_controller.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
/// 장비 입고 폼 컨트롤러
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentInFormController extends ChangeNotifier {
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
final WarehouseService _warehouseService = GetIt.instance<WarehouseService>();
final CompanyService _companyService = GetIt.instance<CompanyService>();
// final WarehouseService _warehouseService = GetIt.instance<WarehouseService>(); // 사용되지 않음 - 제거
// final CompanyService _companyService = GetIt.instance<CompanyService>(); // 사용되지 않음 - 제거
final LookupsService _lookupsService = GetIt.instance<LookupsService>();
final int? equipmentInId; // 실제로는 장비 ID (입고 ID가 아님)
int? actualEquipmentId; // API 호출용 실제 장비 ID
@@ -37,15 +36,15 @@ class EquipmentInFormController extends ChangeNotifier {
/// canSave 상태 업데이트 (UI 렌더링 문제 해결)
void _updateCanSave() {
final hasEquipmentNumber = _equipmentNumber.trim().isNotEmpty;
final hasManufacturer = _manufacturer.trim().isNotEmpty;
final hasEquipmentNumber = _serialNumber.trim().isNotEmpty;
final hasModelsId = _modelsId != null; // models_id 필수
final isNotSaving = !_isSaving;
final newCanSave = isNotSaving && hasEquipmentNumber && hasManufacturer;
final newCanSave = isNotSaving && hasEquipmentNumber && hasModelsId;
if (_canSave != newCanSave) {
_canSave = newCanSave;
print('🚀 [canSave 상태 변경] $_canSaveequipmentNumber: "$_equipmentNumber", manufacturer: "$_manufacturer"');
print('🚀 [canSave 상태 변경] $_canSaveserialNumber: "$_serialNumber", modelsId: $_modelsId');
notifyListeners(); // 명시적 UI 업데이트
}
}
@@ -54,24 +53,18 @@ class EquipmentInFormController extends ChangeNotifier {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// 입력 상태 변수 (백엔드 API 구조에 맞게 수정)
String _equipmentNumber = ''; // 장비번호 (필수) - private으로 변경
String _manufacturer = ''; // 제조사 (필수) - private으로 변경
String _modelName = ''; // 모델명 - private으로 변경
String _serialNumber = ''; // 시리얼번호 - private으로 변경
String _category1 = ''; // 대분류 - private으로 변경
String _category2 = ''; // 중분류 - private으로 변경
String _category3 = ''; // 소분류 - private으로 변경
String _serialNumber = ''; // 장비번호 (필수) - private으로 변경
int? _modelsId; // 모델 ID (필수) - Vendor→Model cascade에서 선택
int? _vendorId; // 벤더 ID (UI용, API에는 전송 안함)
// Legacy 필드 (UI 호환성 유지용)
String _manufacturer = ''; // 제조사 (Legacy) - ModelDto에서 가져옴
String _name = ''; // 모델명 (Legacy) - ModelDto에서 가져옴
String _category1 = ''; // 대분류 (Legacy)
String _category2 = ''; // 중분류 (Legacy)
String _category3 = ''; // 소분류 (Legacy)
// Getters and Setters for reactive fields
String get equipmentNumber => _equipmentNumber;
set equipmentNumber(String value) {
if (_equipmentNumber != value) {
_equipmentNumber = value;
_updateCanSave(); // canSave 상태 업데이트
print('DEBUG [Controller] equipmentNumber updated: "$_equipmentNumber"');
}
}
String get serialNumber => _serialNumber;
set serialNumber(String value) {
if (_serialNumber != value) {
@@ -90,12 +83,12 @@ class EquipmentInFormController extends ChangeNotifier {
}
}
String get modelName => _modelName;
set modelName(String value) {
if (_modelName != value) {
_modelName = value;
String get name => _name;
set name(String value) {
if (_name != value) {
_name = value;
_updateCanSave(); // canSave 상태 업데이트
print('DEBUG [Controller] modelName updated: "$_modelName"');
print('DEBUG [Controller] name updated: "$_name"');
}
}
@@ -122,6 +115,37 @@ class EquipmentInFormController extends ChangeNotifier {
_updateCanSave(); // canSave 상태 업데이트
}
}
// 새로운 필드 getters/setters
int? get modelsId => _modelsId;
set modelsId(int? value) {
if (_modelsId != value) {
_modelsId = value;
_updateCanSave(); // canSave 상태 업데이트
print('DEBUG [Controller] modelsId updated: $_modelsId');
}
}
int? get vendorId => _vendorId;
set vendorId(int? value) {
if (_vendorId != value) {
_vendorId = value;
// vendor 변경 시 model 초기화
if (_modelsId != null) {
_modelsId = null;
_updateCanSave();
}
print('DEBUG [Controller] vendorId updated: $_vendorId');
}
}
// Vendor→Model 선택 콜백
void onVendorModelChanged(int? vendorId, int? modelId) {
_vendorId = vendorId;
_modelsId = modelId;
_updateCanSave();
notifyListeners();
}
DateTime? purchaseDate; // 구매일
double? purchasePrice; // 구매가격
@@ -139,6 +163,16 @@ class EquipmentInFormController extends ChangeNotifier {
int? selectedCompanyId; // 구매처 ID
int? selectedWarehouseId; // 입고지 ID
// 재고 초기화 필드
int _initialStock = 1; // 초기 재고 수량 (기본값: 1)
int get initialStock => _initialStock;
set initialStock(int value) {
if (_initialStock != value && value > 0) {
_initialStock = value;
notifyListeners();
}
}
// 창고 위치 전체 데이터 (이름-ID 매핑용)
Map<String, int> warehouseLocationMap = {};
@@ -147,9 +181,9 @@ class EquipmentInFormController extends ChangeNotifier {
// 수정불가 필드 목록 (수정 모드에서만 적용)
static const List<String> _readOnlyFields = [
'equipmentNumber', // 장비번호
'serialNumber', // 장비번호
'manufacturer', // 제조사
'modelName', // 모델명
'name', // 모델명
'serialNumber', // 시리얼번호
'purchaseDate', // 구매일
'purchasePrice', // 구매가격
@@ -259,45 +293,42 @@ class EquipmentInFormController extends ChangeNotifier {
print('DEBUG [_loadEquipmentIn] Equipment loaded successfully');
DebugLogger.log('장비 정보 로드 성공', tag: 'EQUIPMENT_IN', data: {
'equipmentId': equipment.id,
'manufacturer': equipment.manufacturer,
'equipmentNumber': equipment.equipmentNumber, // name → equipmentNumber
'companiesId': equipment.companiesId, // 백엔드 실제 필드
'serialNumber': equipment.serialNumber,
});
// 장비 정보 설정 (새로운 필드 구조)
// 장비 정보 설정 (백엔드 실제 필드 구조)
print('DEBUG [_loadEquipmentIn] Setting equipment data...');
print('DEBUG [_loadEquipmentIn] equipment.manufacturer="${equipment.manufacturer}"');
print('DEBUG [_loadEquipmentIn] equipment.equipmentNumber="${equipment.equipmentNumber}"');
print('DEBUG [_loadEquipmentIn] equipment.companiesId="${equipment.companiesId}"');
print('DEBUG [_loadEquipmentIn] equipment.serialNumber="${equipment.serialNumber}"');
// 새로운 필드 구조로 매핑 (setter 사용하여 notifyListeners 자동 호출)
_equipmentNumber = equipment.equipmentNumber ?? '';
manufacturer = equipment.manufacturer ?? '';
modelName = equipment.modelName ?? ''; // deprecated name 제거
// 백엔드 실제 필드로 매핑
_serialNumber = equipment.serialNumber ?? '';
category1 = equipment.category1 ?? ''; // deprecated category 제거
category2 = equipment.category2 ?? ''; // deprecated subCategory 제거
category3 = equipment.category3 ?? ''; // deprecated subSubCategory 제거
purchaseDate = equipment.purchaseDate;
purchasePrice = equipment.purchasePrice;
selectedCompanyId = equipment.companyId;
selectedWarehouseId = equipment.warehouseLocationId;
_modelsId = equipment.modelsId; // 백엔드 실제 필드
selectedCompanyId = equipment.companiesId; // companyId → companiesId
purchasePrice = equipment.purchasePrice.toDouble(); // int → double 변환
remarkController.text = equipment.remark ?? '';
print('DEBUG [_loadEquipmentIn] After setting - equipmentNumber="$_equipmentNumber", manufacturer="$_manufacturer", modelName="$_modelName"');
// Legacy 필드들은 기본값으로 설정 (UI 호환성)
manufacturer = ''; // 더 이상 백엔드에서 제공안함
name = '';
category1 = '';
category2 = '';
category3 = '';
print('DEBUG [_loadEquipmentIn] After setting - serialNumber="$_serialNumber", manufacturer="$_manufacturer", name="$_name"');
// 🔧 [DEBUG] UI 업데이트를 위한 중요 필드들 로깅
print('DEBUG [_loadEquipmentIn] purchaseDate: $purchaseDate, purchasePrice: $purchasePrice');
print('DEBUG [_loadEquipmentIn] selectedCompanyId: $selectedCompanyId, selectedWarehouseId: $selectedWarehouseId');
DebugLogger.log('장비 데이터 설정 완료', tag: 'EQUIPMENT_IN', data: {
'equipmentNumber': _equipmentNumber,
'manufacturer': _manufacturer,
'modelName': _modelName,
'category1': _category1,
'category2': _category2,
'category3': _category3,
'serialNumber': _serialNumber,
'companiesId': selectedCompanyId,
'modelsId': _modelsId,
'purchasePrice': purchasePrice,
});
print('DEBUG [EQUIPMENT_IN]: Equipment loaded - equipmentNumber: "$_equipmentNumber", manufacturer: "$_manufacturer", modelName: "$_modelName"');
print('DEBUG [EQUIPMENT_IN]: Equipment loaded - serialNumber: "$_serialNumber", companiesId: "$selectedCompanyId", modelsId: "$_modelsId"');
// 추가 필드들은 입고 시에는 필요하지 않으므로 생략
@@ -359,22 +390,18 @@ class EquipmentInFormController extends ChangeNotifier {
if (_manufacturer.isNotEmpty && !manufacturers.contains(_manufacturer)) {
manufacturers.add(_manufacturer);
}
if (_modelName.isNotEmpty && !equipmentNames.contains(_modelName)) {
equipmentNames.add(_modelName);
if (_name.isNotEmpty && !equipmentNames.contains(_name)) {
equipmentNames.add(_name);
}
// 백엔드 API 구조에 맞는 Equipment 객체 생성
final equipment = Equipment(
// Required 필드들
manufacturer: _manufacturer.trim(),
equipmentNumber: _equipmentNumber.trim(), // required
modelName: _modelName.trim().isEmpty ? _equipmentNumber.trim() : _modelName.trim(), // required
category1: _category1.trim().isEmpty ? 'N/A' : _category1.trim(), // required (nullable에서 non-nullable로)
category2: _category2.trim().isEmpty ? 'N/A' : _category2.trim(), // required (nullable에서 non-nullable로)
category3: _category3.trim().isEmpty ? 'N/A' : _category3.trim(), // required (nullable에서 non-nullable로)
modelsId: _modelsId, // 필수: Model ID (Vendor→Model cascade에서 선택)
equipmentNumber: _serialNumber.trim(), // required: 장비번호
serialNumber: _serialNumber.trim(), // required: 시리얼번호
quantity: 1, // required
// Optional 필드들
serialNumber: _serialNumber.trim().isEmpty ? null : _serialNumber.trim(),
purchaseDate: purchaseDate,
purchasePrice: purchasePrice,
inDate: purchaseDate ?? DateTime.now(),
@@ -393,13 +420,26 @@ class EquipmentInFormController extends ChangeNotifier {
DebugLogger.log('장비 정보 업데이트 시작', tag: 'EQUIPMENT_IN', data: {
'equipmentId': actualEquipmentId,
'equipmentNumber': equipment.equipmentNumber,
'manufacturer': equipment.manufacturer,
'modelName': equipment.modelName,
'serialNumber': equipment.serialNumber,
'modelsId': equipment.modelsId,
'companyId': equipment.companyId,
});
await _equipmentService.updateEquipment(actualEquipmentId!, equipment);
// Equipment 객체를 EquipmentUpdateRequestDto로 변환
final updateRequest = EquipmentUpdateRequestDto(
companiesId: selectedCompanyId ?? 0,
modelsId: _modelsId ?? 0,
serialNumber: _serialNumber,
barcode: null,
purchasedAt: null,
purchasePrice: purchasePrice?.toInt() ?? 0,
warrantyNumber: '',
warrantyStartedAt: DateTime.now(),
warrantyEndedAt: DateTime.now().add(Duration(days: 365)),
remark: remarkController.text.isNotEmpty ? remarkController.text : null,
);
await _equipmentService.updateEquipment(actualEquipmentId!, updateRequest);
DebugLogger.log('장비 정보 업데이트 성공', tag: 'EQUIPMENT_IN');
} else {
@@ -407,25 +447,67 @@ class EquipmentInFormController extends ChangeNotifier {
try {
// 1. 먼저 장비 생성
DebugLogger.log('장비 입고 시작', tag: 'EQUIPMENT_IN', data: {
'equipmentNumber': _equipmentNumber,
'manufacturer': _manufacturer,
'modelName': _modelName,
'serialNumber': _serialNumber,
'manufacturer': _manufacturer,
'name': _name,
'companiesId': selectedCompanyId,
});
final createdEquipment = await _equipmentService.createEquipment(equipment);
// Equipment 객체를 EquipmentRequestDto로 변환
final createRequest = EquipmentRequestDto(
companiesId: selectedCompanyId ?? 0,
modelsId: _modelsId ?? 0,
serialNumber: _serialNumber,
barcode: null,
purchasedAt: null,
purchasePrice: purchasePrice?.toInt() ?? 0,
warrantyNumber: '',
warrantyStartedAt: DateTime.now(),
warrantyEndedAt: DateTime.now().add(Duration(days: 365)),
remark: remarkController.text.isNotEmpty ? remarkController.text : null,
);
final createdEquipment = await _equipmentService.createEquipment(createRequest);
DebugLogger.log('장비 생성 성공', tag: 'EQUIPMENT_IN', data: {
'equipmentId': createdEquipment.id,
});
// 새로운 API에서는 장비 생성 시 입고 처리까지 한 번에 처리됨
// 2. Equipment History (입고 기록) 생성
if (selectedWarehouseId != null && createdEquipment.id != null) {
try {
// EquipmentHistoryController를 통한 입고 처리
final historyController = EquipmentHistoryController();
// 입고 처리 (EquipmentHistoryRequestDto 객체 생성)
final historyRequest = EquipmentHistoryRequestDto(
equipmentsId: createdEquipment.id, // null 체크 이미 완료되어 ! 연산자 불필요
warehousesId: selectedWarehouseId!,
transactionType: 'I', // 입고: 'I'
quantity: _initialStock,
transactedAt: DateTime.now(),
remark: '장비 등록 시 자동 입고',
);
await historyController.createHistory(historyRequest);
DebugLogger.log('Equipment History 생성 성공', tag: 'EQUIPMENT_IN', data: {
'equipmentId': createdEquipment.id,
'warehouseId': selectedWarehouseId,
'quantity': _initialStock,
});
} catch (e) {
// 입고 실패 시에도 장비는 이미 생성되었으므로 경고만 표시
DebugLogger.logError('Equipment History 생성 실패', error: e);
_error = '장비는 등록되었으나 입고 처리 중 오류가 발생했습니다.';
}
}
DebugLogger.log('입고 처리 성공', tag: 'EQUIPMENT_IN');
DebugLogger.log('입고 처리 완료', tag: 'EQUIPMENT_IN');
} catch (e) {
DebugLogger.logError('장비 입고 처리 실패', error: e);
throw e; // 에러를 상위로 전파하여 적절한 에러 메시지 표시
rethrow; // 에러를 상위로 전파하여 적절한 에러 메시지 표시
}
}

View File

@@ -5,12 +5,11 @@ import 'package:superport/services/equipment_service.dart';
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/data/models/common/pagination_params.dart';
import 'package:superport/core/services/lookups_service.dart';
import 'package:superport/data/models/lookups/lookup_data.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
/// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
@@ -76,8 +75,8 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
status: _statusFilter != null ?
EquipmentStatusConverter.clientToServer(_statusFilter) : null,
search: params.search,
companyId: _companyIdFilter,
includeInactive: _includeInactive,
// companyId: _companyIdFilter, // 비활성화: EquipmentService에서 지원하지 않음
// includeInactive: _includeInactive, // 비활성화: EquipmentService에서 지원하지 않음
),
onError: (failure) {
throw failure;
@@ -102,18 +101,13 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
print('DEBUG [EquipmentListController] Converting ${apiEquipmentDtos.items.length} DTOs to UnifiedEquipment');
final items = apiEquipmentDtos.items.map((dto) {
// 🔧 [DEBUG] JOIN된 데이터 로깅
print('DEBUG [EquipmentListController] DTO ID: ${dto.id}, companyName: "${dto.companyName}", warehouseName: "${dto.warehouseName}"');
print('DEBUG [EquipmentListController] DTO ID: ${dto.id}, companyName: "${dto.companyName}"');
final equipment = Equipment(
id: dto.id,
manufacturer: dto.manufacturer ?? 'Unknown',
equipmentNumber: dto.equipmentNumber ?? 'Unknown', // name → equipmentNumber (required)
modelName: dto.modelName ?? dto.equipmentNumber ?? 'Unknown', // 새로운 필수 필드 (required)
// 🔧 [BUG FIX] 하드코딩 제거 - 백엔드 API에서 카테고리 정보 미제공 시 기본값 사용
// TODO: 백엔드 API에서 category1/2/3 필드 추가 필요
category1: 'N/A', // 백엔드에서 카테고리 정보 미제공 시 기본값
category2: 'N/A', // 백엔드에서 카테고리 정보 미제공 시 기본값
category3: 'N/A', // 백엔드에서 카테고리 정보 미제공 시 기본값
serialNumber: dto.serialNumber,
modelsId: dto.modelsId, // Sprint 3: Model FK 사용
model: null, // Equipment 생성자에서 ModelDto? 요구, 현재 DTO는 modelName(String) 제공
equipmentNumber: dto.serialNumber ?? 'Unknown', // 장비번호 (required)
serialNumber: dto.serialNumber ?? 'Unknown', // 시리얼번호 (required)
quantity: 1, // 기본 수량
);
@@ -123,15 +117,15 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
final unifiedEquipment = UnifiedEquipment(
id: dto.id,
equipment: equipment,
date: dto.createdAt ?? DateTime.now(),
status: EquipmentStatusConverter.serverToClient(dto.status),
notes: null, // EquipmentListDto에 remark 필드 없음
date: dto.registeredAt ?? DateTime.now(), // EquipmentDto에는 createdAt 대신 registeredAt 존재
status: '입고', // EquipmentDto에 status 필드 없음 - 기본값 설정 (실제는 Equipment_History에서 상태 관리)
notes: dto.remark, // EquipmentDto에 remark 필드 존재
// 🔧 [BUG FIX] 누락된 위치 정보 필드들 추가
// 문제: 장비 리스트에서 위치 정보(현재 위치, 창고 위치)가 표시되지 않음
// 원인: UnifiedEquipment 생성 시 JOIN된 데이터(companyName, warehouseName) 누락
// 해결: EquipmentListDto의 JOIN된 데이터를 UnifiedEquipment 필드로 매핑
// 원인: EquipmentDto에 warehouseName 필드가 없음 (백엔드 스키마에 warehouse 정보 분리)
// 해결: 현재는 companyName만 사용, warehouseLocation은 null로 설정
currentCompany: dto.companyName, // API company_name → currentCompany
warehouseLocation: dto.warehouseName, // API warehouse_name → warehouseLocation
warehouseLocation: null, // EquipmentDto에 warehouse_name 필드 없음
// currentBranch는 EquipmentListDto에 없으므로 null (백엔드 API 구조 변경으로 지점 개념 제거)
currentBranch: null,
);
@@ -156,10 +150,9 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
@override
bool filterItem(UnifiedEquipment item, String query) {
final q = query.toLowerCase();
return (item.equipment.equipmentNumber.toLowerCase().contains(q)) || // name → equipmentNumber
(item.equipment.modelName?.toLowerCase().contains(q) ?? false) || // 모델명 추가
(item.equipment.serialNumber?.toLowerCase().contains(q) ?? false) ||
(item.equipment.manufacturer.toLowerCase().contains(q)) ||
return (item.equipment.serialNumber?.toLowerCase().contains(q) ?? false) || // serialNumber 검색
(item.equipment.modelName.toLowerCase().contains(q)) || // Equipment.modelName getter 사용
(item.equipment.manufacturer.toLowerCase().contains(q)) || // Equipment.manufacturer getter 사용
(item.notes?.toLowerCase().contains(q) ?? false) ||
(item.status.toLowerCase().contains(q));
}
@@ -277,8 +270,8 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
await ErrorHandler.handleApiCall<void>(
() => _equipmentService.changeEquipmentStatus(
id,
EquipmentStatusConverter.clientToServer(newStatus),
reason,
EquipmentStatusConverter.clientToServer(newStatus),
// reason 파라미터는 EquipmentService에서 지원하지 않음
),
onError: (failure) {
throw failure;
@@ -314,7 +307,7 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
reason: reason ?? '폐기 처리',
);
} catch (e) {
failedEquipments.add('${equipment.equipment.manufacturer} ${equipment.equipment.equipmentNumber}'); // name → equipmentNumber
failedEquipments.add('${equipment.equipment.manufacturer} ${equipment.equipment.serialNumber}'); // name → serialNumber
}
}
@@ -327,8 +320,22 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
/// 장비 정보 수정
Future<void> updateEquipment(int id, UnifiedEquipment equipment) async {
// Equipment → EquipmentUpdateRequestDto 변환 (실제 필드명으로 매핑)
final updateRequest = EquipmentUpdateRequestDto(
companiesId: equipment.equipment.currentCompanyId ?? equipment.equipment.companyId, // companiesId 필드 없음
modelsId: equipment.equipment.modelsId, // ✓ 있음
serialNumber: equipment.equipment.serialNumber, // ✓ 있음
barcode: equipment.equipment.barcode, // ✓ 있음
purchasedAt: equipment.equipment.purchaseDate, // purchasedAt → purchaseDate
purchasePrice: equipment.equipment.purchasePrice?.toInt(), // double? → int? 변환
warrantyNumber: equipment.equipment.warrantyLicense, // warrantyNumber → warrantyLicense
warrantyStartedAt: equipment.equipment.warrantyStartDate, // warrantyStartedAt → warrantyStartDate
warrantyEndedAt: equipment.equipment.warrantyEndDate, // warrantyEndedAt → warrantyEndDate
remark: equipment.notes, // UnifiedEquipment.notes → DTO.remark
);
await ErrorHandler.handleApiCall<void>(
() => _equipmentService.updateEquipment(id, equipment.equipment),
() => _equipmentService.updateEquipment(id, updateRequest),
onError: (failure) {
throw failure;
},

View File

@@ -4,16 +4,16 @@ import 'package:intl/intl.dart';
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/equipment_service.dart';
import 'package:superport/screens/equipment/controllers/equipment_history_controller.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
/// 장비 출고 폼 컨트롤러
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentOutFormController extends ChangeNotifier {
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
// final EquipmentService _equipmentService = GetIt.instance<EquipmentService>(); // 사용되지 않음 - 제거
final CompanyService _companyService = GetIt.instance<CompanyService>();
int? equipmentOutId;
@@ -21,7 +21,7 @@ class EquipmentOutFormController extends ChangeNotifier {
bool isEditMode = false;
// 상태 관리
bool _isLoading = false;
final bool _isLoading = false;
String? _errorMessage;
// Getters
@@ -221,26 +221,34 @@ class EquipmentOutFormController extends ChangeNotifier {
} else {
// 생성 모드
if (selectedEquipments != null && selectedEquipments!.isNotEmpty) {
// 다중 장비 출고
// 다중 장비 출고 (새로운 EquipmentHistoryController 사용)
final historyController = EquipmentHistoryController();
for (var equipmentData in selectedEquipments!) {
final equipment = equipmentData['equipment'] as Equipment;
if (equipment.id != null) {
await equipmentService.equipmentOut(
equipmentId: equipment.id!,
final historyRequest = EquipmentHistoryRequestDto(
equipmentsId: equipment.id!,
warehousesId: 1, // 기본 창고 ID (실제로는 사용자 선택 필요)
transactionType: 'OUT',
quantity: equipment.quantity,
companyId: companyId,
notes: note ?? remarkController.text,
remark: note ?? remarkController.text,
);
await historyController.createHistory(historyRequest);
}
}
} else if (selectedEquipment != null && selectedEquipment!.id != null) {
// 단일 장비 출고
await equipmentService.equipmentOut(
equipmentId: selectedEquipment!.id!,
// 단일 장비 출고 (새로운 EquipmentHistoryController 사용)
final historyController = EquipmentHistoryController();
final historyRequest = EquipmentHistoryRequestDto(
equipmentsId: selectedEquipment!.id!,
warehousesId: 1, // 기본 창고 ID (실제로는 사용자 선택 필요)
transactionType: 'OUT',
quantity: selectedEquipment!.quantity,
companyId: companyId,
notes: note ?? remarkController.text,
remark: note ?? remarkController.text,
);
await historyController.createHistory(historyRequest);
}
}

View File

@@ -1,13 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/utils/currency_formatter.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
import 'package:superport/core/widgets/category_cascade_form_field.dart';
import 'controllers/equipment_in_form_controller.dart';
import 'widgets/equipment_vendor_model_selector.dart';
import 'package:superport/utils/formatters/number_formatter.dart';
/// 새로운 Equipment 입고 폼 (Lookup API 기반)
class EquipmentInFormScreen extends StatefulWidget {
@@ -21,6 +20,9 @@ class EquipmentInFormScreen extends StatefulWidget {
class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
late EquipmentInFormController _controller;
late TextEditingController _serialNumberController;
late TextEditingController _initialStockController;
late TextEditingController _purchasePriceController;
@override
void initState() {
@@ -28,10 +30,26 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
_controller = EquipmentInFormController(equipmentInId: widget.equipmentInId);
_controller.addListener(_onControllerUpdated);
// TextEditingController 초기화
_serialNumberController = TextEditingController(text: _controller.serialNumber);
_serialNumberController = TextEditingController(text: _controller.serialNumber);
_initialStockController = TextEditingController(text: _controller.initialStock.toString());
_purchasePriceController = TextEditingController(
text: _controller.purchasePrice != null
? CurrencyFormatter.formatKRW(_controller.purchasePrice)
: ''
);
// 수정 모드일 때 데이터 로드
if (_controller.isEditMode) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.initializeForEdit();
// 데이터 로드 후 컨트롤러 업데이트
_serialNumberController.text = _controller.serialNumber;
_serialNumberController.text = _controller.serialNumber;
_purchasePriceController.text = _controller.purchasePrice != null
? CurrencyFormatter.formatKRW(_controller.purchasePrice)
: '';
});
}
}
@@ -40,6 +58,10 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
void dispose() {
_controller.removeListener(_onControllerUpdated);
_controller.dispose();
_serialNumberController.dispose();
_serialNumberController.dispose();
_initialStockController.dispose();
_purchasePriceController.dispose();
super.dispose();
}
@@ -47,25 +69,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
if (mounted) setState(() {});
}
// 유효한 제조사 값 반환 (드롭다운 assertion 오류 방지)
String? _getValidManufacturer() {
if (_controller.manufacturer.isEmpty) return null;
final isValid = _controller.manufacturers.contains(_controller.manufacturer);
print('DEBUG [_getValidManufacturer] manufacturer: "${_controller.manufacturer}", isValid: $isValid, available: ${_controller.manufacturers.take(5).toList()}');
return isValid ? _controller.manufacturer : null;
}
// 유효한 모델명 값 반환 (드롭다운 assertion 오류 방지)
String? _getValidModelName() {
if (_controller.modelName.isEmpty) return null;
final isValid = _controller.equipmentNames.contains(_controller.modelName);
print('DEBUG [_getValidModelName] modelName: "${_controller.modelName}", isValid: $isValid, available: ${_controller.equipmentNames.take(5).toList()}');
return isValid ? _controller.modelName : null;
}
// Legacy 필드 제거 - Vendor/Model cascade selector 사용
// 유효한 구매처 ID 반환 (드롭다운 assertion 오류 방지)
int? _getValidCompanyId() {
@@ -94,10 +98,10 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
if (success && mounted) {
Navigator.pop(context, true);
} else if (_controller.error != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error!),
backgroundColor: Colors.red,
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('오류'),
description: Text(_controller.error!),
),
);
}
@@ -106,7 +110,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
@override
Widget build(BuildContext context) {
// 간소화된 디버깅
print('🎯 [UI] canSave: ${_controller.canSave} | 장비번호: "${_controller.equipmentNumber}" | 제조사: "${_controller.manufacturer}"');
print('🎯 [UI] canSave: ${_controller.canSave} | 장비번호: "${_controller.serialNumber}" | 제조사: "${_controller.manufacturer}"');
return FormLayoutTemplate(
title: _controller.isEditMode ? '장비 수정' : '장비 입고',
@@ -114,7 +118,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
onCancel: () => Navigator.of(context).pop(),
isLoading: _controller.isSaving,
child: _controller.isLoading
? const Center(child: CircularProgressIndicator())
? const Center(child: ShadProgress())
: Form(
key: _controller.formKey,
child: SingleChildScrollView(
@@ -138,7 +142,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
}
Widget _buildBasicFields() {
return Card(
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -153,142 +157,50 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
const SizedBox(height: 16),
// 장비 번호 (필수)
TextFormField(
initialValue: _controller.equipmentNumber,
readOnly: _controller.isFieldReadOnly('equipmentNumber'),
decoration: InputDecoration(
labelText: _controller.isFieldReadOnly('equipmentNumber')
? '장비 번호 * 🔒' : '장비 번호 *',
// 🔧 [UI FIX] ReadOnly 필드에서도 의미 있는 hintText 표시
hintText: _controller.isFieldReadOnly('equipmentNumber')
? (_controller.equipmentNumber.isNotEmpty ? null : '장비 번호 없음')
: '장비 번호를 입력하세요',
border: const OutlineInputBorder(),
filled: _controller.isFieldReadOnly('equipmentNumber'),
fillColor: _controller.isFieldReadOnly('equipmentNumber')
? Colors.grey[100] : null,
),
style: TextStyle(
color: _controller.isFieldReadOnly('equipmentNumber')
? Colors.grey[600] : null,
),
ShadInputFormField(
controller: _serialNumberController,
readOnly: _controller.isFieldReadOnly('serialNumber'),
placeholder: Text(_controller.isFieldReadOnly('serialNumber')
? (_controller.serialNumber.isNotEmpty ? _controller.serialNumber : '장비 번호 없음')
: '장비 번호를 입력하세요'),
label: Text(_controller.isFieldReadOnly('serialNumber')
? '장비 번호 * 🔒' : '장비 번호 *'),
validator: (value) {
if (value?.trim().isEmpty ?? true) {
if (value.trim().isEmpty ?? true) {
return '장비 번호는 필수입니다';
}
return null;
},
onChanged: _controller.isFieldReadOnly('equipmentNumber') ? null : (value) {
_controller.equipmentNumber = value?.trim() ?? '';
onChanged: _controller.isFieldReadOnly('serialNumber') ? null : (value) {
_controller.serialNumber = value.trim() ?? '';
setState(() {});
print('DEBUG [장비번호 입력] value: "$value", controller.equipmentNumber: "${_controller.equipmentNumber}"');
},
onSaved: (value) {
_controller.equipmentNumber = value?.trim() ?? '';
print('DEBUG [장비번호 입력] value: "$value", controller.serialNumber: "${_controller.serialNumber}"');
},
),
const SizedBox(height: 16),
// 제조사 (필수, Dropdown)
DropdownButtonFormField<String>(
value: _getValidManufacturer(),
items: _controller.manufacturers.map((String manufacturer) {
return DropdownMenuItem<String>(
value: manufacturer,
child: Text(
manufacturer,
style: TextStyle(
color: _controller.isFieldReadOnly('manufacturer')
? Colors.grey[600] : null,
),
),
);
}).toList(),
decoration: InputDecoration(
labelText: _controller.isFieldReadOnly('manufacturer')
? '제조사 * 🔒' : '제조사 *',
hintText: _controller.isFieldReadOnly('manufacturer')
? '수정불가' : '제조사를 선택하세요',
border: const OutlineInputBorder(),
filled: _controller.isFieldReadOnly('manufacturer'),
fillColor: _controller.isFieldReadOnly('manufacturer')
? Colors.grey[100] : null,
),
validator: (value) {
if (value?.trim().isEmpty ?? true) {
return '제조사는 필수입니다';
}
return null;
},
onChanged: _controller.isFieldReadOnly('manufacturer') ? null : (value) {
setState(() {
_controller.manufacturer = value?.trim() ?? '';
});
print('🔧 DEBUG [제조사 선택] value: "$value", controller.manufacturer: "${_controller.manufacturer}", canSave: ${_controller.canSave}');
},
),
const SizedBox(height: 16),
// 모델명 (선택, Dropdown)
DropdownButtonFormField<String>(
value: _getValidModelName(),
items: _controller.equipmentNames.map((String equipmentName) {
return DropdownMenuItem<String>(
value: equipmentName,
child: Text(
equipmentName,
style: TextStyle(
color: _controller.isFieldReadOnly('modelName')
? Colors.grey[600] : null,
),
),
);
}).toList(),
decoration: InputDecoration(
labelText: _controller.isFieldReadOnly('modelName')
? '모델명 🔒' : '모델명',
hintText: _controller.isFieldReadOnly('modelName')
? '수정불가' : '모델명을 선택하세요',
border: const OutlineInputBorder(),
filled: _controller.isFieldReadOnly('modelName'),
fillColor: _controller.isFieldReadOnly('modelName')
? Colors.grey[100] : null,
),
onChanged: _controller.isFieldReadOnly('modelName') ? null : (value) {
setState(() {
_controller.modelName = value?.trim() ?? '';
});
print('DEBUG [모델명 선택] value: "$value", controller.modelName: "${_controller.modelName}"');
},
// Vendor→Model cascade 선택기
EquipmentVendorModelSelector(
initialVendorId: _controller.vendorId,
initialModelId: _controller.modelsId,
onChanged: _controller.onVendorModelChanged,
isReadOnly: _controller.isFieldReadOnly('modelsId'),
),
const SizedBox(height: 16),
// 시리얼 번호 (선택)
TextFormField(
initialValue: _controller.serialNumber,
ShadInputFormField(
controller: _serialNumberController,
readOnly: _controller.isFieldReadOnly('serialNumber'),
decoration: InputDecoration(
labelText: _controller.isFieldReadOnly('serialNumber')
? '시리얼 번호 🔒' : '시리얼 번호',
hintText: _controller.isFieldReadOnly('serialNumber')
? '수정불가' : '시리얼 번호를 입력하세요',
border: const OutlineInputBorder(),
filled: _controller.isFieldReadOnly('serialNumber'),
fillColor: _controller.isFieldReadOnly('serialNumber')
? Colors.grey[100] : null,
),
style: TextStyle(
color: _controller.isFieldReadOnly('serialNumber')
? Colors.grey[600] : null,
),
placeholder: Text(_controller.isFieldReadOnly('serialNumber')
? '수정불가' : '시리얼 번호를 입력하세요'),
label: Text(_controller.isFieldReadOnly('serialNumber')
? '시리얼 번호 🔒' : '시리얼 번호'),
onChanged: _controller.isFieldReadOnly('serialNumber') ? null : (value) {
_controller.serialNumber = value?.trim() ?? '';
_controller.serialNumber = value.trim() ?? '';
setState(() {});
print('DEBUG [시리얼번호 입력] value: "$value", controller.serialNumber: "${_controller.serialNumber}"');
},
onSaved: (value) {
_controller.serialNumber = value?.trim() ?? '';
},
),
],
),
@@ -297,7 +209,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
}
Widget _buildCategorySection() {
return Card(
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -328,7 +240,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
}
Widget _buildLocationSection() {
return Card(
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -343,55 +255,73 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
const SizedBox(height: 16),
// 구매처 (드롭다운 전용)
DropdownButtonFormField<int>(
value: _getValidCompanyId(),
items: _controller.companies.entries.map((entry) {
return DropdownMenuItem<int>(
ShadSelect<int>(
initialValue: _getValidCompanyId(),
placeholder: const Text('구매처를 선택하세요'),
options: _controller.companies.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
);
}).toList(),
decoration: const InputDecoration(
labelText: '구매처',
hintText: '구매처를 선택하세요',
border: OutlineInputBorder(),
),
)
).toList(),
selectedOptionBuilder: (context, value) =>
Text(_controller.companies[value] ?? '선택하세요'),
onChanged: (value) {
setState(() {
_controller.selectedCompanyId = value;
});
print('DEBUG [구매처 선택] value: $value, companies: ${_controller.companies.length}');
},
onSaved: (value) {
_controller.selectedCompanyId = value;
},
),
const SizedBox(height: 16),
// 입고지 (드롭다운 전용)
DropdownButtonFormField<int>(
value: _getValidWarehouseId(),
items: _controller.warehouses.entries.map((entry) {
return DropdownMenuItem<int>(
ShadSelect<int>(
initialValue: _getValidWarehouseId(),
placeholder: const Text('입고지를 선택하세요'),
options: _controller.warehouses.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
);
}).toList(),
decoration: const InputDecoration(
labelText: '입고지',
hintText: '입고지를 선택하세요',
border: OutlineInputBorder(),
),
)
).toList(),
selectedOptionBuilder: (context, value) =>
Text(_controller.warehouses[value] ?? '선택하세요'),
onChanged: (value) {
setState(() {
_controller.selectedWarehouseId = value;
});
print('DEBUG [입고지 선택] value: $value, warehouses: ${_controller.warehouses.length}');
},
onSaved: (value) {
_controller.selectedWarehouseId = value;
},
),
const SizedBox(height: 16),
// 초기 재고 수량 (신규 등록 시에만 표시)
if (!_controller.isEditMode)
ShadInputFormField(
controller: _initialStockController,
label: const Text('초기 재고 수량 *'),
placeholder: const Text('입고할 수량을 입력하세요'),
description: const Text('장비 등록 시 자동으로 입고 처리됩니다'),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
validator: (value) {
if (value.isEmpty) {
return '재고 수량은 필수입니다';
}
final quantity = int.tryParse(value);
if (quantity == null || quantity <= 0) {
return '1개 이상의 수량을 입력하세요';
}
return null;
},
onChanged: (value) {
final quantity = int.tryParse(value) ?? 1;
_controller.initialStock = quantity;
},
),
],
),
),
@@ -399,7 +329,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
}
Widget _buildPurchaseSection() {
return Card(
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -431,29 +361,39 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
});
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: _controller.isFieldReadOnly('purchaseDate')
? '구매일 🔒' : '구매일',
suffixIcon: Icon(
Icons.calendar_today,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(
color: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[600] : null,
? Colors.grey[300]!
: Theme.of(context).dividerColor,
),
border: const OutlineInputBorder(),
filled: _controller.isFieldReadOnly('purchaseDate'),
fillColor: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[100] : null,
borderRadius: BorderRadius.circular(6),
color: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[50]
: null,
),
child: Text(
// 🔧 [UI FIX] ReadOnly 필드에서도 의미 있는 텍스트 표시
_controller.purchaseDate != null
? '${_controller.purchaseDate!.year}-${_controller.purchaseDate!.month.toString().padLeft(2, '0')}-${_controller.purchaseDate!.day.toString().padLeft(2, '0')}'
: _controller.isFieldReadOnly('purchaseDate') ? '구매일 미설정' : '날짜 선택',
style: TextStyle(
color: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[600] : null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_controller.purchaseDate != null
? '${_controller.purchaseDate!.year}-${_controller.purchaseDate!.month.toString().padLeft(2, '0')}-${_controller.purchaseDate!.day.toString().padLeft(2, '0')}'
: _controller.isFieldReadOnly('purchaseDate') ? '구매일 미설정' : '날짜 선택',
style: TextStyle(
color: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[600]
: null,
),
),
Icon(
Icons.calendar_today,
size: 16,
color: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[600] : null,
),
],
),
),
),
@@ -462,31 +402,21 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
// 구매 가격
Expanded(
child: TextFormField(
initialValue: _controller.purchasePrice != null
? CurrencyFormatter.formatKRW(_controller.purchasePrice)
: '',
child: ShadInputFormField(
controller: _purchasePriceController,
readOnly: _controller.isFieldReadOnly('purchasePrice'),
decoration: InputDecoration(
labelText: _controller.isFieldReadOnly('purchasePrice')
? '구매 가격 🔒' : '구매 가격',
hintText: _controller.isFieldReadOnly('purchasePrice')
? '수정불가' : '₩2,000,000',
border: const OutlineInputBorder(),
filled: _controller.isFieldReadOnly('purchasePrice'),
fillColor: _controller.isFieldReadOnly('purchasePrice')
? Colors.grey[100] : null,
),
style: TextStyle(
color: _controller.isFieldReadOnly('purchasePrice')
? Colors.grey[600] : null,
),
label: Text(_controller.isFieldReadOnly('purchasePrice')
? '구매 가격 🔒' : '구매 가격'),
placeholder: Text(_controller.isFieldReadOnly('purchasePrice')
? '수정불가' : '₩2,000,000'),
keyboardType: _controller.isFieldReadOnly('purchasePrice')
? null : TextInputType.number,
inputFormatters: _controller.isFieldReadOnly('purchasePrice')
? null : [KRWTextInputFormatter()],
onSaved: (value) {
_controller.purchasePrice = CurrencyFormatter.parseKRW(value);
? null : [CurrencyInputFormatter()], // 새로운 통화 포맷터
onChanged: (value) {
// 숫자만 추출하여 저장
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
_controller.purchasePrice = int.tryParse(digitsOnly)?.toDouble();
},
),
),
@@ -499,7 +429,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
}
Widget _buildRemarkSection() {
return Card(
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -513,13 +443,10 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
),
const SizedBox(height: 16),
TextFormField(
ShadInputFormField(
controller: _controller.remarkController,
decoration: const InputDecoration(
labelText: '비고',
hintText: '비고사항을 입력하세요',
border: OutlineInputBorder(),
),
label: const Text('비고'),
placeholder: const Text('비고사항을 입력하세요'),
maxLines: 3,
),
],

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
@@ -9,15 +10,13 @@ import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/utils/equipment_display_helper.dart';
import 'package:superport/screens/equipment/widgets/equipment_history_dialog.dart';
/// shadcn/ui 스타일로 재설계된 장비 관리 화면
class EquipmentList extends StatefulWidget {
final String currentRoute;
const EquipmentList({Key? key, this.currentRoute = Routes.equipment})
: super(key: key);
const EquipmentList({super.key, this.currentRoute = Routes.equipment});
@override
State<EquipmentList> createState() => _EquipmentListState();
@@ -28,7 +27,6 @@ class _EquipmentListState extends State<EquipmentList> {
bool _showDetailedColumns = true;
final TextEditingController _searchController = TextEditingController();
final ScrollController _horizontalScrollController = ScrollController();
final ScrollController _scrollController = ScrollController();
String _selectedStatus = 'all';
// String _searchKeyword = ''; // Removed - unused field
String _appliedSearchKeyword = '';
@@ -92,10 +90,6 @@ class _EquipmentListState extends State<EquipmentList> {
print('DEBUG: Initial filter set - route: ${widget.currentRoute}, status: $_selectedStatus, filter: ${_controller.selectedStatusFilter}'); // 디버그 정보
}
/// 데이터 로드
Future<void> _loadData({bool isRefresh = false}) async {
await _controller.loadData(isRefresh: isRefresh);
}
/// 상태 필터 변경
Future<void> _onStatusFilterChanged(String status) async {
@@ -144,25 +138,6 @@ class _EquipmentListState extends State<EquipmentList> {
_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(equipment!);
});
}
/// 전체 선택/해제
void _onSelectAll(bool? value) {
@@ -194,18 +169,13 @@ class _EquipmentListState extends State<EquipmentList> {
equipments = equipments.where((e) {
final keyword = _appliedSearchKeyword.toLowerCase();
return [
e.equipment.manufacturer,
e.equipment.equipmentNumber, // name → equipmentNumber (메인 필드)
e.equipment.modelName ?? '', // 모델명 추가
e.equipment.category1, // category → category1 (메인 필드)
e.equipment.category2, // subCategory → category2 (메인 필드)
e.equipment.category3, // subSubCategory → category3 (메인 필드)
e.equipment.serialNumber ?? '',
e.equipment.barcode ?? '',
e.equipment.remark ?? '',
e.equipment.warrantyLicense ?? '',
e.notes ?? '',
].any((field) => field.toLowerCase().contains(keyword));
e.equipment.model?.vendor?.name ?? '', // Vendor 이름
e.equipment.serialNumber ?? '', // 시리얼 번호 (메인 필드)
e.equipment.model?.name ?? '', // Model 이름
e.equipment.serialNumber ?? '', // 시리얼 번호 (중복 제거)
e.equipment.barcode ?? '', // 바코드
e.equipment.remark ?? '', // 비고
].any((field) => field.toLowerCase().contains(keyword.toLowerCase()));
}).toList();
}
@@ -215,8 +185,11 @@ class _EquipmentListState extends State<EquipmentList> {
/// 출고 처리 버튼 핸들러
void _handleOutEquipment() async {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('출고할 장비를 선택해주세요.')),
ShadToaster.of(context).show(
const ShadToast(
title: Text('알림'),
description: Text('출고할 장비를 선택해주세요.'),
),
);
return;
}
@@ -241,16 +214,20 @@ class _EquipmentListState extends State<EquipmentList> {
/// 대여 처리 버튼 핸들러
void _handleRentEquipment() async {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('대여할 장비를 선택해주세요.')),
ShadToaster.of(context).show(
const ShadToast(
title: Text('알림'),
description: Text('대여할 장비를 선택해주세요.'),
),
);
return;
}
final selectedEquipmentsSummary = _controller.getSelectedEquipmentsSummary();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${selectedEquipmentsSummary.length}개 장비 대여 기능은 준비 중입니다.'),
ShadToaster.of(context).show(
ShadToast(
title: const Text('알림'),
description: Text('${selectedEquipmentsSummary.length}개 장비 대여 기능은 준비 중입니다.'),
),
);
}
@@ -262,8 +239,11 @@ class _EquipmentListState extends State<EquipmentList> {
.toList();
if (selectedEquipments.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('폐기할 장비를 선택해주세요. (이미 폐기된 장비는 제외)')),
ShadToaster.of(context).show(
const ShadToast(
title: Text('알림'),
description: Text('폐기할 장비를 선택해주세요. (이미 폐기된 장비는 제외)'),
),
);
return;
}
@@ -271,11 +251,11 @@ class _EquipmentListState extends State<EquipmentList> {
// 폐기 사유 입력을 위한 컨트롤러
final TextEditingController reasonController = TextEditingController();
final result = await showDialog<bool>(
final result = await showShadDialog<bool>(
context: context,
builder: (context) => AlertDialog(
builder: (context) => ShadDialog(
title: const Text('폐기 확인'),
content: SingleChildScrollView(
description: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -289,7 +269,7 @@ class _EquipmentListState extends State<EquipmentList> {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'${equipment.manufacturer} ${equipment.equipmentNumber}', // name → equipmentNumber
'${equipment.model?.vendor?.name ?? 'N/A'} ${equipment.serialNumber}', // Vendor + Equipment Number
style: const TextStyle(fontSize: 14),
),
);
@@ -297,25 +277,22 @@ class _EquipmentListState extends State<EquipmentList> {
const SizedBox(height: 16),
const Text('폐기 사유:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
TextField(
ShadInputFormField(
controller: reasonController,
decoration: const InputDecoration(
hintText: '폐기 사유를 입력해주세요',
border: OutlineInputBorder(),
),
placeholder: const Text('폐기 사유를 입력해주세요'),
maxLines: 2,
),
],
),
),
actions: [
TextButton(
ShadButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('취소'),
),
TextButton(
ShadButton.destructive(
onPressed: () => Navigator.pop(context, true),
child: const Text('폐기', style: TextStyle(color: Colors.red)),
child: const Text('폐기'),
),
],
),
@@ -323,11 +300,13 @@ class _EquipmentListState extends State<EquipmentList> {
if (result == true) {
// 로딩 다이얼로그 표시
showDialog(
showShadDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
builder: (context) => const ShadDialog(
child: Center(
child: ShadProgress(),
),
),
);
@@ -338,8 +317,11 @@ class _EquipmentListState extends State<EquipmentList> {
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('선택한 장비가 폐기 처리되었습니다.')),
ShadToaster.of(context).show(
const ShadToast(
title: Text('폐기 완료'),
description: Text('선택한 장비가 폐기 처리되었습니다.'),
),
);
setState(() {
_controller.loadData(isRefresh: true);
@@ -348,8 +330,11 @@ class _EquipmentListState extends State<EquipmentList> {
} catch (e) {
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('폐기 처리 실패: ${e.toString()}')),
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('폐기 실패'),
description: Text(e.toString()),
),
);
}
}
@@ -382,17 +367,17 @@ class _EquipmentListState extends State<EquipmentList> {
/// 삭제 핸들러
void _handleDelete(UnifiedEquipment equipment) {
showDialog(
showShadDialog(
context: context,
builder: (context) => AlertDialog(
builder: (context) => ShadDialog(
title: const Text('삭제 확인'),
content: const Text('이 장비 정보를 삭제하시겠습니까?'),
description: const Text('이 장비 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
ShadButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
ShadButton(
onPressed: () async {
Navigator.pop(context);
try {
@@ -400,48 +385,31 @@ class _EquipmentListState extends State<EquipmentList> {
await _controller.deleteEquipment(equipment.equipment.id!, equipment.status);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('장비가 삭제되었습니다.')),
ShadToaster.of(context).show(
ShadToast(
title: const Text('장비 삭제'),
description: const Text('장비가 삭제되었습니다.'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('삭제 실패: ${e.toString()}'),
backgroundColor: Colors.red,
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('삭제 실패'),
description: Text(e.toString()),
),
);
}
}
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
child: const Text('삭제'),
),
],
),
);
}
/// 이력 보기 핸들러
void _handleHistory(UnifiedEquipment equipment) async {
if (equipment.equipment.id == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('장비 ID가 없습니다.')),
);
return;
}
// 팝업 다이얼로그로 이력 표시
final result = await EquipmentHistoryDialog.show(
context: context,
equipmentId: equipment.equipment.id!,
equipmentName: '${equipment.equipment.manufacturer} ${equipment.equipment.equipmentNumber}', // name → equipmentNumber
);
if (result == true) {
_controller.loadData(isRefresh: true);
}
}
@override
Widget build(BuildContext context) {
@@ -511,7 +479,7 @@ class _EquipmentListState extends State<EquipmentList> {
controller: _searchController,
onSubmitted: (_) => _onSearch(),
decoration: InputDecoration(
hintText: '장비명, 제조사, 카테고리, 시리얼번호 등...',
hintText: '제조사, 모델명, 시리얼번호, 바코드 등...',
hintStyle: TextStyle(color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), fontSize: 14),
prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20),
border: InputBorder.none,
@@ -539,22 +507,21 @@ class _EquipmentListState extends State<EquipmentList> {
const SizedBox(width: 16),
// 상태 필터 드롭다운 (캐시된 데이터 사용)
Container(
SizedBox(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: ShadcnTheme.card,
border: Border.all(color: Colors.black),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedStatus,
onChanged: (value) => _onStatusFilterChanged(value!),
style: TextStyle(fontSize: 14, color: ShadcnTheme.foreground),
icon: const Icon(Icons.arrow_drop_down, size: 20),
items: _buildStatusDropdownItems(),
width: 150,
child: ShadSelect<String>(
selectedOptionBuilder: (context, value) => Text(
_getStatusDisplayText(value),
style: const TextStyle(fontSize: 14),
),
placeholder: const Text('상태 선택'),
options: _buildStatusSelectOptions(),
onChanged: (value) {
if (value != null) {
_onStatusFilterChanged(value);
}
},
),
),
],
@@ -573,12 +540,13 @@ class _EquipmentListState extends State<EquipmentList> {
// TODO: 실제 권한 체크 로직 추가 필요
Row(
children: [
Checkbox(
ShadCheckbox(
value: _controller.includeInactive,
onChanged: (_) => setState(() {
_controller.toggleIncludeInactive();
}),
),
const SizedBox(width: 8),
const Text('비활성 포함'),
],
),
@@ -635,8 +603,11 @@ class _EquipmentListState extends State<EquipmentList> {
ShadcnButton(
text: '재입고',
onPressed: selectedOutCount > 0
? () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('재입고 기능은 준비 중입니다.')),
? () => ShadToaster.of(context).show(
const ShadToast(
title: Text('알림'),
description: Text('재입고 기능은 준비 중입니다.'),
),
)
: null,
variant: selectedOutCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
@@ -646,8 +617,11 @@ class _EquipmentListState extends State<EquipmentList> {
ShadcnButton(
text: '수리 요청',
onPressed: selectedOutCount > 0
? () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('수리 요청 기능은 준비 중입니다.')),
? () => ShadToaster.of(context).show(
const ShadToast(
title: Text('알림'),
description: Text('수리 요청 기능은 준비 중입니다.'),
),
)
: null,
variant: selectedOutCount > 0 ? ShadcnButtonVariant.destructive : ShadcnButtonVariant.secondary,
@@ -661,8 +635,11 @@ class _EquipmentListState extends State<EquipmentList> {
ShadcnButton(
text: '반납',
onPressed: selectedRentCount > 0
? () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('대여 반납 기능은 준비 중입니다.')),
? () => ShadToaster.of(context).show(
const ShadToast(
title: Text('알림'),
description: Text('대여 반납 기능은 준비 중입니다.'),
),
)
: null,
variant: selectedRentCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
@@ -672,8 +649,11 @@ class _EquipmentListState extends State<EquipmentList> {
ShadcnButton(
text: '연장',
onPressed: selectedRentCount > 0
? () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('대여 연장 기능은 준비 중입니다.')),
? () => ShadToaster.of(context).show(
const ShadToast(
title: Text('알림'),
description: Text('대여 연장 기능은 준비 중입니다.'),
),
)
: null,
variant: selectedRentCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
@@ -795,180 +775,212 @@ class _EquipmentListState extends State<EquipmentList> {
}
}
/// 유연한 테이블 빌더
/// 유연한 테이블 빌더 - Virtual Scrolling 적용
Widget _buildFlexibleTable(List<UnifiedEquipment> pagedEquipments, {required bool useExpanded}) {
final hasOutOrRent = pagedEquipments.any((e) =>
e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent
);
// 헤더를 별도로 빌드
Widget header = Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 10,
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
// 체크박스
_buildDataCell(
ShadCheckbox(
value: _isAllSelected(),
onChanged: (bool? value) => _onSelectAll(value),
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
),
// 번호
_buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 50),
// 제조사
_buildHeaderCell('제조사', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 장비번호
_buildHeaderCell('장비번호', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 모델명
_buildHeaderCell('모델명', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 상세 정보 (조건부) - 바코드로 변경
if (_showDetailedColumns) ...[
_buildHeaderCell('바코드', flex: 3, useExpanded: useExpanded, minWidth: 120),
],
// 수량
_buildHeaderCell('수량', flex: 1, useExpanded: useExpanded, minWidth: 50),
// 재고 상태
_buildHeaderCell('재고', flex: 2, useExpanded: useExpanded, minWidth: 80),
// 상태
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 입출고일
_buildHeaderCell('입출고일', flex: 2, useExpanded: useExpanded, minWidth: 80),
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90),
],
),
);
// 빈 상태 처리
if (pagedEquipments.isEmpty) {
return Column(
children: [
header,
Expanded(
child: Center(
child: Text(
'데이터가 없습니다',
style: ShadcnTheme.bodyMedium,
),
),
),
],
);
}
// Virtual Scrolling을 위한 CustomScrollView 사용
return Column(
children: [
// 테이블 헤더
Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 10,
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
// 체크박스
_buildDataCell(
Checkbox(
value: _isAllSelected(),
onChanged: _onSelectAll,
header, // 헤더는 고정
Expanded(
child: ListView.builder(
controller: ScrollController(),
itemCount: pagedEquipments.length,
itemBuilder: (context, index) {
final UnifiedEquipment equipment = pagedEquipments[index];
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 4,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
),
// 번호
_buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 50),
// 제조사
_buildHeaderCell('제조사', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 장비번호
_buildHeaderCell('장비번호', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 모델명
_buildHeaderCell('모델명', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 상세 정보 (조건부)
if (_showDetailedColumns) ...[
_buildHeaderCell('시리얼번호', flex: 3, useExpanded: useExpanded, minWidth: 120),
],
// 수량
_buildHeaderCell('수량', flex: 1, useExpanded: useExpanded, minWidth: 50),
// 상태
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 입출고일
_buildHeaderCell('입출고일', flex: 2, useExpanded: useExpanded, minWidth: 80),
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90),
],
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
// 체크박스
_buildDataCell(
ShadCheckbox(
value: _selectedItems.contains(equipment.equipment.id ?? 0),
onChanged: (bool? value) {
if (equipment.equipment.id != null) {
_onItemSelected(equipment.equipment.id!, value ?? false);
}
},
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
),
// 번호
_buildDataCell(
Text(
'${((_controller.currentPage - 1) * _controller.pageSize) + index + 1}',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 50,
),
// 제조사
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.model?.vendor?.name ?? 'N/A',
equipment.equipment.model?.vendor?.name ?? 'N/A',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 장비번호
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.serialNumber ?? '',
equipment.equipment.serialNumber ?? '',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 모델명
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.model?.name ?? '-',
equipment.equipment.model?.name ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 상세 정보 (조건부) - 바코드로 변경
if (_showDetailedColumns) ...[
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.barcode ?? '-',
equipment.equipment.barcode ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
],
// 수량 (백엔드에서 관리하지 않으므로 고정값)
_buildDataCell(
Text(
'1',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 50,
),
// 재고 상태
_buildDataCell(
_buildInventoryStatus(equipment),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
// 상태
_buildDataCell(
_buildStatusBadge(equipment.status),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 입출고일
_buildDataCell(
_buildCreatedDateWidget(equipment),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
// 관리
_buildDataCell(
_buildActionButtons(equipment.equipment.id ?? 0),
flex: 2,
useExpanded: useExpanded,
minWidth: 90,
),
],
),
);
},
),
),
// 테이블 데이터
...pagedEquipments.asMap().entries.map((entry) {
final int index = entry.key;
final UnifiedEquipment equipment = entry.value;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 4,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
// 체크박스
_buildDataCell(
Checkbox(
value: _selectedItems.contains(equipment.equipment.id ?? 0),
onChanged: (bool? value) {
if (equipment.equipment.id != null) {
_onItemSelected(equipment.equipment.id!, value ?? false);
}
},
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
),
// 번호
_buildDataCell(
Text(
'${((_controller.currentPage - 1) * _controller.pageSize) + index + 1}',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 50,
),
// 제조사
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.manufacturer,
equipment.equipment.manufacturer,
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 장비번호
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.equipmentNumber, // name → equipmentNumber (메인 필드)
equipment.equipment.equipmentNumber,
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 모델명
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.modelName ?? '-', // 모델명 표시
equipment.equipment.modelName ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 상세 정보 (조건부)
if (_showDetailedColumns) ...[
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.serialNumber ?? '-',
equipment.equipment.serialNumber ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
],
// 수량
_buildDataCell(
Text(
equipment.equipment.quantity.toString(),
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 50,
),
// 상태
_buildDataCell(
_buildStatusBadge(equipment.status),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 입출고일
_buildDataCell(
_buildCreatedDateWidget(equipment),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
// 관리
_buildDataCell(
_buildActionButtons(equipment.equipment.id ?? 0),
flex: 2,
useExpanded: useExpanded,
minWidth: 90,
),
],
),
);
}).toList(),
],
);
}
@@ -1051,6 +1063,60 @@ class _EquipmentListState extends State<EquipmentList> {
);
}
/// 재고 상태 위젯 빌더 (백엔드 기반 단순화)
Widget _buildInventoryStatus(UnifiedEquipment equipment) {
// 백엔드 Equipment_History 기반으로 단순 상태만 표시
Widget stockInfo;
if (equipment.status == EquipmentStatus.in_) {
// 입고 상태: 재고 있음
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green, size: 16),
const SizedBox(width: 4),
Text(
'보유중',
style: ShadcnTheme.bodySmall.copyWith(color: Colors.green[700]),
),
],
);
} else if (equipment.status == EquipmentStatus.out) {
// 출고 상태: 재고 없음
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warning, color: Colors.orange, size: 16),
const SizedBox(width: 4),
Text(
'출고됨',
style: ShadcnTheme.bodySmall.copyWith(color: Colors.orange[700]),
),
],
);
} else if (equipment.status == EquipmentStatus.rent) {
// 대여 상태
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, color: Colors.blue, size: 16),
const SizedBox(width: 4),
Text(
'대여중',
style: ShadcnTheme.bodySmall.copyWith(color: Colors.blue[700]),
),
],
);
} else {
// 기타 상태
stockInfo = Text(
'-',
style: ShadcnTheme.bodySmall,
);
}
return stockInfo;
}
/// 상태 배지 빌더
Widget _buildStatusBadge(String status) {
String displayText;
@@ -1148,7 +1214,7 @@ class _EquipmentListState extends State<EquipmentList> {
final result = await EquipmentHistoryDialog.show(
context: context,
equipmentId: equipmentId,
equipmentName: '${equipment.equipment.manufacturer} ${equipment.equipment.equipmentNumber}', // name → equipmentNumber
equipmentName: '${equipment.equipment.model?.vendor?.name ?? 'N/A'} ${equipment.equipment.serialNumber}', // Vendor + Equipment Number
);
if (result == true) {
@@ -1156,15 +1222,6 @@ class _EquipmentListState extends State<EquipmentList> {
}
}
// 편집 다이얼로그 표시
void _showEditDialog(UnifiedEquipment equipment) {
_handleEdit(equipment);
}
// 삭제 다이얼로그 표시
void _showDeleteDialog(UnifiedEquipment equipment) {
_handleDelete(equipment);
}
// 편집 핸들러 (액션 버튼에서 호출) - 장비 ID로 처리
void _handleEditById(int equipmentId) {
@@ -1197,27 +1254,45 @@ class _EquipmentListState extends State<EquipmentList> {
});
}
/// 페이지 데이터 가져오기
List<UnifiedEquipment> _getPagedEquipments() {
// 서버 페이지네이션 사용: 컨트롤러의 items가 이미 페이지네이션된 데이터
// 로컬 필터링만 적용
return _getFilteredEquipments();
}
// 사용하지 않는 카테고리 관련 함수들 제거됨 (리스트 API에서 제공하지 않음)
/// 캐시된 데이터를 사용한 상태 드롭다운 아이템 생성
List<DropdownMenuItem<String>> _buildStatusDropdownItems() {
List<DropdownMenuItem<String>> items = [
const DropdownMenuItem(value: 'all', child: Text('전체')),
/// 상태 표시 텍스트 가져오기
String _getStatusDisplayText(String status) {
switch (status) {
case 'all':
return '전체';
case 'in':
return '입고';
case 'out':
return '출고';
case 'rent':
return '대여';
case 'repair':
return '수리중';
case 'damaged':
return '손상';
case 'lost':
return '분실';
case 'disposed':
return '폐기';
default:
return '전체';
}
}
/// 캐시된 데이터를 사용한 상태 선택 옵션 생성
List<ShadOption<String>> _buildStatusSelectOptions() {
List<ShadOption<String>> options = [
const ShadOption(value: 'all', child: Text('전체')),
];
// 캐시된 상태 데이터에서 드롭다운 아이템 생성
// 캐시된 상태 데이터에서 선택 옵션 생성
final cachedStatuses = _controller.getCachedEquipmentStatuses();
for (final status in cachedStatuses) {
items.add(
DropdownMenuItem(
options.add(
ShadOption(
value: status.id,
child: Text(status.name),
),
@@ -1226,18 +1301,18 @@ class _EquipmentListState extends State<EquipmentList> {
// 캐시된 데이터가 없을 때 폴백으로 하드코딩된 상태 사용
if (cachedStatuses.isEmpty) {
items.addAll([
const DropdownMenuItem(value: 'in', child: Text('입고')),
const DropdownMenuItem(value: 'out', child: Text('출고')),
const DropdownMenuItem(value: 'rent', child: Text('대여')),
const DropdownMenuItem(value: 'repair', child: Text('수리중')),
const DropdownMenuItem(value: 'damaged', child: Text('손상')),
const DropdownMenuItem(value: 'lost', child: Text('분실')),
const DropdownMenuItem(value: 'disposed', child: Text('폐기')),
options.addAll([
const ShadOption(value: 'in', child: Text('입고')),
const ShadOption(value: 'out', child: Text('출고')),
const ShadOption(value: 'rent', child: Text('대여')),
const ShadOption(value: 'repair', child: Text('수리중')),
const ShadOption(value: 'damaged', child: Text('손상')),
const ShadOption(value: 'lost', child: Text('분실')),
const ShadOption(value: 'disposed', child: Text('폐기')),
]);
}
return items;
return options;
}
// 사용하지 않는 현재위치, 점검일 관련 함수들 제거됨 (리스트 API에서 제공하지 않음)

View File

@@ -1,15 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
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/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_shadcn.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';
import 'package:superport/screens/common/widgets/remark_input.dart';
class EquipmentOutFormScreen extends StatefulWidget {
@@ -19,12 +17,12 @@ class EquipmentOutFormScreen extends StatefulWidget {
final List<Map<String, dynamic>>? selectedEquipments;
const EquipmentOutFormScreen({
Key? key,
super.key,
this.equipmentOutId,
this.selectedEquipment,
this.selectedEquipmentInId,
this.selectedEquipments,
}) : super(key: key);
});
@override
State<EquipmentOutFormScreen> createState() => _EquipmentOutFormScreenState();
@@ -68,11 +66,9 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
}
// 각 장비별로 전체 폭을 사용하는 리스트로 구현
return Container(
return SizedBox(
width: double.infinity, // 전체 폭 사용
child: Card(
elevation: 2,
margin: EdgeInsets.zero, // margin 제거
child: ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -157,7 +153,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
);
if (picked != null) {
equipment.warrantyStartDate = picked;
controller.notifyListeners();
// controller.notifyListeners() 호출 불필요 - 데이터 변경 시 자동 알림
}
},
child: Container(
@@ -192,7 +188,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
);
if (picked != null) {
equipment.warrantyEndDate = picked;
controller.notifyListeners();
// controller.notifyListeners() 호출 불필요 - 데이터 변경 시 자동 알림
}
},
child: Container(
@@ -254,7 +250,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
title: const Text('장비 출고'),
),
body: const Center(
child: CircularProgressIndicator(),
child: ShadProgress(),
),
);
}
@@ -286,7 +282,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
ShadButton(
onPressed: () {
controller.clearError();
controller.loadDropdownData();
@@ -305,7 +301,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
controller.isEditMode
? '장비 출고 수정'
: totalSelectedEquipments > 0
? '장비 출고 등록 (${totalSelectedEquipments})'
? '장비 출고 등록 ($totalSelectedEquipments)'
: '장비 출고 등록',
),
),
@@ -323,7 +319,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
_buildSummaryTable(controller)
else if (controller.selectedEquipment != null)
// 단일 장비 요약 카드도 전체 폭으로 맞춤
Container(
SizedBox(
width: double.infinity,
child: EquipmentSingleSummaryCard(
equipment: controller.selectedEquipment!,
@@ -334,7 +330,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 요약 카드 아래 라디오 버튼 추가
const SizedBox(height: 12),
// 전체 폭을 사용하는 라디오 버튼
Container(width: double.infinity, child: _buildOutTypeRadio(controller)),
SizedBox(width: double.infinity, child: _buildOutTypeRadio(controller)),
const SizedBox(height: 16),
// 출고 정보 입력 섹션 (수정/등록)
_buildOutgoingInfoSection(context, controller),
@@ -380,7 +376,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
child: ShadButton(
onPressed:
canSubmit
? () {
@@ -408,10 +404,10 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
controller.saveEquipmentOut(context).then((success) {
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('출고 완료되었습니다.'),
duration: Duration(seconds: 2),
ShadToaster.of(context).show(
ShadToast(
title: const Text('출고 완료'),
description: const Text('출고가 완료되었습니다.'),
),
);
Navigator.pop(context, true);
@@ -419,22 +415,8 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
});
}
: null,
style:
canSubmit
? ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
foregroundColor: Colors.white,
)
: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade300,
foregroundColor: Colors.grey.shade700,
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
child: Text(
controller.isEditMode ? '수정하기' : '등록하기',
),
),
),
@@ -470,25 +452,19 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 장비 상태 변경 (출고 시 'inuse'로 자동 설정)
const Text('장비 상태 설정', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: 'inuse', // 출고 시 기본값
decoration: const InputDecoration(
hintText: '출고 후 장비 상태',
labelText: '출고 후 상태 *',
ShadSelect<String>(
initialValue: 'inuse', // 출고 시 기본값
placeholder: const Text('출고 후 장비 상태'),
selectedOptionBuilder: (context, value) => Text(
value == 'inuse' ? '사용 중' : '유지보수',
),
items: const [
DropdownMenuItem(value: 'inuse', child: Text('사용 중')),
DropdownMenuItem(value: 'maintenance', child: Text('유지보수')),
options: const [
ShadOption(value: 'inuse', child: Text('사용 중')),
ShadOption(value: 'maintenance', child: Text('유지보수')),
],
onChanged: (value) {
// controller.equipmentStatus = value; // TODO: 컨트롤러에 추가 필요
},
validator: (value) {
if (value == null || value.isEmpty) {
return '출고 후 상태를 선택해주세요';
}
return null;
},
),
const SizedBox(height: 16),
@@ -517,30 +493,23 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
...List.generate(controller.selectedCompanies.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: DropdownButtonFormField<String>(
value: controller.selectedCompanies[index],
decoration: InputDecoration(
hintText: index == 0 ? '출고할 회사를 선택하세요' : '추가된 출고할 회사를 선택하세요',
// 이전 드롭다운에 값이 선택되지 않았으면 비활성화
enabled:
index == 0 ||
controller.selectedCompanies[index - 1] != null,
),
items:
child: ShadSelect<String>(
initialValue: controller.selectedCompanies[index],
placeholder: Text(index == 0 ? '출고할 회사를 선택하세요' : '추가된 출고할 회사를 선택하세요'),
enabled:
index == 0 ||
controller.selectedCompanies[index - 1] != null,
selectedOptionBuilder: (context, value) =>
_buildCompanyDropdownItem(value ?? '', controller),
options:
controller.availableCompaniesPerDropdown[index]
.map(
(item) => DropdownMenuItem<String>(
(item) => ShadOption(
value: item.name,
child: _buildCompanyDropdownItem(item.name, controller),
),
)
.toList(),
validator: (value) {
if (index == 0 && (value == null || value.isEmpty)) {
return '출고 회사를 선택해주세요';
}
return null;
},
onChanged:
(index == 0 ||
controller.selectedCompanies[index - 1] != null)
@@ -740,19 +709,19 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(hintText: hint),
items:
ShadSelect<String>(
initialValue: value,
placeholder: Text(hint),
selectedOptionBuilder: (context, value) => Text(value ?? ''),
options:
items
.map(
(item) => DropdownMenuItem<String>(
(item) => ShadOption(
value: item,
child: Text(item),
),
)
.toList(),
validator: validator,
onChanged: onChanged,
),
const SizedBox(height: 12),
@@ -835,23 +804,6 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
);
}
// 회사 ID에 따른 담당자 정보를 가져와 표시하는 위젯 목록 생성
List<Widget> _getUsersForCompany(CompanyBranchInfo companyInfo) {
final List<Widget> userWidgets = [];
// 판교지점 특별 처리
if (companyInfo.originalName == "판교지점" &&
companyInfo.parentCompanyName == "LG전자") {
userWidgets.add(
Text(
'정수진 사원 010-4567-8901 jung.soojin@lg.com',
style: ShadcnTheme.bodyMedium,
),
);
}
return userWidgets;
}
// 출고/대여/폐기 라디오 버튼 위젯
Widget _buildOutTypeRadio(EquipmentOutFormController controller) {

View File

@@ -14,7 +14,7 @@ class AutocompleteTextField extends StatefulWidget {
final FocusNode? focusNode;
const AutocompleteTextField({
Key? key,
super.key,
required this.label,
required this.value,
required this.items,
@@ -23,7 +23,7 @@ class AutocompleteTextField extends StatefulWidget {
this.isRequired = false,
this.hintText = '',
this.focusNode,
}) : super(key: key);
});
@override
State<AutocompleteTextField> createState() => _AutocompleteTextFieldState();
@@ -141,7 +141,7 @@ class _AutocompleteTextFieldState extends State<AutocompleteTextField> {
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
color: Colors.grey.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),

View File

@@ -16,7 +16,7 @@ class CustomDropdownField extends StatefulWidget {
final GlobalKey fieldKey;
const CustomDropdownField({
Key? key,
super.key,
required this.label,
required this.hint,
required this.required,
@@ -29,7 +29,7 @@ class CustomDropdownField extends StatefulWidget {
required this.onDropdownPressed,
required this.layerLink,
required this.fieldKey,
}) : super(key: key);
});
@override
State<CustomDropdownField> createState() => _CustomDropdownFieldState();

View File

@@ -193,13 +193,14 @@ class EquipmentBasicInfoSection extends StatelessWidget {
focusNode: nameFieldFocusNode,
items: controller.equipmentNames,
onChanged: (value) {
controller.name = value;
// Equipment name은 model 선택으로 자동 설정됨
// controller.name = value;
},
onFieldSubmitted: (value) {
final suggestion = getEquipmentNameAutocompleteSuggestion(value);
if (suggestion != null && suggestion.length > value.length) {
equipmentNameController.text = suggestion;
controller.name = suggestion;
// controller.name = suggestion;
equipmentNameController.selection = TextSelection.collapsed(
offset: suggestion.length,
);

View File

@@ -12,10 +12,10 @@ class EquipmentHistoryDialog extends StatefulWidget {
final String equipmentName;
const EquipmentHistoryDialog({
Key? key,
super.key,
required this.equipmentId,
required this.equipmentName,
}) : super(key: key);
});
@override
State<EquipmentHistoryDialog> createState() => _EquipmentHistoryDialogState();
@@ -138,9 +138,9 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
setState(() {
if (isRefresh) {
_histories = histories;
_histories = histories.cast<EquipmentHistoryDto>();
} else {
_histories.addAll(histories);
_histories.addAll(histories.cast<EquipmentHistoryDto>());
}
_filterHistories();
_hasMore = histories.length == _perPage;
@@ -217,7 +217,7 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.02),
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -239,7 +239,7 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: typeColor.withOpacity(0.1),
color: typeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Center(
@@ -379,7 +379,7 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, 10),
),

View File

@@ -0,0 +1,402 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/screens/equipment/controllers/equipment_history_controller.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
import 'package:intl/intl.dart';
/// Equipment의 입출고 이력을 표시하는 패널 위젯
class EquipmentHistoryPanel extends StatefulWidget {
final Equipment equipment;
final bool isExpanded;
final VoidCallback? onToggleExpand;
const EquipmentHistoryPanel({
super.key,
required this.equipment,
this.isExpanded = false,
this.onToggleExpand,
});
@override
State<EquipmentHistoryPanel> createState() => _EquipmentHistoryPanelState();
}
class _EquipmentHistoryPanelState extends State<EquipmentHistoryPanel> {
late EquipmentHistoryController _controller;
bool _isLoading = false;
List<EquipmentHistoryDto> _histories = [];
@override
void initState() {
super.initState();
_controller = GetIt.instance<EquipmentHistoryController>();
if (widget.equipment.id != null) {
_loadHistory();
}
}
Future<void> _loadHistory() async {
if (widget.equipment.id == null) return;
setState(() {
_isLoading = true;
});
try {
// Equipment ID로 이력 조회
await _controller.searchEquipmentHistories(
equipmentId: widget.equipment.id,
);
setState(() {
_histories = _controller.historyList;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
InkWell(
onTap: widget.onToggleExpand,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.history,
size: 20,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
Text(
'장비 이력',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
if (_histories.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_histories.length}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.green,
),
),
),
],
),
Icon(
widget.isExpanded ? Icons.expand_less : Icons.expand_more,
color: Colors.grey[600],
),
],
),
),
),
// 확장된 내용
if (widget.isExpanded) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 현재 상태 요약
_buildCurrentStatusSummary(),
const SizedBox(height: 16),
// 이력 표시
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
)
else if (_histories.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey[300]!,
style: BorderStyle.solid,
),
),
child: Column(
children: [
Icon(
Icons.history,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 12),
Text(
'입출고 이력이 없습니다',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
],
),
)
else
_buildHistoryList(),
],
),
),
],
],
),
);
}
Widget _buildCurrentStatusSummary() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.blue[200]!,
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: Colors.blue[700],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'현재 장비 정보',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue[900],
),
),
const SizedBox(height: 4),
Text(
'장비번호: ${widget.equipment.serialNumber}',
style: TextStyle(
fontSize: 13,
color: Colors.blue[700],
),
),
if (widget.equipment.model != null) ...[
Text(
'제조사: ${widget.equipment.model?.vendor?.name ?? 'N/A'}',
style: TextStyle(
fontSize: 13,
color: Colors.blue[700],
),
),
Text(
'모델: ${widget.equipment.model?.name ?? 'N/A'}',
style: TextStyle(
fontSize: 13,
color: Colors.blue[700],
),
),
],
if (widget.equipment.serialNumber != null)
Text(
'시리얼: ${widget.equipment.serialNumber}',
style: TextStyle(
fontSize: 13,
color: Colors.blue[700],
),
),
],
),
),
],
),
);
}
Widget _buildHistoryList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'입출고 이력',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
// 테이블 헤더
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(7),
topRight: Radius.circular(7),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text('거래유형',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Colors.grey[700])),
),
Expanded(
flex: 2,
child: Text('날짜',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Colors.grey[700])),
),
Expanded(
flex: 1,
child: Text('수량',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Colors.grey[700])),
),
Expanded(
flex: 2,
child: Text('창고',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Colors.grey[700])),
),
Expanded(
flex: 3,
child: Text('비고',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Colors.grey[700])),
),
],
),
),
// 테이블 바디
..._histories.map((history) => _buildHistoryRow(history)),
],
),
),
],
);
}
Widget _buildHistoryRow(EquipmentHistoryDto history) {
final dateFormat = DateFormat('yyyy-MM-dd');
final isIn = history.transactionType == 'I';
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey[200]!),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Row(
children: [
Icon(
isIn ? Icons.arrow_downward : Icons.arrow_upward,
size: 16,
color: isIn ? Colors.green : Colors.red,
),
const SizedBox(width: 4),
Text(
isIn ? '입고' : '출고',
style: TextStyle(
fontSize: 13,
color: isIn ? Colors.green[700] : Colors.red[700],
fontWeight: FontWeight.w500,
),
),
],
),
),
Expanded(
flex: 2,
child: Text(
dateFormat.format(history.transactedAt),
style: const TextStyle(fontSize: 13),
),
),
Expanded(
flex: 1,
child: Text(
history.quantity?.toString() ?? '-',
style: const TextStyle(fontSize: 13),
),
),
Expanded(
flex: 2,
child: Text(
'-', // 창고 정보는 백엔드에서 제공하지 않음
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 3,
child: Text(
history.remark ?? '-',
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

@@ -27,7 +27,7 @@ class EquipmentMultiSummaryCard extends StatelessWidget {
...selectedEquipments.map((equipmentData) {
final equipment = equipmentData['equipment'] as Equipment;
return EquipmentSingleSummaryCard(equipment: equipment);
}).toList(),
}),
],
);
}
@@ -91,7 +91,7 @@ class EquipmentSingleSummaryCard extends StatelessWidget {
border: Border.all(color: Colors.blue.shade300),
),
child: Text(
'수량: ${equipment.quantity}',
'수량: 1',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
@@ -110,10 +110,10 @@ class EquipmentSingleSummaryCard extends StatelessWidget {
: '정보 없음',
),
EquipmentSummaryRow(
label: '카테고리',
label: '모델명',
value:
equipment.category.isNotEmpty
? '${equipment.category} > ${equipment.subCategory} > ${equipment.subSubCategory}'
equipment.modelName.isNotEmpty
? equipment.modelName
: '정보 없음',
),
EquipmentSummaryRow(
@@ -126,19 +126,13 @@ class EquipmentSingleSummaryCard extends StatelessWidget {
),
EquipmentSummaryRow(
label: '출고 수량',
value: equipment.quantity.toString(),
value: '1',
),
EquipmentSummaryRow(
label: '입고일',
value: _formatDate(equipment.inDate),
),
// 워런티 정보 추가
if (equipment.warrantyLicense != null &&
equipment.warrantyLicense!.isNotEmpty)
EquipmentSummaryRow(
label: '워런티 라이센스',
value: equipment.warrantyLicense!,
),
// 워런티 라이센스 필드는 백엔드에서 제거됨
EquipmentSummaryRow(
label: '워런티 시작일',
value: _formatDate(equipment.warrantyStartDate),

View File

@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/screens/vendor/controllers/vendor_controller.dart';
import 'package:superport/screens/model/controllers/model_controller.dart';
import 'package:superport/injection_container.dart';
/// Equipment 등록/수정 폼에서 사용할 Vendor→Model cascade 선택 위젯
class EquipmentVendorModelSelector extends StatefulWidget {
final int? initialVendorId;
final int? initialModelId;
final Function(int? vendorId, int? modelId) onChanged;
final bool isReadOnly;
const EquipmentVendorModelSelector({
super.key,
this.initialVendorId,
this.initialModelId,
required this.onChanged,
this.isReadOnly = false,
});
@override
State<EquipmentVendorModelSelector> createState() => _EquipmentVendorModelSelectorState();
}
class _EquipmentVendorModelSelectorState extends State<EquipmentVendorModelSelector> {
late VendorController _vendorController;
late ModelController _modelController;
int? _selectedVendorId;
int? _selectedModelId;
List<ModelDto> _filteredModels = [];
bool _isLoadingVendors = false;
bool _isLoadingModels = false;
@override
void initState() {
super.initState();
_vendorController = getIt<VendorController>();
_modelController = getIt<ModelController>();
_selectedVendorId = widget.initialVendorId;
_selectedModelId = widget.initialModelId;
_loadInitialData();
}
Future<void> _loadInitialData() async {
setState(() => _isLoadingVendors = true);
try {
// Vendor 목록 로드
await _vendorController.loadVendors();
// 초기 vendor가 설정되어 있으면 해당 vendor의 모델 로드
if (_selectedVendorId != null) {
await _loadModelsForVendor(_selectedVendorId!);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('데이터 로드 실패: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoadingVendors = false);
}
}
}
Future<void> _loadModelsForVendor(int vendorId) async {
setState(() {
_isLoadingModels = true;
_filteredModels = [];
_selectedModelId = null; // Vendor 변경 시 Model 선택 초기화
});
try {
// 특정 vendor의 모델 목록 로드
await _modelController.refreshModels();
// vendor에 해당하는 모델만 필터링
final allModels = _modelController.allModels;
_filteredModels = allModels.where((model) => model.vendorsId == vendorId).toList();
// 초기 모델이 설정되어 있고 필터링된 목록에 있으면 유지
if (widget.initialModelId != null &&
_filteredModels.any((m) => m.id == widget.initialModelId)) {
_selectedModelId = widget.initialModelId;
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('모델 로드 실패: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoadingModels = false);
}
}
}
void _onVendorChanged(int? vendorId) {
setState(() {
_selectedVendorId = vendorId;
_selectedModelId = null; // Vendor 변경 시 Model 초기화
});
if (vendorId != null) {
_loadModelsForVendor(vendorId);
} else {
setState(() {
_filteredModels = [];
});
}
// 변경사항 콜백
widget.onChanged(_selectedVendorId, _selectedModelId);
}
void _onModelChanged(int? modelId) {
setState(() {
_selectedModelId = modelId;
});
// 변경사항 콜백
widget.onChanged(_selectedVendorId, _selectedModelId);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Vendor 선택 드롭다운
_buildVendorDropdown(),
const SizedBox(height: 16),
// Model 선택 드롭다운 (Vendor 선택 후 활성화)
_buildModelDropdown(),
],
);
}
Widget _buildVendorDropdown() {
if (_isLoadingVendors) {
return const Center(child: CircularProgressIndicator());
}
final vendors = _vendorController.vendors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isReadOnly ? '제조사 * 🔒' : '제조사 *',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
ShadSelect<int>(
placeholder: const Text('제조사를 선택하세요'),
options: vendors.map((vendor) {
return ShadOption(
value: vendor.id!,
child: Text(vendor.name),
);
}).toList(),
selectedOptionBuilder: (context, value) {
final vendor = vendors.firstWhere((v) => v.id == value);
return Text(vendor.name);
},
onChanged: widget.isReadOnly ? null : _onVendorChanged,
initialValue: _selectedVendorId,
enabled: !widget.isReadOnly,
),
],
);
}
Widget _buildModelDropdown() {
if (_isLoadingModels) {
return const Center(child: CircularProgressIndicator());
}
// Vendor가 선택되지 않으면 비활성화
final isEnabled = !widget.isReadOnly && _selectedVendorId != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isReadOnly ? '모델 * 🔒' : '모델 *',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
ShadSelect<int>(
placeholder: Text(
_selectedVendorId == null
? '먼저 제조사를 선택하세요'
: '모델을 선택하세요'
),
options: _filteredModels.map((model) {
return ShadOption(
value: model.id,
child: Text(model.name),
);
}).toList(),
selectedOptionBuilder: (context, value) {
final model = _filteredModels.firstWhere((m) => m.id == value);
return Text(model.name);
},
onChanged: isEnabled ? _onModelChanged : null,
initialValue: _selectedModelId,
enabled: isEnabled,
),
],
);
}
}