backup: 사용하지 않는 파일 삭제 전 복구 지점

- 전체 371개 파일 중 82개 미사용 파일 식별
- Phase 1: 33개 파일 삭제 예정 (100% 안전)
- Phase 2: 30개 파일 삭제 검토 예정
- Phase 3: 19개 파일 수동 검토 예정

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-09-02 19:51:40 +09:00
parent 650cd4be55
commit c419f8f458
149 changed files with 12934 additions and 3644 deletions

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import '../../../data/models/equipment/equipment_dto.dart';
import '../../../domain/usecases/equipment/get_equipments_usecase.dart';
import '../../../domain/usecases/equipment/get_equipment_detail_usecase.dart';
import '../../../domain/usecases/equipment/create_equipment_usecase.dart';
import '../../../domain/usecases/equipment/update_equipment_usecase.dart';
import '../../../domain/usecases/equipment/delete_equipment_usecase.dart';
import '../../../domain/usecases/equipment/restore_equipment_usecase.dart';
import '../../../core/constants/app_constants.dart';
@injectable
class EquipmentController with ChangeNotifier {
final GetEquipmentsUseCase _getEquipmentsUseCase;
final GetEquipmentDetailUseCase _getEquipmentDetailUseCase;
final CreateEquipmentUseCase _createEquipmentUseCase;
final UpdateEquipmentUseCase _updateEquipmentUseCase;
final DeleteEquipmentUseCase _deleteEquipmentUseCase;
final RestoreEquipmentUseCase _restoreEquipmentUseCase;
// 상태 관리
bool _isLoading = false;
String? _error;
List<EquipmentDto> _equipments = [];
EquipmentDto? _selectedEquipment;
// 페이지네이션
int _currentPage = 1;
int _totalPages = 0;
int _totalItems = 0;
final int _pageSize = AppConstants.equipmentPageSize;
// 필터
String? _searchQuery;
bool _includeDeleted = false;
EquipmentController(
this._getEquipmentsUseCase,
this._getEquipmentDetailUseCase,
this._createEquipmentUseCase,
this._updateEquipmentUseCase,
this._deleteEquipmentUseCase,
this._restoreEquipmentUseCase,
);
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasError => _error != null;
List<EquipmentDto> get equipments => _equipments;
EquipmentDto? get selectedEquipment => _selectedEquipment;
int get currentPage => _currentPage;
int get totalPages => _totalPages;
int get totalItems => _totalItems;
int get pageSize => _pageSize;
String? get searchQuery => _searchQuery;
bool get includeDeleted => _includeDeleted;
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String? error) {
_error = error;
notifyListeners();
}
void clearError() {
_error = null;
notifyListeners();
}
/// 장비 목록 조회
Future<void> loadEquipments({
int page = 1,
int perPage = AppConstants.equipmentPageSize,
String? search,
bool refresh = false,
}) async {
try {
if (refresh) {
_equipments.clear();
_currentPage = 1;
notifyListeners();
}
_setLoading(true);
final params = GetEquipmentsParams(
page: page,
perPage: perPage,
search: search,
);
final response = await _getEquipmentsUseCase(params);
response.fold(
(failure) => _setError('장비 목록을 불러오는데 실패했습니다: ${failure.message}'),
(data) {
_equipments = data.items;
_currentPage = page;
_totalPages = data.totalPages;
_totalItems = data.totalElements;
_searchQuery = search;
clearError();
},
);
} catch (e) {
_setError('장비 목록을 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 장비 상세 조회
Future<void> loadEquipment(int id) async {
try {
_setLoading(true);
final response = await _getEquipmentDetailUseCase(id);
response.fold(
(failure) => _setError('장비 정보를 불러오는데 실패했습니다: ${failure.message}'),
(equipment) {
_selectedEquipment = equipment;
clearError();
},
);
} catch (e) {
_setError('장비 정보를 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 장비 생성
Future<bool> createEquipment(EquipmentRequestDto request) async {
try {
_setLoading(true);
final response = await _createEquipmentUseCase(request);
return response.fold(
(failure) {
_setError('장비 생성에 실패했습니다: ${failure.message}');
return false;
},
(equipment) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 생성에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 장비 수정
Future<bool> updateEquipment(int id, EquipmentUpdateRequestDto request) async {
try {
_setLoading(true);
final params = UpdateEquipmentParams(id: id, request: request);
final response = await _updateEquipmentUseCase(params);
return response.fold(
(failure) {
_setError('장비 수정에 실패했습니다: ${failure.message}');
return false;
},
(equipment) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 수정에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 장비 삭제 (Soft Delete)
Future<bool> deleteEquipment(int id) async {
try {
_setLoading(true);
final response = await _deleteEquipmentUseCase(id);
return response.fold(
(failure) {
_setError('장비 삭제에 실패했습니다: ${failure.message}');
return false;
},
(_) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 삭제에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 장비 복구
Future<bool> restoreEquipment(int id) async {
try {
_setLoading(true);
final response = await _restoreEquipmentUseCase(id);
return response.fold(
(failure) {
_setError('장비 복구에 실패했습니다: ${failure.message}');
return false;
},
(equipment) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 복구에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 페이지 변경
Future<void> goToPage(int page) async {
if (page < 1 || page > _totalPages || page == _currentPage) return;
await loadEquipments(
page: page,
search: _searchQuery,
);
}
/// 검색 설정
void setSearch(String? search) {
_searchQuery = search;
loadEquipments(
search: search,
refresh: true,
);
}
/// 새로고침
Future<void> refresh() async {
await loadEquipments(
page: 1,
search: _searchQuery,
refresh: true,
);
}
/// 선택된 장비 초기화
void clearSelectedEquipment() {
_selectedEquipment = null;
notifyListeners();
}
}

View File

@@ -2,12 +2,14 @@ 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 '../../../data/models/model/model_dto.dart';
import '../../../data/models/vendor_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 '../../../domain/usecases/models/get_models_usecase.dart';
import '../../../domain/usecases/vendor_usecase.dart';
import '../../../core/errors/failures.dart';
/// 장비 폼 컨트롤러 (생성/수정)
@@ -18,19 +20,22 @@ class EquipmentFormController extends ChangeNotifier {
final UpdateEquipmentUseCase _updateEquipmentUseCase;
final GetEquipmentDetailUseCase _getEquipmentDetailUseCase;
final GetCompaniesUseCase _getCompaniesUseCase;
final ModelUseCase _modelUseCase;
final GetModelsUseCase _getModelsUseCase;
final VendorUseCase _vendorUseCase;
EquipmentFormController(
this._createEquipmentUseCase,
this._updateEquipmentUseCase,
this._getEquipmentDetailUseCase,
this._getCompaniesUseCase,
this._modelUseCase,
this._getModelsUseCase,
this._vendorUseCase,
);
// 상태 관리
bool _isLoading = false;
bool _isLoadingCompanies = false;
bool _isLoadingVendors = false;
bool _isLoadingModels = false;
bool _isSaving = false;
String? _error;
@@ -41,11 +46,13 @@ class EquipmentFormController extends ChangeNotifier {
// 드롭다운 데이터
List<CompanyDto> _companies = [];
List<VendorDto> _vendors = [];
List<ModelDto> _models = [];
List<ModelDto> _filteredModels = [];
// 선택된 값
int? _selectedCompanyId;
int? _selectedVendorId;
int? _selectedModelId;
// 폼 컨트롤러들
@@ -57,12 +64,13 @@ class EquipmentFormController extends ChangeNotifier {
// 날짜 필드들
DateTime? _purchasedAt;
DateTime _warrantyStartedAt = DateTime.now();
DateTime _warrantyEndedAt = DateTime.now().add(const Duration(days: 365));
DateTime _warrantyStartedAt = DateTime.now().toUtc();
DateTime _warrantyEndedAt = DateTime.now().toUtc().add(const Duration(days: 365));
// Getters
bool get isLoading => _isLoading;
bool get isLoadingCompanies => _isLoadingCompanies;
bool get isLoadingVendors => _isLoadingVendors;
bool get isLoadingModels => _isLoadingModels;
bool get isSaving => _isSaving;
String? get error => _error;
@@ -70,9 +78,11 @@ class EquipmentFormController extends ChangeNotifier {
bool get isEditMode => _equipmentId != null;
List<CompanyDto> get companies => _companies;
List<VendorDto> get vendors => _vendors;
List<ModelDto> get filteredModels => _filteredModels;
int? get selectedCompanyId => _selectedCompanyId;
int? get selectedVendorId => _selectedVendorId;
int? get selectedModelId => _selectedModelId;
DateTime? get purchasedAt => _purchasedAt;
@@ -105,10 +115,11 @@ class EquipmentFormController extends ChangeNotifier {
}
}
/// 초기 데이터 로드 (회사, 모델)
/// 초기 데이터 로드 (회사, 제조사, 모델)
Future<void> _loadInitialData() async {
await Future.wait([
_loadCompanies(),
_loadVendors(),
_loadModels(),
]);
}
@@ -142,14 +153,41 @@ class EquipmentFormController extends ChangeNotifier {
}
}
/// 제조사 목록 로드
Future<void> _loadVendors() async {
_isLoadingVendors = true;
notifyListeners();
try {
final vendorResponse = await _vendorUseCase.getVendors(limit: 1000); // 모든 제조사 가져오기
_vendors = (vendorResponse.items as List)
.whereType<VendorDto>()
.where((vendor) => vendor.isActive)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
} catch (e) {
_error = '제조사 목록을 불러오는데 실패했습니다: $e';
} finally {
_isLoadingVendors = false;
notifyListeners();
}
}
/// 모델 목록 로드
Future<void> _loadModels() async {
_isLoadingModels = true;
notifyListeners();
try {
_models = await _modelUseCase.getModels();
_filteredModels = _models;
const params = GetModelsParams(page: 1, perPage: 1000);
final result = await _getModelsUseCase(params);
result.fold(
(failure) => throw Exception(failure.message),
(modelResponse) {
_models = modelResponse.items;
_filteredModels = _models;
},
);
} catch (e) {
_error = '모델 목록을 불러오는데 실패했습니다: $e';
} finally {
@@ -184,9 +222,9 @@ class EquipmentFormController extends ChangeNotifier {
warrantyNumberController.text = equipment.warrantyNumber;
remarkController.text = equipment.remark ?? '';
_purchasedAt = equipment.purchasedAt;
_warrantyStartedAt = equipment.warrantyStartedAt;
_warrantyEndedAt = equipment.warrantyEndedAt;
_purchasedAt = equipment.purchasedAt?.toUtc(); // ✅ UTC 타임존으로 변환
_warrantyStartedAt = equipment.warrantyStartedAt.toUtc(); // ✅ UTC 타임존으로 변환
_warrantyEndedAt = equipment.warrantyEndedAt.toUtc(); // ✅ UTC 타임존으로 변환
// 선택된 회사에 따라 모델 필터링
_filterModelsByCompany(_selectedCompanyId);
@@ -197,8 +235,14 @@ class EquipmentFormController extends ChangeNotifier {
/// 회사 선택
void selectCompany(int? companyId) {
_selectedCompanyId = companyId;
notifyListeners();
}
/// 제조사 선택 (제조사별 모델 필터링 활성화)
void selectVendor(int? vendorId) {
_selectedVendorId = vendorId;
_selectedModelId = null; // 모델 선택 초기화
_filterModelsByCompany(companyId);
_filterModelsByVendor(vendorId); // 실제 제조사별 필터링 실행
notifyListeners();
}
@@ -208,13 +252,23 @@ class EquipmentFormController extends ChangeNotifier {
notifyListeners();
}
/// 사별 모델 필터링
/// 제조사별 모델 필터링 (실제 구현)
void _filterModelsByVendor(int? vendorId) {
if (vendorId == null) {
_filteredModels = _models;
} else {
// vendorsId 기준으로 실제 필터링 구현
_filteredModels = _models.where((model) => model.vendorsId == vendorId).toList();
}
notifyListeners();
}
/// 레거시 호환성을 위한 Company 기반 필터링 (현재는 전체 모델 표시)
void _filterModelsByCompany(int? companyId) {
if (companyId == null) {
_filteredModels = _models;
} else {
// 실제로는 vendor로 필터링해야 하지만,
// 현재 구조에서는 모든 모델을 보여주고 사용자가 선택하도록 함
// 회사별 모델 필터링은 현재 구조에서는 불가능 (모든 모델 표시)
_filteredModels = _models;
}
notifyListeners();
@@ -222,13 +276,13 @@ class EquipmentFormController extends ChangeNotifier {
/// 구매일 선택
void setPurchasedAt(DateTime? date) {
_purchasedAt = date;
_purchasedAt = date?.toUtc(); // ✅ UTC 타임존으로 변환
notifyListeners();
}
/// 워런티 시작일 선택
void setWarrantyStartedAt(DateTime date) {
_warrantyStartedAt = date;
_warrantyStartedAt = date.toUtc(); // ✅ UTC 타임존으로 변환
// 시작일이 종료일보다 늦으면 종료일을 1년 후로 설정
if (_warrantyStartedAt.isAfter(_warrantyEndedAt)) {
_warrantyEndedAt = _warrantyStartedAt.add(const Duration(days: 365));
@@ -238,7 +292,7 @@ class EquipmentFormController extends ChangeNotifier {
/// 워런티 종료일 선택
void setWarrantyEndedAt(DateTime date) {
_warrantyEndedAt = date;
_warrantyEndedAt = date.toUtc(); // ✅ UTC 타임존으로 변환
notifyListeners();
}
@@ -298,17 +352,17 @@ class EquipmentFormController extends ChangeNotifier {
/// 장비 생성
Future<bool> _createEquipment() async {
final request = EquipmentRequestDto(
companiesId: _selectedCompanyId!,
modelsId: _selectedModelId!,
companiesId: _selectedCompanyId, // 백엔드: Option<i32> - null 허용
modelsId: _selectedModelId, // 백엔드: Option<i32> - null 허용
serialNumber: serialNumberController.text.trim(),
barcode: barcodeController.text.trim().isNotEmpty
? barcodeController.text.trim()
: null,
purchasedAt: _purchasedAt,
purchasedAt: (_purchasedAt ?? DateTime.now()).toUtc(), // 백엔드: 필수 필드 - 기본값 제공
purchasePrice: int.tryParse(purchasePriceController.text) ?? 0,
warrantyNumber: warrantyNumberController.text.trim(),
warrantyStartedAt: _warrantyStartedAt,
warrantyEndedAt: _warrantyEndedAt,
warrantyStartedAt: _warrantyStartedAt.toUtc(), // ✅ UTC 타임존으로 변환
warrantyEndedAt: _warrantyEndedAt.toUtc(), // ✅ UTC 타임존으로 변환
remark: remarkController.text.trim().isNotEmpty
? remarkController.text.trim()
: null,
@@ -337,11 +391,11 @@ class EquipmentFormController extends ChangeNotifier {
barcode: barcodeController.text.trim().isNotEmpty
? barcodeController.text.trim()
: null,
purchasedAt: _purchasedAt,
purchasedAt: _purchasedAt?.toUtc(), // ✅ UTC 타임존으로 변환
purchasePrice: int.tryParse(purchasePriceController.text) ?? 0,
warrantyNumber: warrantyNumberController.text.trim(),
warrantyStartedAt: _warrantyStartedAt,
warrantyEndedAt: _warrantyEndedAt,
warrantyStartedAt: _warrantyStartedAt.toUtc(), // ✅ UTC 타임존으로 변환
warrantyEndedAt: _warrantyEndedAt.toUtc(), // ✅ UTC 타임존으로 변환
remark: remarkController.text.trim().isNotEmpty
? remarkController.text.trim()
: null,
@@ -376,8 +430,8 @@ class EquipmentFormController extends ChangeNotifier {
remarkController.clear();
_purchasedAt = null;
_warrantyStartedAt = DateTime.now();
_warrantyEndedAt = DateTime.now().add(const Duration(days: 365));
_warrantyStartedAt = DateTime.now().toUtc(); // ✅ UTC 타임존으로 변환
_warrantyEndedAt = DateTime.now().toUtc().add(const Duration(days: 365)); // ✅ UTC 타임존으로 변환
_error = null;
}

View File

@@ -2,7 +2,7 @@ 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';
import '../../../core/constants/app_constants.dart';
class EquipmentHistoryController extends ChangeNotifier {
final EquipmentHistoryUseCase _useCase;
@@ -18,7 +18,7 @@ class EquipmentHistoryController extends ChangeNotifier {
// 페이지네이션
int _currentPage = 1;
int _pageSize = PaginationConstants.defaultPageSize;
int _pageSize = AppConstants.historyPageSize;
int _totalCount = 0;
// 필터 (백엔드 실제 필드만)
@@ -242,7 +242,7 @@ class EquipmentHistoryController extends ChangeNotifier {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 100,
pageSize: AppConstants.bulkPageSize,
transactionType: transactionType,
equipmentsId: equipmentId,
warehousesId: warehouseId,
@@ -286,7 +286,7 @@ class EquipmentHistoryController extends ChangeNotifier {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 1000,
pageSize: AppConstants.maxBulkPageSize,
equipmentsId: equipmentId,
warehousesId: warehouseId,
);
@@ -309,7 +309,7 @@ class EquipmentHistoryController extends ChangeNotifier {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 1000,
pageSize: AppConstants.maxBulkPageSize,
warehousesId: warehouseId,
);

View File

@@ -1,3 +1,4 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
@@ -13,6 +14,7 @@ import 'package:superport/data/models/equipment_history_dto.dart';
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentInFormController extends ChangeNotifier {
bool _disposed = false;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
// final WarehouseService _warehouseService = GetIt.instance<WarehouseService>(); // 사용되지 않음 - 제거
// final CompanyService _companyService = GetIt.instance<CompanyService>(); // 사용되지 않음 - 제거
@@ -37,15 +39,18 @@ class EquipmentInFormController extends ChangeNotifier {
/// canSave 상태 업데이트 (UI 렌더링 문제 해결)
void _updateCanSave() {
if (_disposed) return; // dispose된 경우 업데이트 방지
final hasEquipmentNumber = _serialNumber.trim().isNotEmpty;
final hasModelsId = _modelsId != null; // models_id 필수
final hasWarrantyNumber = warrantyNumberController.text.trim().isNotEmpty; // warranty_number 필수
final isNotSaving = !_isSaving;
final newCanSave = isNotSaving && hasEquipmentNumber && hasModelsId;
final newCanSave = isNotSaving && hasEquipmentNumber && hasModelsId && hasWarrantyNumber;
if (_canSave != newCanSave) {
_canSave = newCanSave;
print('🚀 [canSave 상태 변경] $_canSave → serialNumber: "$_serialNumber", modelsId: $_modelsId');
print('🚀 [canSave 상태 변경] $_canSave → serialNumber: "$_serialNumber", modelsId: $_modelsId, warrantyNumber: "${warrantyNumberController.text}"');
notifyListeners(); // 명시적 UI 업데이트
}
}
@@ -55,6 +60,7 @@ class EquipmentInFormController extends ChangeNotifier {
// 입력 상태 변수 (백엔드 API 구조에 맞게 수정)
String _serialNumber = ''; // 장비번호 (필수) - private으로 변경
String _barcode = ''; // 바코드 (선택사항) - 새로 추가
int? _modelsId; // 모델 ID (필수) - Vendor→Model cascade에서 선택
int? _vendorId; // 벤더 ID (UI용, API에는 전송 안함)
@@ -72,6 +78,14 @@ class EquipmentInFormController extends ChangeNotifier {
}
}
String get barcode => _barcode;
set barcode(String value) {
if (_barcode != value) {
_barcode = value;
print('DEBUG [Controller] barcode updated: "$_barcode"');
}
}
String get manufacturer => _manufacturer;
set manufacturer(String value) {
if (_manufacturer != value) {
@@ -116,12 +130,13 @@ class EquipmentInFormController extends ChangeNotifier {
// Vendor→Model 선택 콜백
void onVendorModelChanged(int? vendorId, int? modelId) {
if (_disposed) return;
_vendorId = vendorId;
_modelsId = modelId;
_updateCanSave();
notifyListeners();
}
DateTime? purchaseDate; // 구매일
DateTime? purchaseDate = DateTime.now(); // 구매일 (기본값: 현재 날짜)
double? purchasePrice; // 구매가격
// 삭제된 필드들 (백엔드 미지원)
@@ -142,6 +157,7 @@ class EquipmentInFormController extends ChangeNotifier {
int _initialStock = 1; // 초기 재고 수량 (기본값: 1)
int get initialStock => _initialStock;
set initialStock(int value) {
if (_disposed) return;
if (_initialStock != value && value > 0) {
_initialStock = value;
notifyListeners();
@@ -188,8 +204,17 @@ class EquipmentInFormController extends ChangeNotifier {
EquipmentInFormController({this.equipmentInId}) {
isEditMode = equipmentInId != null;
_loadDropdownData();
// 워런티 번호 기본값 설정
if (warrantyNumberController.text.isEmpty) {
warrantyNumberController.text = 'WR-${DateTime.now().millisecondsSinceEpoch}';
}
_updateCanSave(); // 초기 canSave 상태 설정
// ✅ 비동기 드롭다운 데이터 로드 시작 (await 불가능하므로 별도 처리)
unawaited(_loadDropdownData());
// 수정 모드일 때 초기 데이터 로드는 initializeForEdit() 메서드로 이동
}
@@ -243,8 +268,28 @@ class EquipmentInFormController extends ChangeNotifier {
void _processDropdownData(Map<String, dynamic> data) {
manufacturers = data['manufacturers'] as List<String>? ?? [];
equipmentNames = data['equipment_names'] as List<String>? ?? [];
companies = data['companies'] as Map<int, String>? ?? {};
warehouses = data['warehouses'] as Map<int, String>? ?? {};
// ✅ List<Map> → Map<int, String> 안전한 변환 (사전 로드된 데이터)
try {
final companiesList = data['companies'] as List<dynamic>? ?? [];
companies = Map<int, String>.fromIterable(
companiesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
final warehousesList = data['warehouses'] as List<dynamic>? ?? [];
warehouses = Map<int, String>.fromIterable(
warehousesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
} catch (e) {
DebugLogger.logError('사전 로드된 드롭다운 데이터 변환 실패', error: e);
companies = {};
warehouses = {};
}
DebugLogger.log('드롭다운 데이터 처리 완료', tag: 'EQUIPMENT_IN', data: {
'manufacturers_count': manufacturers.length,
@@ -255,7 +300,7 @@ class EquipmentInFormController extends ChangeNotifier {
}
// 드롭다운 데이터 로드 (매번 API 호출)
void _loadDropdownData() async {
Future<void> _loadDropdownData() async {
try {
DebugLogger.log('Equipment 폼 드롭다운 데이터 로드 시작', tag: 'EQUIPMENT_IN');
final result = await _lookupsService.getEquipmentFormDropdownData();
@@ -268,13 +313,33 @@ class EquipmentInFormController extends ChangeNotifier {
equipmentNames = [];
companies = {};
warehouses = {};
notifyListeners();
if (!_disposed) notifyListeners();
},
(data) {
manufacturers = data['manufacturers'] as List<String>;
equipmentNames = data['equipment_names'] as List<String>;
companies = data['companies'] as Map<int, String>;
warehouses = data['warehouses'] as Map<int, String>;
// ✅ List<Map> → Map<int, String> 안전한 변환
try {
final companiesList = data['companies'] as List<dynamic>? ?? [];
companies = Map<int, String>.fromIterable(
companiesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
final warehousesList = data['warehouses'] as List<dynamic>? ?? [];
warehouses = Map<int, String>.fromIterable(
warehousesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
} catch (e) {
DebugLogger.logError('드롭다운 데이터 변환 실패', error: e);
companies = {};
warehouses = {};
}
DebugLogger.log('드롭다운 데이터 로드 성공', tag: 'EQUIPMENT_IN', data: {
'manufacturers_count': manufacturers.length,
@@ -283,7 +348,7 @@ class EquipmentInFormController extends ChangeNotifier {
'warehouses_count': warehouses.length,
});
notifyListeners();
if (!_disposed) notifyListeners();
},
);
} catch (e) {
@@ -292,29 +357,45 @@ class EquipmentInFormController extends ChangeNotifier {
equipmentNames = [];
companies = {};
warehouses = {};
notifyListeners();
if (!_disposed) notifyListeners();
}
}
// 기존의 개별 로드 메서드들은 _loadDropdownData()로 통합됨
// warehouseLocations, partnerCompanies 리스트 변수들도 제거됨
// 전달받은 장비 데이터로 폼 초기화
// 전달받은 장비 데이터로 폼 초기화 (간소화: 백엔드 JOIN 데이터 직접 활용)
void _loadFromEquipment(EquipmentDto equipment) {
serialNumber = equipment.serialNumber;
barcode = equipment.barcode ?? '';
modelsId = equipment.modelsId;
// vendorId는 ModelDto에서 가져와야 함 (필요 시)
purchasePrice = equipment.purchasePrice.toDouble();
initialStock = 1; // EquipmentDto에는 initialStock 필드가 없음
purchasePrice = equipment.purchasePrice > 0 ? equipment.purchasePrice.toDouble() : null;
initialStock = 1;
selectedCompanyId = equipment.companiesId;
// selectedWarehouseId는 현재 위치를 추적해야 함 (EquipmentHistory에서)
remarkController.text = equipment.remark ?? '';
warrantyNumberController.text = equipment.warrantyNumber;
// ✅ 간소화: 백엔드 JOIN 데이터 직접 사용 (복잡한 Controller 조회 제거)
manufacturer = equipment.vendorName ?? '제조사 정보 없음';
name = equipment.modelName ?? '모델 정보 없음';
// 날짜 필드 설정
purchaseDate = equipment.purchasedAt;
warrantyStartDate = equipment.warrantyStartedAt;
warrantyEndDate = equipment.warrantyEndedAt;
// TextEditingController 동기화
remarkController.text = equipment.remark ?? '';
warrantyNumberController.text = equipment.warrantyNumber;
// 수정 모드에서 입고지 기본값 설정
if (isEditMode && selectedWarehouseId == null && warehouses.isNotEmpty) {
selectedWarehouseId = warehouses.keys.first;
}
// preloadedEquipment에 저장 (UI에서 JOIN 데이터 접근용)
preloadedEquipment = equipment;
_updateCanSave();
notifyListeners(); // UI 즉시 업데이트
}
// 기존 데이터 로드(수정 모드)
@@ -404,7 +485,7 @@ class EquipmentInFormController extends ChangeNotifier {
} finally {
_isLoading = false;
_updateCanSave(); // 데이터 로드 완료 시 canSave 상태 업데이트
notifyListeners();
if (!_disposed) notifyListeners();
}
}
@@ -442,7 +523,7 @@ class EquipmentInFormController extends ChangeNotifier {
_isSaving = true;
_error = null;
_updateCanSave(); // 저장 시작 시 canSave 상태 업데이트
notifyListeners();
if (!_disposed) notifyListeners();
try {
@@ -501,7 +582,7 @@ class EquipmentInFormController extends ChangeNotifier {
companiesId: validCompanyId,
modelsId: _modelsId,
serialNumber: _serialNumber.trim(),
barcode: null,
barcode: _barcode.trim().isEmpty ? null : _barcode.trim(),
purchasedAt: purchaseDate,
purchasePrice: purchasePrice?.toInt(),
warrantyNumber: validWarrantyNumber,
@@ -538,17 +619,19 @@ class EquipmentInFormController extends ChangeNotifier {
'companiesId': selectedCompanyId,
});
// Equipment 객체를 EquipmentRequestDto로 변환
// Equipment 객체를 EquipmentRequestDto로 변환 (백엔드 스펙에 맞게)
final createRequest = EquipmentRequestDto(
companiesId: selectedCompanyId ?? 0,
modelsId: _modelsId ?? 0,
companiesId: selectedCompanyId, // 백엔드: Option<i32> - null 허용
modelsId: _modelsId, // 백엔드: Option<i32> - null 허용
serialNumber: _serialNumber,
barcode: null,
purchasedAt: null,
barcode: _barcode.trim().isEmpty ? null : _barcode.trim(),
purchasedAt: (purchaseDate ?? DateTime.now()).toUtc(), // 단순 UTC 변환
purchasePrice: purchasePrice?.toInt() ?? 0,
warrantyNumber: '',
warrantyStartedAt: DateTime.now(),
warrantyEndedAt: DateTime.now().add(Duration(days: 365)),
warrantyNumber: warrantyNumberController.text.isNotEmpty
? warrantyNumberController.text
: 'WR-${DateTime.now().millisecondsSinceEpoch}',
warrantyStartedAt: warrantyStartDate.toUtc(), // 단순 UTC 변환
warrantyEndedAt: warrantyEndDate.toUtc(), // 단순 UTC 변환
remark: remarkController.text.isNotEmpty ? remarkController.text : null,
);
@@ -602,21 +685,22 @@ class EquipmentInFormController extends ChangeNotifier {
return true;
} on Failure catch (e) {
_error = e.message;
notifyListeners();
if (!_disposed) notifyListeners();
return false;
} catch (e) {
_error = 'An unexpected error occurred: $e';
notifyListeners();
if (!_disposed) notifyListeners();
return false;
} finally {
_isSaving = false;
_updateCanSave(); // 저장 완료 시 canSave 상태 업데이트
notifyListeners();
if (!_disposed) notifyListeners();
}
}
// 에러 처리
void clearError() {
if (_disposed) return;
_error = null;
notifyListeners();
}
@@ -625,6 +709,7 @@ class EquipmentInFormController extends ChangeNotifier {
@override
void dispose() {
_disposed = true; // dispose 상태 설정
remarkController.dispose();
warrantyNumberController.dispose();
super.dispose();

View File

@@ -10,6 +10,7 @@ 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';
import 'package:superport/domain/usecases/equipment/search_equipment_usecase.dart';
/// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
@@ -76,7 +77,7 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
status: _statusFilter != null ?
EquipmentStatusConverter.clientToServer(_statusFilter) : null,
search: params.search,
// companyId: _companyIdFilter, // 활성화: EquipmentService에서 지원하지 않음
companyId: _companyIdFilter, // 활성화: 회사별 필터링 지원
// includeInactive: _includeInactive, // 비활성화: EquipmentService에서 지원하지 않음
),
onError: (failure) {
@@ -110,6 +111,15 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
equipmentNumber: dto.serialNumber ?? 'Unknown', // 장비번호 (required)
serialNumber: dto.serialNumber ?? 'Unknown', // 시리얼번호 (required)
quantity: 1, // 기본 수량
// ⚡ [FIX] 누락된 구매 정보 필드들 추가
purchasePrice: dto.purchasePrice.toDouble(), // int → double 변환
purchaseDate: dto.purchasedAt, // 구매일
barcode: dto.barcode, // 바코드
remark: dto.remark, // 비고
// 보증 정보
warrantyLicense: dto.warrantyNumber,
warrantyStartDate: dto.warrantyStartedAt,
warrantyEndDate: dto.warrantyEndedAt,
);
// 간단한 Company 정보 생성 (사용하지 않으므로 제거)
@@ -129,6 +139,10 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
warehouseLocation: null, // EquipmentDto에 warehouse_name 필드 없음
// currentBranch는 EquipmentListDto에 없으므로 null (백엔드 API 구조 변경으로 지점 개념 제거)
currentBranch: null,
// ⚡ [FIX] 백엔드 직접 제공 필드들 추가 - 화면에서 N/A 문제 해결
companyName: dto.companyName, // API company_name → UI 회사명 컬럼
vendorName: dto.vendorName, // API vendor_name → UI 제조사 컬럼
modelName: dto.modelName, // API model_name → UI 모델명 컬럼
);
// 🔧 [DEBUG] 변환된 UnifiedEquipment 로깅 (필요 시 활성화)
// print('DEBUG [EquipmentListController] UnifiedEquipment ID: ${unifiedEquipment.id}, currentCompany: "${unifiedEquipment.currentCompany}", warehouseLocation: "${unifiedEquipment.warehouseLocation}"');
@@ -197,12 +211,34 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
try {
final result = await _lookupsService.getEquipmentFormDropdownData();
result.fold(
(failure) => throw failure,
(data) => cachedDropdownData = data,
(failure) {
debugPrint('❌ 드롭다운 데이터 로드 실패: ${failure.message}');
// 실패해도 빈 데이터로 초기화하여 타입 오류 방지
cachedDropdownData = {
'manufacturers': <String>[],
'equipment_names': <String>[],
'companies': <Map<String, dynamic>>[],
'warehouses': <Map<String, dynamic>>[],
'category1_list': <String>[],
'category_combinations': <dynamic>[],
};
},
(data) {
debugPrint('✅ 드롭다운 데이터 로드 성공: ${data.keys}');
cachedDropdownData = data;
},
);
} catch (e) {
print('Failed to preload dropdown data: $e');
// 캐시 실패해도 계속 진행
debugPrint('❌ 드롭다운 데이터 로드 예외: $e');
// 예외 발생 시에도 빈 데이터로 초기화
cachedDropdownData = {
'manufacturers': <String>[],
'equipment_names': <String>[],
'companies': <Map<String, dynamic>>[],
'warehouses': <Map<String, dynamic>>[],
'category1_list': <String>[],
'category_combinations': <dynamic>[],
};
}
}
@@ -248,6 +284,60 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
loadData(isRefresh: true);
}
/// 시리얼번호로 장비 검색
Future<EquipmentDto?> searchBySerial(String serial) async {
try {
final useCase = GetIt.instance<GetEquipmentBySerialUseCase>();
final result = await useCase(serial);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(equipment) => equipment,
);
} catch (e) {
debugPrint('시리얼번호 검색 실패: $e');
rethrow;
}
}
/// 바코드로 장비 검색
Future<EquipmentDto?> searchByBarcode(String barcode) async {
try {
final useCase = GetIt.instance<GetEquipmentByBarcodeUseCase>();
final result = await useCase(barcode);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(equipment) => equipment,
);
} catch (e) {
debugPrint('바코드 검색 실패: $e');
rethrow;
}
}
/// 회사별 장비 목록 조회
Future<List<EquipmentDto>?> getEquipmentsByCompany(int companyId) async {
try {
final useCase = GetIt.instance<GetEquipmentsByCompanyUseCase>();
final result = await useCase(companyId);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(equipments) => equipments,
);
} catch (e) {
debugPrint('회사별 장비 조회 실패: $e');
rethrow;
}
}
/// 필터 초기화
void clearFilters() {
_statusFilter = null;

View File

@@ -21,6 +21,7 @@ class EquipmentInFormScreen extends StatefulWidget {
class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
late EquipmentInFormController _controller;
late TextEditingController _serialNumberController;
late TextEditingController _barcodeController;
late TextEditingController _initialStockController;
late TextEditingController _purchasePriceController;
Future<void>? _initFuture;
@@ -49,6 +50,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
// TextEditingController 초기화
_serialNumberController = TextEditingController(text: _controller.serialNumber);
_barcodeController = TextEditingController(text: _controller.barcode);
_initialStockController = TextEditingController(text: _controller.initialStock.toString());
_purchasePriceController = TextEditingController(
text: _controller.purchasePrice != null
@@ -62,6 +64,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
// 데이터 로드 후 컨트롤러 업데이트
setState(() {
_serialNumberController.text = _controller.serialNumber;
_barcodeController.text = _controller.barcode;
_purchasePriceController.text = _controller.purchasePrice != null
? CurrencyFormatter.formatKRW(_controller.purchasePrice)
: '';
@@ -73,7 +76,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
_controller.removeListener(_onControllerUpdated);
_controller.dispose();
_serialNumberController.dispose();
_serialNumberController.dispose();
_barcodeController.dispose();
_initialStockController.dispose();
_purchasePriceController.dispose();
super.dispose();
@@ -167,6 +170,8 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
const SizedBox(height: 24),
_buildPurchaseSection(),
const SizedBox(height: 24),
_buildWarrantySection(),
const SizedBox(height: 24),
_buildRemarkSection(),
],
),
@@ -183,6 +188,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'기본 정보',
@@ -192,7 +198,22 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
),
const SizedBox(height: 16),
// 장비 번호 (필수)
// 1. 제조사/모델 정보 (수정 모드: 읽기 전용, 생성 모드: 선택)
if (_controller.isEditMode)
..._buildReadOnlyVendorModel()
else
Container(
width: double.infinity,
child: EquipmentVendorModelSelector(
initialVendorId: _controller.vendorId,
initialModelId: _controller.modelsId,
onChanged: _controller.onVendorModelChanged,
isReadOnly: false,
),
),
const SizedBox(height: 16),
// 2. 장비 번호 (필수)
ShadInputFormField(
controller: _serialNumberController,
readOnly: _controller.isFieldReadOnly('serialNumber'),
@@ -202,40 +223,27 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
label: Text(_controller.isFieldReadOnly('serialNumber')
? '장비 번호 * 🔒' : '장비 번호 *'),
validator: (value) {
if (value.trim().isEmpty ?? true) {
if (value?.trim().isEmpty ?? true) {
return '장비 번호는 필수입니다';
}
return null;
},
onChanged: _controller.isFieldReadOnly('serialNumber') ? null : (value) {
_controller.serialNumber = value.trim() ?? '';
_controller.serialNumber = value?.trim() ?? '';
setState(() {});
print('DEBUG [장비번호 입력] value: "$value", controller.serialNumber: "${_controller.serialNumber}"');
},
),
const SizedBox(height: 16),
// Vendor→Model cascade 선택기
EquipmentVendorModelSelector(
initialVendorId: _controller.vendorId,
initialModelId: _controller.modelsId,
onChanged: _controller.onVendorModelChanged,
isReadOnly: _controller.isFieldReadOnly('modelsId'),
),
const SizedBox(height: 16),
// 시리얼 번호 (선택)
// 3. 바코드 (선택사항)
ShadInputFormField(
controller: _serialNumberController,
readOnly: _controller.isFieldReadOnly('serialNumber'),
placeholder: Text(_controller.isFieldReadOnly('serialNumber')
? '수정불가' : '시리얼 번호를 입력하세요'),
label: Text(_controller.isFieldReadOnly('serialNumber')
? '시리얼 번호 🔒' : '시리얼 번호'),
onChanged: _controller.isFieldReadOnly('serialNumber') ? null : (value) {
_controller.serialNumber = value.trim() ?? '';
setState(() {});
print('DEBUG [시리얼번호 입력] value: "$value", controller.serialNumber: "${_controller.serialNumber}"');
controller: _barcodeController,
placeholder: const Text('바코드를 입력하세요'),
label: const Text('바코드'),
onChanged: (value) {
_controller.barcode = value?.trim() ?? '';
print('DEBUG [바코드 입력] value: "$value", controller.barcode: "${_controller.barcode}"');
},
),
],
@@ -260,30 +268,32 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
),
const SizedBox(height: 16),
// 구매처 (드롭다운 전용)
ShadSelect<int>(
initialValue: _getValidCompanyId(),
placeholder: const Text('구매처를 선택하세요'),
options: _controller.companies.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
)
).toList(),
selectedOptionBuilder: (context, value) {
// companies가 비어있거나 해당 value가 없는 경우 처리
if (_controller.companies.isEmpty) {
return const Text('로딩중...');
}
return Text(_controller.companies[value] ?? '선택하세요');
},
onChanged: (value) {
setState(() {
_controller.selectedCompanyId = value;
});
print('DEBUG [구매처 선택] value: $value, companies: ${_controller.companies.length}');
},
),
// 구매처 (수정 모드: 읽기 전용, 생성 모드: 선택)
if (_controller.isEditMode)
_buildReadOnlyCompany()
else
ShadSelect<int>(
initialValue: _getValidCompanyId(),
placeholder: const Text('구매처를 선택하세요'),
options: _controller.companies.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
)
).toList(),
selectedOptionBuilder: (context, value) {
if (_controller.companies.isEmpty) {
return const Text('로딩중...');
}
return Text(_controller.companies[value] ?? '선택하세요');
},
onChanged: (value) {
setState(() {
_controller.selectedCompanyId = value;
});
print('DEBUG [구매처 선택] value: $value, companies: ${_controller.companies.length}');
},
),
const SizedBox(height: 16),
// 입고지 (드롭다운 전용)
@@ -396,7 +406,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
Text(
_controller.purchaseDate != null
? '${_controller.purchaseDate!.year}-${_controller.purchaseDate!.month.toString().padLeft(2, '0')}-${_controller.purchaseDate!.day.toString().padLeft(2, '0')}'
: _controller.isFieldReadOnly('purchaseDate') ? '구매일 미설정' : '날짜 선택',
: _controller.isFieldReadOnly('purchaseDate') ? '구매일 미설정' : '현재 날짜',
style: TextStyle(
color: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[600]
@@ -444,6 +454,140 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
);
}
Widget _buildWarrantySection() {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'워런티 정보 *',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 워런티 번호 (필수)
ShadInputFormField(
controller: _controller.warrantyNumberController,
label: const Text('워런티 번호 *'),
placeholder: const Text('워런티 번호를 입력하세요'),
validator: (value) {
if (value.trim().isEmpty ?? true) {
return '워런티 번호는 필수입니다';
}
return null;
},
),
const SizedBox(height: 16),
Row(
children: [
// 워런티 시작일 (필수)
Expanded(
child: InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _controller.warrantyStartDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (date != null) {
setState(() {
_controller.warrantyStartDate = date;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_controller.warrantyStartDate.year}-${_controller.warrantyStartDate.month.toString().padLeft(2, '0')}-${_controller.warrantyStartDate.day.toString().padLeft(2, '0')}',
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
const SizedBox(width: 16),
// 워런티 만료일 (필수)
Expanded(
child: InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _controller.warrantyEndDate,
firstDate: _controller.warrantyStartDate,
lastDate: DateTime(2100),
);
if (date != null) {
setState(() {
_controller.warrantyEndDate = date;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_controller.warrantyEndDate.year}-${_controller.warrantyEndDate.month.toString().padLeft(2, '0')}-${_controller.warrantyEndDate.day.toString().padLeft(2, '0')}',
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
],
),
const SizedBox(height: 16),
// 워런티 기간 표시
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'워런티 기간: ${_controller.getWarrantyPeriodSummary()}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
);
}
Widget _buildRemarkSection() {
return ShadCard(
child: Padding(
@@ -471,4 +615,43 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
);
}
/// 읽기 전용 구매처 정보 표시 (백엔드 JOIN 데이터 활용)
Widget _buildReadOnlyCompany() {
// preloadedEquipment가 있으면 companyName 사용, 없으면 기본값
final companyName = _controller.preloadedEquipment?.companyName ??
(_controller.companies.isNotEmpty && _controller.selectedCompanyId != null
? _controller.companies[_controller.selectedCompanyId]
: '구매처 정보 없음');
return ShadInputFormField(
readOnly: true,
label: const Text('구매처 🔒'),
initialValue: companyName,
);
}
/// 읽기 전용 제조사/모델 정보 표시 (백엔드 JOIN 데이터 활용)
List<Widget> _buildReadOnlyVendorModel() {
return [
// 제조사 (읽기 전용)
ShadInputFormField(
readOnly: true,
label: const Text('제조사 * 🔒'),
initialValue: _controller.manufacturer.isNotEmpty
? _controller.manufacturer
: '제조사 정보 없음',
),
const SizedBox(height: 16),
// 모델 (읽기 전용)
ShadInputFormField(
readOnly: true,
label: const Text('모델 * 🔒'),
initialValue: _controller.name.isNotEmpty
? _controller.name
: '모델 정보 없음',
),
];
}
}

View File

@@ -9,8 +9,10 @@ import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/equipment/widgets/equipment_history_dialog.dart';
import 'package:superport/screens/equipment/widgets/equipment_search_dialog.dart';
/// shadcn/ui 스타일로 재설계된 장비 관리 화면
class EquipmentList extends StatefulWidget {
@@ -38,7 +40,7 @@ class _EquipmentListState extends State<EquipmentList> {
void initState() {
super.initState();
_controller = EquipmentListController();
_controller.pageSize = 10; // 페이지 크기 설정
_controller.pageSize = AppConstants.equipmentPageSize; // 페이지 크기 설정
_setInitialFilter();
_preloadDropdownData(); // 드롭다운 데이터 미리 로드
@@ -76,11 +78,12 @@ class _EquipmentListState extends State<EquipmentList> {
_adjustColumnsForScreenSize();
}
/// 화면 크기에 따라 컬럼 표시 조정
/// 화면 크기에 따라 컬럼 표시 조정 - 다단계 반응형
void _adjustColumnsForScreenSize() {
final width = MediaQuery.of(context).size.width;
setState(() {
_showDetailedColumns = width > 900;
// 1200px 이상에서만 상세 컬럼 (바코드, 구매가격, 구매일, 보증기간) 표시
_showDetailedColumns = width > 1200;
});
}
@@ -145,6 +148,14 @@ class _EquipmentListState extends State<EquipmentList> {
_controller.changeStatusFilter(_controller.selectedStatusFilter);
}
/// 회사 필터 변경
Future<void> _onCompanyFilterChanged(int? companyId) async {
setState(() {
_controller.filterByCompany(companyId);
_controller.goToPage(1);
});
}
/// 검색 실행
void _onSearch() async {
setState(() {
@@ -185,10 +196,10 @@ class _EquipmentListState extends State<EquipmentList> {
equipments = equipments.where((e) {
final keyword = _appliedSearchKeyword.toLowerCase();
return [
e.equipment.model?.vendor?.name ?? '', // Vendor 이름
e.equipment.serialNumber ?? '', // 시리얼 번호 (메인 필드)
e.equipment.model?.name ?? '', // Model 이름
e.equipment.serialNumber ?? '', // 시리얼 번호 (중복 제거)
e.vendorName ?? '', // 백엔드 직접 제공 Vendor 이름
e.modelName ?? '', // 백엔드 직접 제공 Model 이름
e.companyName ?? '', // 백엔드 직접 제공 Company 이름
e.equipment.serialNumber ?? '', // 시리얼 번호
e.equipment.barcode ?? '', // 바코드
e.equipment.remark ?? '', // 비고
].any((field) => field.toLowerCase().contains(keyword.toLowerCase()));
@@ -285,7 +296,7 @@ class _EquipmentListState extends State<EquipmentList> {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'${equipment.model?.vendor?.name ?? 'N/A'} ${equipment.serialNumber}', // Vendor + Equipment Number
'${unifiedEquipment.vendorName ?? 'N/A'} ${equipment.serialNumber}', // 백엔드 직접 제공 Vendor + Equipment Number
style: const TextStyle(fontSize: 14),
),
);
@@ -523,6 +534,9 @@ class _EquipmentListState extends State<EquipmentList> {
final filteredEquipments = _getFilteredEquipments();
// 백엔드 API에서 제공하는 실제 전체 아이템 수 사용
final totalCount = controller.total;
// 디버그: 페이지네이션 상태 확인
print('DEBUG Pagination: total=${controller.total}, totalPages=${controller.totalPages}, pageSize=${controller.pageSize}, currentPage=${controller.currentPage}');
return BaseListScreen(
isLoading: controller.isLoading && controller.equipments.isEmpty,
@@ -543,8 +557,8 @@ class _EquipmentListState extends State<EquipmentList> {
// 데이터 테이블
dataTable: _buildDataTable(filteredEquipments),
// 페이지네이션
pagination: controller.totalPages > 1 ? Pagination(
// 페이지네이션 - 조건 수정으로 표시 개선
pagination: controller.total > controller.pageSize ? Pagination(
totalCount: controller.total,
currentPage: controller.currentPage,
pageSize: controller.pageSize,
@@ -621,6 +635,25 @@ class _EquipmentListState extends State<EquipmentList> {
},
),
),
const SizedBox(width: 16),
// 회사별 필터 드롭다운
SizedBox(
height: 40,
width: 150,
child: ShadSelect<int?>(
selectedOptionBuilder: (context, value) => Text(
value == null ? '전체 회사' : _getCompanyDisplayText(value),
style: const TextStyle(fontSize: 14),
),
placeholder: const Text('회사 선택'),
options: _buildCompanySelectOptions(),
onChanged: (value) {
_onCompanyFilterChanged(value);
},
),
),
],
);
}
@@ -631,6 +664,19 @@ class _EquipmentListState extends State<EquipmentList> {
leftActions: [
// 라우트별 액션 버튼
_buildRouteSpecificActions(selectedInCount, selectedOutCount, selectedRentCount),
const SizedBox(width: 8),
// 검색 버튼 추가
ShadButton.outline(
onPressed: () => _showEquipmentSearchDialog(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search, size: 16),
const SizedBox(width: 4),
const Text('고급 검색'),
],
),
),
],
rightActions: [
// 관리자용 비활성 포함 체크박스
@@ -667,7 +713,9 @@ class _EquipmentListState extends State<EquipmentList> {
Widget _buildRouteSpecificActions(int selectedInCount, int selectedOutCount, int selectedRentCount) {
switch (widget.currentRoute) {
case Routes.equipmentInList:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '출고',
@@ -675,7 +723,6 @@ class _EquipmentListState extends State<EquipmentList> {
variant: selectedInCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.exit_to_app, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '입고',
onPressed: () async {
@@ -695,7 +742,9 @@ class _EquipmentListState extends State<EquipmentList> {
],
);
case Routes.equipmentOutList:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '재입고',
@@ -710,9 +759,8 @@ class _EquipmentListState extends State<EquipmentList> {
variant: selectedOutCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.assignment_return, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '수리 요청',
text: '수리',
onPressed: selectedOutCount > 0
? () => ShadToaster.of(context).show(
const ShadToast(
@@ -727,7 +775,9 @@ class _EquipmentListState extends State<EquipmentList> {
],
);
case Routes.equipmentRentList:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '반납',
@@ -742,7 +792,6 @@ class _EquipmentListState extends State<EquipmentList> {
variant: selectedRentCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.keyboard_return, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '연장',
onPressed: selectedRentCount > 0
@@ -759,7 +808,9 @@ class _EquipmentListState extends State<EquipmentList> {
],
);
default:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '입고',
@@ -777,24 +828,21 @@ class _EquipmentListState extends State<EquipmentList> {
textColor: Colors.white,
icon: const Icon(Icons.add, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '출고 처리',
text: '출고',
onPressed: selectedInCount > 0 ? _handleOutEquipment : null,
variant: selectedInCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
textColor: selectedInCount > 0 ? Colors.white : null,
icon: const Icon(Icons.local_shipping, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '대여 처리',
text: '대여',
onPressed: selectedInCount > 0 ? _handleRentEquipment : null,
variant: selectedInCount > 0 ? ShadcnButtonVariant.secondary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.assignment, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '폐기 처리',
text: '폐기',
onPressed: selectedInCount > 0 ? _handleDisposeEquipment : null,
variant: selectedInCount > 0 ? ShadcnButtonVariant.destructive : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.delete, size: 16),
@@ -805,28 +853,36 @@ class _EquipmentListState extends State<EquipmentList> {
}
/// 최소 테이블 너비 계산
double _getMinimumTableWidth(List<UnifiedEquipment> pagedEquipments) {
/// 최소 테이블 너비 계산 - 반응형 최적화
double _getMinimumTableWidth(List<UnifiedEquipment> pagedEquipments, double availableWidth) {
double totalWidth = 0;
// 기본 컬럼들 (리스트 API에서 제공하는 데이터만)
totalWidth += 40; // 체크박스
totalWidth += 50; // 번호
totalWidth += 120; // 제조사
totalWidth += 120; // 장비번호
totalWidth += 120; // 모델명
totalWidth += 50; // 수량
totalWidth += 70; // 상태
totalWidth += 80; // 입출고일
totalWidth += 90; // 관리
// 필수 컬럼들 (항상 표시) - 더 작게 조정
totalWidth += 30; // 체크박스 (35->30)
totalWidth += 35; // 번호 (40->35)
totalWidth += 70; // 회사명 (90->70)
totalWidth += 60; // 제조사 (80->60)
totalWidth += 80; // 모델명 (100->80)
totalWidth += 70; // 장비번호 (90->70)
totalWidth += 50; // 상태 (60->50)
totalWidth += 90; // 관리 (120->90, 아이콘 전용으로 최적화)
// 상세 컬럼들 (조건부)
if (_showDetailedColumns) {
totalWidth += 120; // 시리얼번호
// 중간 화면용 추가 컬럼들 (800px 이상)
if (availableWidth > 800) {
totalWidth += 35; // 수량 (40->35)
totalWidth += 70; // 입출고일 (80->70)
}
// padding 추가 (좌우 각 16px)
totalWidth += 32;
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) {
totalWidth += 70; // 바코드 (90->70)
totalWidth += 70; // 구매가격 (80->70)
totalWidth += 70; // 구매일 (80->70)
totalWidth += 80; // 보증기간 (90->80)
}
// padding 추가 (좌우 각 2px로 축소)
totalWidth += 4;
return totalWidth;
}
@@ -873,16 +929,16 @@ class _EquipmentListState extends State<EquipmentList> {
}
/// 유연한 테이블 빌더 - Virtual Scrolling 적용
Widget _buildFlexibleTable(List<UnifiedEquipment> pagedEquipments, {required bool useExpanded}) {
Widget _buildFlexibleTable(List<UnifiedEquipment> pagedEquipments, {required bool useExpanded, required double availableWidth}) {
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,
horizontal: ShadcnTheme.spacing1, // spacing2 -> spacing1로 더 축소
vertical: 6, // 8 -> 6으로 더 축소
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
@@ -892,6 +948,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
child: Row(
children: [
// 필수 컬럼들 (항상 표시) - 축소된 너비 적용
// 체크박스
_buildDataCell(
ShadCheckbox(
@@ -900,30 +957,38 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
minWidth: 30,
),
// 번호
_buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 50),
_buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 35),
// 회사명 (소유회사)
_buildHeaderCell('소유회사', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 제조사
_buildHeaderCell('제조사', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 장비번호
_buildHeaderCell('장비번호', flex: 3, useExpanded: useExpanded, minWidth: 120),
_buildHeaderCell('제조사', flex: 2, useExpanded: useExpanded, minWidth: 60),
// 모델명
_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: 3, useExpanded: useExpanded, minWidth: 80),
// 장비번호
_buildHeaderCell('장비번호', flex: 3, useExpanded: useExpanded, minWidth: 70),
// 상태
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 입출고일
_buildHeaderCell('입출고일', flex: 2, useExpanded: useExpanded, minWidth: 80),
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 50),
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90),
// 중간 화면용 컬럼들 (800px 이상)
if (availableWidth > 800) ...[
// 수량
_buildHeaderCell('수량', flex: 1, useExpanded: useExpanded, minWidth: 35),
// 입출고일
_buildHeaderCell('입출고일', flex: 2, useExpanded: useExpanded, minWidth: 70),
],
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) ...[
_buildHeaderCell('바코드', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('구매가격', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('구매일', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('보증기간', flex: 2, useExpanded: useExpanded, minWidth: 80),
],
],
),
);
@@ -959,8 +1024,8 @@ class _EquipmentListState extends State<EquipmentList> {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 4,
horizontal: ShadcnTheme.spacing1, // spacing2 -> spacing1로 더 축소
vertical: 2, // 3 -> 2로 더 축소
),
decoration: BoxDecoration(
border: Border(
@@ -969,6 +1034,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
child: Row(
children: [
// 필수 컬럼들 (항상 표시) - 축소된 너비 적용
// 체크박스
_buildDataCell(
ShadCheckbox(
@@ -981,7 +1047,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
minWidth: 30,
),
// 번호
_buildDataCell(
@@ -991,17 +1057,37 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 1,
useExpanded: useExpanded,
minWidth: 50,
minWidth: 35,
),
// 소유회사
_buildDataCell(
_buildTextWithTooltip(
equipment.companyName ?? 'N/A',
equipment.companyName ?? 'N/A',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 제조사
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.model?.vendor?.name ?? 'N/A',
equipment.equipment.model?.vendor?.name ?? 'N/A',
equipment.vendorName ?? 'N/A',
equipment.vendorName ?? 'N/A',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 60,
),
// 모델명
_buildDataCell(
_buildTextWithTooltip(
equipment.modelName ?? '-',
equipment.modelName ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
minWidth: 80,
),
// 장비번호
_buildDataCell(
@@ -1011,68 +1097,120 @@ class _EquipmentListState extends State<EquipmentList> {
),
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,
minWidth: 70,
),
// 상태
_buildDataCell(
_buildStatusBadge(equipment.status),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
minWidth: 50,
),
// 입출고일
// 관리 (아이콘 전용 버튼으로 최적화)
_buildDataCell(
_buildCreatedDateWidget(equipment),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
// 관리
_buildDataCell(
_buildActionButtons(equipment.equipment.id ?? 0),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: '이력 보기',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDialog(equipment.equipment.id ?? 0),
child: const Icon(Icons.history, size: 16),
),
),
const SizedBox(width: 2),
Tooltip(
message: '수정',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleEdit(equipment),
child: const Icon(Icons.edit, size: 16),
),
),
const SizedBox(width: 2),
Tooltip(
message: '삭제',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleDelete(equipment),
child: const Icon(Icons.delete_outline, size: 16),
),
),
],
),
flex: 2,
useExpanded: useExpanded,
minWidth: 90,
),
// 중간 화면용 컬럼들 (800px 이상)
if (availableWidth > 800) ...[
// 수량 (백엔드에서 관리하지 않으므로 고정값)
_buildDataCell(
Text(
'1',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 35,
),
// 입출고일
_buildDataCell(
_buildTextWithTooltip(
_formatDate(equipment.date),
_formatDate(equipment.date),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
],
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) ...[
// 바코드
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.barcode ?? '-',
equipment.equipment.barcode ?? '-',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 구매가격
_buildDataCell(
_buildTextWithTooltip(
_formatPrice(equipment.equipment.purchasePrice),
_formatPrice(equipment.equipment.purchasePrice),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 구매일
_buildDataCell(
_buildTextWithTooltip(
_formatDate(equipment.equipment.purchaseDate),
_formatDate(equipment.equipment.purchaseDate),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 보증기간
_buildDataCell(
_buildTextWithTooltip(
_formatWarrantyPeriod(equipment.equipment.warrantyStartDate, equipment.equipment.warrantyEndDate),
_formatWarrantyPeriod(equipment.equipment.warrantyStartDate, equipment.equipment.warrantyEndDate),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
],
],
),
);
@@ -1127,7 +1265,7 @@ class _EquipmentListState extends State<EquipmentList> {
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final minimumWidth = _getMinimumTableWidth(pagedEquipments);
final minimumWidth = _getMinimumTableWidth(pagedEquipments, availableWidth);
final needsHorizontalScroll = minimumWidth > availableWidth;
if (needsHorizontalScroll) {
@@ -1137,12 +1275,12 @@ class _EquipmentListState extends State<EquipmentList> {
controller: _horizontalScrollController,
child: SizedBox(
width: minimumWidth,
child: _buildFlexibleTable(pagedEquipments, useExpanded: false),
child: _buildFlexibleTable(pagedEquipments, useExpanded: false, availableWidth: availableWidth),
),
);
} else {
// 충분한 공간이 있을 때는 Expanded 사용
return _buildFlexibleTable(pagedEquipments, useExpanded: true);
return _buildFlexibleTable(pagedEquipments, useExpanded: true, availableWidth: availableWidth);
}
},
),
@@ -1161,6 +1299,35 @@ class _EquipmentListState extends State<EquipmentList> {
);
}
/// 가격 포맷팅
String _formatPrice(double? price) {
if (price == null) return '-';
return '${(price / 10000).toStringAsFixed(0)}만원';
}
/// 날짜 포맷팅
String _formatDate(DateTime? date) {
if (date == null) return '-';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 보증기간 포맷팅
String _formatWarrantyPeriod(DateTime? startDate, DateTime? endDate) {
if (startDate == null || endDate == null) return '-';
final now = DateTime.now();
final isExpired = now.isAfter(endDate);
final remainingDays = isExpired ? 0 : endDate.difference(now).inDays;
if (isExpired) {
return '만료됨';
} else if (remainingDays <= 30) {
return '$remainingDays일 남음';
} else {
return _formatDate(endDate);
}
}
/// 재고 상태 위젯 빌더 (백엔드 기반 단순화)
Widget _buildInventoryStatus(UnifiedEquipment equipment) {
// 백엔드 Equipment_History 기반으로 단순 상태만 표시
@@ -1260,41 +1427,32 @@ class _EquipmentListState extends State<EquipmentList> {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: IconButton(
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.history, size: 16),
onPressed: () => _showEquipmentHistoryDialog(equipmentId),
tooltip: '이력',
// 이력 버튼 - 텍스트 + 아이콘으로 강화
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDialog(equipmentId),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.history, size: 14),
SizedBox(width: 4),
Text('이력', style: TextStyle(fontSize: 12)),
],
),
),
Flexible(
child: IconButton(
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.edit_outlined, size: 16),
onPressed: () => _handleEditById(equipmentId),
tooltip: '편집',
),
const SizedBox(width: 4),
// 편집 버튼
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _handleEditById(equipmentId),
child: const Icon(Icons.edit_outlined, size: 14),
),
Flexible(
child: IconButton(
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.delete_outline, size: 16),
onPressed: () => _handleDeleteById(equipmentId),
tooltip: '삭제',
),
const SizedBox(width: 4),
// 삭제 버튼
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _handleDeleteById(equipmentId),
child: const Icon(Icons.delete_outline, size: 14),
),
],
);
@@ -1312,7 +1470,7 @@ class _EquipmentListState extends State<EquipmentList> {
final result = await EquipmentHistoryDialog.show(
context: context,
equipmentId: equipmentId,
equipmentName: '${equipment.equipment.model?.vendor?.name ?? 'N/A'} ${equipment.equipment.serialNumber}', // Vendor + Equipment Number
equipmentName: '${equipment.vendorName ?? 'N/A'} ${equipment.equipment.serialNumber}', // 백엔드 직접 제공 Vendor + Equipment Number
);
if (result == true) {
@@ -1413,5 +1571,69 @@ class _EquipmentListState extends State<EquipmentList> {
return options;
}
/// 회사명 표시 텍스트 가져오기
String _getCompanyDisplayText(int companyId) {
// 캐시된 드롭다운 데이터에서 회사명 찾기
if (_cachedDropdownData != null && _cachedDropdownData!['companies'] != null) {
final companies = _cachedDropdownData!['companies'] as List<dynamic>;
for (final company in companies) {
if (company['id'] == companyId) {
return company['name'] ?? '알수없는 회사';
}
}
}
return '회사 #$companyId';
}
/// 소유회사별 필터 드롭다운 옵션 생성
List<ShadOption<int?>> _buildCompanySelectOptions() {
List<ShadOption<int?>> options = [
const ShadOption(value: null, child: Text('전체 소유회사')),
];
// 캐시된 드롭다운 데이터에서 회사 목록 가져오기
if (_cachedDropdownData != null && _cachedDropdownData!['companies'] != null) {
final companies = _cachedDropdownData!['companies'] as List<dynamic>;
for (final company in companies) {
final id = company['id'] as int?;
final name = company['name'] as String?;
if (id != null && name != null) {
options.add(
ShadOption(
value: id,
child: Text(name),
),
);
}
}
}
return options;
}
// 사용하지 않는 현재위치, 점검일 관련 함수들 제거됨 (리스트 API에서 제공하지 않음)
/// 장비 고급 검색 다이얼로그 표시
void _showEquipmentSearchDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => EquipmentSearchDialog(
onEquipmentFound: (equipment) {
// 검색된 장비를 상세보기로 이동 또는 다른 처리
ShadToaster.of(context).show(
ShadToast(
title: const Text('장비 검색 완료'),
description: Text('${equipment.serialNumber} 장비를 찾았습니다.'),
),
);
// 필요하면 검색된 장비의 상세정보로 이동
// _onEditTap(equipment);
},
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/core/errors/failures.dart';
@@ -50,7 +51,7 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
bool _isInitialLoad = true;
String? _error;
int _currentPage = 1;
final int _perPage = 20;
final int _perPage = AppConstants.historyPageSize;
bool _hasMore = true;
String _searchQuery = '';
@@ -339,11 +340,11 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
horizontal: isDesktop ? 40 : 10,
vertical: isDesktop ? 40 : 20,
),
child: RawKeyboardListener(
child: KeyboardListener(
focusNode: FocusNode(),
autofocus: true,
onKey: (RawKeyEvent event) {
if (event is RawKeyDownEvent &&
onKeyEvent: (KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
}

View File

@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../common/theme_shadcn.dart';
import '../../../data/models/equipment/equipment_dto.dart';
import '../../../injection_container.dart';
import '../controllers/equipment_controller.dart';
/// 장비 복구 확인 다이얼로그
class EquipmentRestoreDialog extends StatefulWidget {
final EquipmentDto equipment;
final VoidCallback? onRestored;
const EquipmentRestoreDialog({
super.key,
required this.equipment,
this.onRestored,
});
@override
State<EquipmentRestoreDialog> createState() => _EquipmentRestoreDialogState();
}
class _EquipmentRestoreDialogState extends State<EquipmentRestoreDialog> {
late final EquipmentController _controller;
bool _isRestoring = false;
@override
void initState() {
super.initState();
_controller = sl<EquipmentController>();
}
Future<void> _restore() async {
setState(() {
_isRestoring = true;
});
final success = await _controller.restoreEquipment(widget.equipment.id);
if (mounted) {
if (success) {
Navigator.of(context).pop(true);
if (widget.onRestored != null) {
widget.onRestored!();
}
// 성공 메시지
ShadToaster.of(context).show(
ShadToast(
title: const Text('복구 완료'),
description: Text('${widget.equipment.serialNumber} 장비가 복구되었습니다.'),
),
);
} else {
setState(() {
_isRestoring = false;
});
// 실패 메시지
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('복구 실패'),
description: Text(_controller.error ?? '장비 복구에 실패했습니다.'),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return ShadDialog(
child: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
children: [
Icon(Icons.restore, color: Colors.green, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text('장비 복구', style: ShadcnTheme.headingH3),
),
],
),
const SizedBox(height: 24),
// 복구 정보
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'다음 장비를 복구하시겠습니까?',
style: ShadcnTheme.bodyLarge.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
_buildInfoRow('시리얼 번호', widget.equipment.serialNumber),
if (widget.equipment.barcode != null)
_buildInfoRow('바코드', widget.equipment.barcode!),
if (widget.equipment.modelName != null)
_buildInfoRow('모델명', widget.equipment.modelName!),
if (widget.equipment.companyName != null)
_buildInfoRow('소속 회사', widget.equipment.companyName!),
],
),
),
const SizedBox(height: 16),
Text(
'복구된 장비는 다시 활성 상태로 변경됩니다.',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
const SizedBox(height: 24),
// 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.outline(
onPressed: _isRestoring ? null : () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isRestoring ? null : _restore,
child: _isRestoring
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
const SizedBox(width: 8),
const Text('복구 중...'),
],
)
: const Text('복구'),
),
],
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
),
Expanded(
child: Text(
value,
style: ShadcnTheme.bodySmall.copyWith(
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
/// 장비 복구 다이얼로그 표시 유틸리티
Future<bool?> showEquipmentRestoreDialog(
BuildContext context, {
required EquipmentDto equipment,
VoidCallback? onRestored,
}) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => EquipmentRestoreDialog(
equipment: equipment,
onRestored: onRestored,
),
);
}

View File

@@ -0,0 +1,374 @@
import 'package:flutter/material.dart';
import '../../../injection_container.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../common/theme_shadcn.dart';
import '../../../domain/usecases/equipment/search_equipment_usecase.dart';
import '../../../data/models/equipment/equipment_dto.dart';
/// 장비 시리얼/바코드 검색 다이얼로그
class EquipmentSearchDialog extends StatefulWidget {
final Function(EquipmentDto equipment)? onEquipmentFound;
const EquipmentSearchDialog({super.key, this.onEquipmentFound});
@override
State<EquipmentSearchDialog> createState() => _EquipmentSearchDialogState();
}
class _EquipmentSearchDialogState extends State<EquipmentSearchDialog> {
final _serialController = TextEditingController();
final _barcodeController = TextEditingController();
late final GetEquipmentBySerialUseCase _serialUseCase;
late final GetEquipmentByBarcodeUseCase _barcodeUseCase;
bool _isLoading = false;
String? _errorMessage;
EquipmentDto? _foundEquipment;
@override
void initState() {
super.initState();
_serialUseCase = sl<GetEquipmentBySerialUseCase>();
_barcodeUseCase = sl<GetEquipmentByBarcodeUseCase>();
}
@override
void dispose() {
_serialController.dispose();
_barcodeController.dispose();
super.dispose();
}
Future<void> _searchBySerial() async {
final serial = _serialController.text.trim();
if (serial.isEmpty) {
setState(() {
_errorMessage = '시리얼 번호를 입력해주세요.';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_foundEquipment = null;
});
final result = await _serialUseCase(serial);
result.fold(
(failure) {
setState(() {
_errorMessage = failure.message;
_isLoading = false;
});
},
(equipment) {
setState(() {
_foundEquipment = equipment;
_isLoading = false;
});
},
);
}
Future<void> _searchByBarcode() async {
final barcode = _barcodeController.text.trim();
if (barcode.isEmpty) {
setState(() {
_errorMessage = '바코드를 입력해주세요.';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_foundEquipment = null;
});
final result = await _barcodeUseCase(barcode);
result.fold(
(failure) {
setState(() {
_errorMessage = failure.message;
_isLoading = false;
});
},
(equipment) {
setState(() {
_foundEquipment = equipment;
_isLoading = false;
});
},
);
}
@override
Widget build(BuildContext context) {
return ShadDialog(
child: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('장비 검색', style: ShadcnTheme.headingH3),
ShadButton.ghost(
onPressed: () => Navigator.of(context).pop(),
child: const Icon(Icons.close),
),
],
),
const SizedBox(height: 24),
// 시리얼 검색
Text('시리얼 번호로 검색', style: ShadcnTheme.bodyLarge.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ShadInput(
controller: _serialController,
placeholder: const Text('시리얼 번호 입력'),
onSubmitted: (_) => _searchBySerial(),
),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isLoading ? null : _searchBySerial,
child: const Text('검색'),
),
],
),
const SizedBox(height: 16),
// 바코드 검색
Text('바코드로 검색', style: ShadcnTheme.bodyLarge.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ShadInput(
controller: _barcodeController,
placeholder: const Text('바코드 입력'),
onSubmitted: (_) => _searchByBarcode(),
),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isLoading ? null : _searchByBarcode,
child: const Text('검색'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => _showQRScanDialog(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.qr_code_scanner, size: 16),
const SizedBox(width: 4),
const Text('QR 스캔'),
],
),
),
],
),
const SizedBox(height: 24),
// 로딩 표시
if (_isLoading)
Center(
child: Column(
children: [
ShadProgress(value: null),
const SizedBox(height: 8),
Text('검색 중...', style: ShadcnTheme.bodyMedium),
],
),
),
// 오류 메시지
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: ShadcnTheme.bodyMedium.copyWith(color: Colors.red),
),
),
],
),
),
// 검색 결과
if (_foundEquipment != null)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 8),
Text(
'장비를 찾았습니다!',
style: ShadcnTheme.bodyLarge.copyWith(
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 12),
_buildEquipmentInfo(_foundEquipment!),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'),
),
const SizedBox(width: 8),
ShadButton(
onPressed: () {
if (widget.onEquipmentFound != null) {
widget.onEquipmentFound!(_foundEquipment!);
}
Navigator.of(context).pop();
},
child: const Text('선택'),
),
],
),
],
),
),
const SizedBox(height: 16),
],
),
),
);
}
/// 장비 정보 표시
Widget _buildEquipmentInfo(EquipmentDto equipment) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('시리얼 번호', equipment.serialNumber),
if (equipment.barcode != null)
_buildInfoRow('바코드', equipment.barcode!),
if (equipment.modelName != null)
_buildInfoRow('모델명', equipment.modelName!),
if (equipment.companyName != null)
_buildInfoRow('소속 회사', equipment.companyName!),
_buildInfoRow('활성 상태', equipment.isActive ? '활성' : '비활성'),
],
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
),
Expanded(
child: Text(
value,
style: ShadcnTheme.bodySmall.copyWith(
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
/// QR 스캔 다이얼로그 (임시 구현)
void _showQRScanDialog() {
showDialog(
context: context,
builder: (context) => ShadDialog(
child: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.qr_code_scanner, size: 64, color: ShadcnTheme.primary),
const SizedBox(height: 16),
Text(
'QR 스캔 기능',
style: ShadcnTheme.headingH3,
),
const SizedBox(height: 8),
Text(
'QR 스캔 기능은 추후 구현 예정입니다.',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.foregroundMuted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ShadButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('확인'),
),
],
),
),
),
);
}
}
/// 장비 검색 다이얼로그 표시 유틸리티
Future<EquipmentDto?> showEquipmentSearchDialog(
BuildContext context, {
Function(EquipmentDto equipment)? onEquipmentFound,
}) async {
return await showDialog<EquipmentDto>(
context: context,
barrierDismissible: false,
builder: (context) => EquipmentSearchDialog(
onEquipmentFound: onEquipmentFound,
),
);
}

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/data/models/model/model_dto.dart';
import 'package:superport/data/models/vendor_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';
import 'package:superport/screens/common/widgets/standard_dropdown.dart';
/// Equipment 등록/수정 폼에서 사용할 Vendor→Model cascade 선택 위젯
class EquipmentVendorModelSelector extends StatefulWidget {
@@ -141,6 +141,7 @@ class _EquipmentVendorModelSelectorState extends State<EquipmentVendorModelSelec
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Vendor 선택 드롭다운
_buildVendorDropdown(),
@@ -153,96 +154,59 @@ class _EquipmentVendorModelSelectorState extends State<EquipmentVendorModelSelec
}
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,
orElse: () => VendorDto(
id: value,
name: '로딩중...',
),
);
return Text(vendor.name);
},
onChanged: widget.isReadOnly ? null : _onVendorChanged,
initialValue: _selectedVendorId,
enabled: !widget.isReadOnly,
),
],
return Container(
width: double.infinity,
child: StandardIntDropdown<VendorDto>(
label: widget.isReadOnly ? '제조사 * 🔒' : '제조사 *',
isRequired: true,
items: vendors,
isLoading: _isLoadingVendors,
selectedValue: _selectedVendorId != null
? vendors.where((v) => v.id == _selectedVendorId).firstOrNull
: null,
onChanged: (VendorDto? selectedVendor) {
if (!widget.isReadOnly) {
_onVendorChanged(selectedVendor?.id);
}
},
itemBuilder: (VendorDto vendor) => Text(vendor.name),
selectedItemBuilder: (VendorDto vendor) => Text(vendor.name),
idExtractor: (VendorDto vendor) => vendor.id!,
placeholder: '제조사를 선택하세요',
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,
orElse: () => ModelDto(
id: value,
name: '로딩중...',
vendorsId: 0,
),
);
return Text(model.name);
},
onChanged: isEnabled ? _onModelChanged : null,
initialValue: _selectedModelId,
enabled: isEnabled,
),
],
return Container(
width: double.infinity,
child: StandardIntDropdown<ModelDto>(
label: widget.isReadOnly ? '모델 * 🔒' : '모델 *',
isRequired: true,
items: _filteredModels,
isLoading: _isLoadingModels,
selectedValue: _selectedModelId != null
? _filteredModels.where((m) => m.id == _selectedModelId).firstOrNull
: null,
onChanged: (ModelDto? selectedModel) {
if (isEnabled) {
_onModelChanged(selectedModel?.id);
}
},
itemBuilder: (ModelDto model) => Text(model.name),
selectedItemBuilder: (ModelDto model) => Text(model.name),
idExtractor: (ModelDto model) => model.id ?? 0,
placeholder: _selectedVendorId == null
? '먼저 제조사를 선택하세요'
: '모델을 선택하세요',
enabled: isEnabled,
),
);
}
}