프로젝트 최초 커밋

This commit is contained in:
JiWoong Sul
2025-07-02 17:45:44 +09:00
commit e346f83c97
235 changed files with 23139 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
/// 장비 입고 폼 컨트롤러
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentInFormController {
final MockDataService dataService;
final int? equipmentInId;
// 폼 키
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// 입력 상태 변수
String manufacturer = '';
String name = '';
String category = '';
String subCategory = '';
String subSubCategory = '';
String serialNumber = '';
String barcode = '';
int quantity = 1;
DateTime inDate = DateTime.now();
String equipmentType = EquipmentType.new_;
bool hasSerialNumber = true;
// 워런티 관련 상태
String? warrantyLicense;
String? warrantyCode; // 워런티 코드(텍스트 입력)
DateTime warrantyStartDate = DateTime.now();
DateTime warrantyEndDate = DateTime.now().add(const Duration(days: 365));
List<String> warrantyLicenses = [];
// 자동완성 데이터
List<String> manufacturers = [];
List<String> equipmentNames = [];
// 카테고리 자동완성 데이터
List<String> categories = [];
List<String> subCategories = [];
List<String> subSubCategories = [];
// 편집 모드 여부
bool isEditMode = false;
// 입고지, 파트너사 관련 상태
String? warehouseLocation;
String? partnerCompany;
List<String> warehouseLocations = [];
List<String> partnerCompanies = [];
final TextEditingController remarkController = TextEditingController();
EquipmentInFormController({required this.dataService, this.equipmentInId}) {
isEditMode = equipmentInId != null;
_loadManufacturers();
_loadEquipmentNames();
_loadCategories();
_loadSubCategories();
_loadSubSubCategories();
_loadWarehouseLocations();
_loadPartnerCompanies();
_loadWarrantyLicenses();
if (isEditMode) {
_loadEquipmentIn();
}
}
// 제조사 목록 로드
void _loadManufacturers() {
manufacturers = dataService.getAllManufacturers();
}
// 장비명 목록 로드
void _loadEquipmentNames() {
equipmentNames = dataService.getAllEquipmentNames();
}
// 카테고리 목록 로드
void _loadCategories() {
categories = dataService.getAllCategories();
}
// 서브카테고리 목록 로드
void _loadSubCategories() {
subCategories = dataService.getAllSubCategories();
}
// 서브서브카테고리 목록 로드
void _loadSubSubCategories() {
subSubCategories = dataService.getAllSubSubCategories();
}
// 입고지 목록 로드
void _loadWarehouseLocations() {
warehouseLocations =
dataService.getAllWarehouseLocations().map((e) => e.name).toList();
}
// 파트너사 목록 로드
void _loadPartnerCompanies() {
partnerCompanies =
dataService
.getAllCompanies()
.where((c) => c.companyTypes.contains(CompanyType.partner))
.map((c) => c.name)
.toList();
}
// 워런티 라이센스 목록 로드
void _loadWarrantyLicenses() {
// 실제로는 API나 서비스에서 불러와야 하지만, 파트너사와 동일한 데이터 사용
warrantyLicenses = List.from(partnerCompanies);
}
// 기존 데이터 로드(수정 모드)
void _loadEquipmentIn() {
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
manufacturer = equipmentIn.equipment.manufacturer;
name = equipmentIn.equipment.name;
category = equipmentIn.equipment.category;
subCategory = equipmentIn.equipment.subCategory;
subSubCategory = equipmentIn.equipment.subSubCategory;
serialNumber = equipmentIn.equipment.serialNumber ?? '';
barcode = equipmentIn.equipment.barcode ?? '';
quantity = equipmentIn.equipment.quantity;
inDate = equipmentIn.inDate;
hasSerialNumber = serialNumber.isNotEmpty;
equipmentType = equipmentIn.type;
warehouseLocation = equipmentIn.warehouseLocation;
partnerCompany = equipmentIn.partnerCompany;
remarkController.text = equipmentIn.remark ?? '';
// 워런티 정보 로드 (실제 구현에서는 기존 값을 불러옵니다)
warrantyLicense = equipmentIn.partnerCompany; // 기본값으로 파트너사 이름 사용
warrantyStartDate = equipmentIn.inDate;
warrantyEndDate = equipmentIn.inDate.add(const Duration(days: 365));
// 워런티 코드도 불러오도록(실제 구현시)
warrantyCode = null; // TODO: 실제 데이터에서 불러올 경우 수정
}
}
// 워런티 기간 계산
String getWarrantyPeriodSummary() {
final difference = warrantyEndDate.difference(warrantyStartDate);
final days = difference.inDays;
if (days <= 0) {
return '유효하지 않은 기간';
}
final years = days ~/ 365;
final remainingDays = days % 365;
String summary = '';
if (years > 0) {
summary += '$years년 ';
}
if (remainingDays > 0) {
summary += '$remainingDays일';
}
return summary.trim();
}
// 저장 처리
bool save() {
if (!formKey.currentState!.validate()) {
return false;
}
formKey.currentState!.save();
// 입력값이 리스트에 없으면 추가
if (partnerCompany != null &&
partnerCompany!.isNotEmpty &&
!partnerCompanies.contains(partnerCompany)) {
partnerCompanies.add(partnerCompany!);
}
if (warehouseLocation != null &&
warehouseLocation!.isNotEmpty &&
!warehouseLocations.contains(warehouseLocation)) {
warehouseLocations.add(warehouseLocation!);
}
if (manufacturer.isNotEmpty && !manufacturers.contains(manufacturer)) {
manufacturers.add(manufacturer);
}
if (name.isNotEmpty && !equipmentNames.contains(name)) {
equipmentNames.add(name);
}
if (category.isNotEmpty && !categories.contains(category)) {
categories.add(category);
}
if (subCategory.isNotEmpty && !subCategories.contains(subCategory)) {
subCategories.add(subCategory);
}
if (subSubCategory.isNotEmpty &&
!subSubCategories.contains(subSubCategory)) {
subSubCategories.add(subSubCategory);
}
if (warrantyLicense != null &&
warrantyLicense!.isNotEmpty &&
!warrantyLicenses.contains(warrantyLicense)) {
warrantyLicenses.add(warrantyLicense!);
}
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: hasSerialNumber ? serialNumber : null,
barcode: barcode.isNotEmpty ? barcode : null,
quantity: quantity,
remark: remarkController.text.trim(),
warrantyLicense: warrantyLicense,
warrantyStartDate: warrantyStartDate,
warrantyEndDate: warrantyEndDate,
// 워런티 코드 저장 필요시 여기에 추가
);
if (isEditMode) {
final equipmentIn = dataService.getEquipmentInById(equipmentInId!);
if (equipmentIn != null) {
final updatedEquipmentIn = EquipmentIn(
id: equipmentIn.id,
equipment: equipment,
inDate: inDate,
status: equipmentIn.status,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentIn(updatedEquipmentIn);
}
} else {
final newEquipmentIn = EquipmentIn(
equipment: equipment,
inDate: inDate,
type: equipmentType,
warehouseLocation: warehouseLocation,
partnerCompany: partnerCompany,
remark: remarkController.text.trim(),
);
dataService.addEquipmentIn(newEquipmentIn);
}
// 저장 후 리스트 재로딩 (중복 방지 및 최신화)
_loadManufacturers();
_loadEquipmentNames();
_loadCategories();
_loadSubCategories();
_loadSubSubCategories();
_loadWarehouseLocations();
_loadPartnerCompanies();
_loadWarrantyLicenses();
return true;
}
void dispose() {
remarkController.dispose();
}
}

View File

@@ -0,0 +1,170 @@
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/utils/constants.dart';
// companyTypeToString 함수 import
import 'package:superport/utils/constants.dart'
show companyTypeToString, CompanyType;
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentListController {
final MockDataService dataService;
List<UnifiedEquipment> equipments = [];
String? selectedStatusFilter;
final Set<String> selectedEquipmentIds = {}; // 'id:status' 형식
EquipmentListController({required this.dataService});
// 데이터 로드 및 상태 필터 적용
void loadData() {
equipments = dataService.getAllEquipments();
if (selectedStatusFilter != null) {
equipments =
equipments.where((e) => e.status == selectedStatusFilter).toList();
}
selectedEquipmentIds.clear();
}
// 상태 필터 변경
void changeStatusFilter(String? status) {
selectedStatusFilter = status;
loadData();
}
// 장비 선택/해제 (모든 상태 지원)
void selectEquipment(int? id, String status, bool? isSelected) {
if (id == null || isSelected == null) return;
final key = '$id:$status';
if (isSelected) {
selectedEquipmentIds.add(key);
} else {
selectedEquipmentIds.remove(key);
}
}
// 선택된 입고 장비 수 반환
int getSelectedInStockCount() {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == EquipmentStatus.in_) {
count++;
}
}
return count;
}
// 선택된 전체 장비 수 반환
int getSelectedEquipmentCount() {
return selectedEquipmentIds.length;
}
// 선택된 특정 상태의 장비 수 반환
int getSelectedEquipmentCountByStatus(String status) {
int count = 0;
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
count++;
}
}
return count;
}
// 선택된 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipments() {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == parts[1],
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 특정 상태의 장비들의 UnifiedEquipment 객체 목록 반환
List<UnifiedEquipment> getSelectedEquipmentsByStatus(String status) {
List<UnifiedEquipment> selected = [];
for (final idStatusPair in selectedEquipmentIds) {
final parts = idStatusPair.split(':');
if (parts.length == 2 && parts[1] == status) {
final id = int.tryParse(parts[0]);
if (id != null) {
final equipment = equipments.firstWhere(
(e) => e.id == id && e.status == status,
orElse: () => null as UnifiedEquipment,
);
if (equipment != null) {
selected.add(equipment);
}
}
}
}
return selected;
}
// 선택된 장비들의 요약 정보를 Map 형태로 반환 (출고/대여/폐기 폼에서 사용)
List<Map<String, dynamic>> getSelectedEquipmentsSummary() {
List<Map<String, dynamic>> summaryList = [];
List<UnifiedEquipment> selectedEquipmentsInStock =
getSelectedEquipmentsByStatus(EquipmentStatus.in_);
for (final equipment in selectedEquipmentsInStock) {
summaryList.add({
'equipment': equipment.equipment,
'equipmentInId': equipment.id,
'status': equipment.status,
});
}
return summaryList;
}
// 출고 정보(회사, 담당자, 라이센스 등) 반환
String getOutEquipmentInfo(int equipmentId, String infoType) {
final equipmentOut = dataService.getEquipmentOutById(equipmentId);
if (equipmentOut != null) {
switch (infoType) {
case 'company':
final company = equipmentOut.company ?? '-';
if (company != '-') {
final companyObj = dataService.getAllCompanies().firstWhere(
(c) => c.name == company,
orElse:
() => Company(
name: company,
address: Address(),
companyTypes: [CompanyType.customer], // 기본값 고객사
),
);
// 여러 유형 중 첫 번째만 표시 (대표 유형)
final typeText =
companyObj.companyTypes.isNotEmpty
? companyTypeToString(companyObj.companyTypes.first)
: '-';
return '$company (${typeText})';
}
return company;
case 'manager':
return equipmentOut.manager ?? '-';
case 'license':
return equipmentOut.license ?? '-';
default:
return '-';
}
}
return '-';
}
}

View File

@@ -0,0 +1,645 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/mock_data_service.dart';
// 장비 출고 폼의 상태 및 비즈니스 로직을 담당하는 컨트롤러
class EquipmentOutFormController {
final MockDataService dataService;
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final TextEditingController remarkController = TextEditingController();
// 상태 변수
bool isEditMode = false;
String manufacturer = '';
String name = '';
String category = '';
String subCategory = '';
String subSubCategory = '';
String serialNumber = '';
String barcode = '';
int quantity = 1;
DateTime outDate = DateTime.now();
bool hasSerialNumber = false;
DateTime? inDate;
String returnType = '재입고';
DateTime returnDate = DateTime.now();
bool hasManagers = false;
// 출고 유형(출고/대여/폐기) 상태 변수 추가
String outType = '출고'; // 기본값은 '출고'
// 기존 필드 - 호환성을 위해 유지
String? _selectedCompany;
String? get selectedCompany =>
selectedCompanies.isNotEmpty ? selectedCompanies[0] : null;
set selectedCompany(String? value) {
if (selectedCompanies.isEmpty) {
selectedCompanies.add(value);
} else {
selectedCompanies[0] = value;
}
_selectedCompany = value;
}
String? _selectedManager;
String? get selectedManager =>
selectedManagersPerCompany.isNotEmpty
? selectedManagersPerCompany[0]
: null;
set selectedManager(String? value) {
if (selectedManagersPerCompany.isEmpty) {
selectedManagersPerCompany.add(value);
} else {
selectedManagersPerCompany[0] = value;
}
_selectedManager = value;
}
String? selectedLicense;
List<String> companies = [];
// 회사 및 지점 관련 데이터
List<CompanyBranchInfo> companiesWithBranches = [];
List<String> managers = [];
List<String> filteredManagers = [];
List<String> licenses = [];
// 출고 회사 목록 관리
List<String?> selectedCompanies = [null]; // 첫 번째 드롭다운을 위한 초기값
List<List<String>> availableCompaniesPerDropdown =
[]; // 각 드롭다운마다 사용 가능한 회사 목록
List<String?> selectedManagersPerCompany = [null]; // 각 드롭다운 회사별 선택된 담당자
List<List<String>> filteredManagersPerCompany = []; // 각 드롭다운 회사별 필터링된 담당자 목록
List<bool> hasManagersPerCompany = [false]; // 각 회사별 담당자 유무
// 입력 데이터
Equipment? selectedEquipment;
int? selectedEquipmentInId;
int? equipmentOutId;
List<Map<String, dynamic>>? _selectedEquipments;
EquipmentOutFormController({required this.dataService});
// 선택된 장비 정보 설정 (디버그용)
set selectedEquipments(List<Map<String, dynamic>>? equipments) {
debugPrint('설정된 장비 목록: ${equipments?.length ?? 0}');
if (equipments != null) {
for (var i = 0; i < equipments.length; i++) {
final equipment = equipments[i]['equipment'] as Equipment;
debugPrint('장비 $i: ${equipment.manufacturer} ${equipment.name}');
}
}
_selectedEquipments = equipments;
}
List<Map<String, dynamic>>? get selectedEquipments => _selectedEquipments;
// 드롭다운 데이터 로드
void loadDropdownData() {
final allCompanies = dataService.getAllCompanies();
// 회사와 지점 통합 목록 생성
companiesWithBranches = [];
companies = [];
for (var company in allCompanies) {
// 회사 자체 정보 추가
final companyType =
company.companyTypes.isNotEmpty
? companyTypeToString(company.companyTypes.first)
: '-';
final companyInfo = CompanyBranchInfo(
id: company.id,
name: "${company.name} (${companyType})",
originalName: company.name,
isMainCompany: true,
companyId: company.id,
branchId: null,
);
companiesWithBranches.add(companyInfo);
companies.add(companyInfo.name);
// 지점 정보 추가
if (company.branches != null && company.branches!.isNotEmpty) {
for (var branch in company.branches!) {
final branchInfo = CompanyBranchInfo(
id: branch.id,
name: "${company.name} ${branch.name}",
displayName: branch.name,
originalName: branch.name,
isMainCompany: false,
companyId: company.id,
branchId: branch.id,
parentCompanyName: company.name,
);
companiesWithBranches.add(branchInfo);
companies.add(branchInfo.name);
}
}
}
// 나머지 데이터 로드
final allUsers = dataService.getAllUsers();
managers = allUsers.map((user) => user.name).toList();
filteredManagers = managers;
final allLicenses = dataService.getAllLicenses();
licenses = allLicenses.map((license) => license.name).toList();
if (companies.isEmpty) companies.add('기타');
if (managers.isEmpty) managers.add('기타');
if (licenses.isEmpty) licenses.add('기타');
updateManagersState();
// 출고 회사 드롭다운 초기화
availableCompaniesPerDropdown = [List.from(companies)];
filteredManagersPerCompany = [List.from(managers)];
hasManagersPerCompany = [hasManagers];
// 디버그 정보 출력
debugPrint('드롭다운 데이터 로드 완료');
debugPrint('장비 목록: ${_selectedEquipments?.length ?? 0}');
debugPrint('회사 및 지점 목록: ${companiesWithBranches.length}');
// 수정 모드인 경우 기존 선택값 동기화
if (isEditMode && equipmentOutId != null) {
final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!);
if (equipmentOut != null && equipmentOut.company != null) {
String companyName = '';
// 회사 이름 찾기
for (String company in companies) {
if (company.startsWith(equipmentOut.company!)) {
companyName = company;
break;
}
}
if (companyName.isNotEmpty) {
selectedCompanies[0] = companyName;
filterManagersByCompanyAtIndex(companyName, 0);
// 기존 담당자 설정
if (equipmentOut.manager != null) {
selectedManagersPerCompany[0] = equipmentOut.manager;
}
}
// 라이센스 설정
if (equipmentOut.license != null) {
selectedLicense = equipmentOut.license;
}
}
}
}
// 회사에 따라 담당자 목록 필터링
void filterManagersByCompany(String? companyName) {
if (companyName == null || companyName.isEmpty) {
filteredManagers = managers;
} else {
// 회사 또는 지점 이름에서 CompanyBranchInfo 찾기
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
companyName,
);
if (companyInfo != null && companyInfo.companyId != null) {
int companyId = companyInfo.companyId!;
final companyUsers =
dataService
.getAllUsers()
.where((user) => user.companyId == companyId)
.toList();
if (companyUsers.isNotEmpty) {
filteredManagers = companyUsers.map((user) => user.name).toList();
} else {
filteredManagers = ['없음'];
}
} else {
filteredManagers = ['없음'];
}
}
if (selectedManager != null &&
!filteredManagers.contains(selectedManager)) {
selectedManager =
filteredManagers.isNotEmpty ? filteredManagers[0] : null;
}
updateManagersState();
// 첫 번째 회사에 대한 담당자 목록과 동기화
if (filteredManagersPerCompany.isNotEmpty) {
filteredManagersPerCompany[0] = List.from(filteredManagers);
hasManagersPerCompany[0] = hasManagers;
if (selectedManagersPerCompany.isNotEmpty) {
selectedManagersPerCompany[0] = selectedManager;
}
}
}
// 특정 인덱스의 회사에 따라 담당자 목록 필터링
void filterManagersByCompanyAtIndex(String? companyName, int index) {
if (companyName == null || companyName.isEmpty) {
filteredManagersPerCompany[index] = managers;
} else {
// 회사 또는 지점 이름에서 CompanyBranchInfo 찾기
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
companyName,
);
if (companyInfo != null && companyInfo.companyId != null) {
int companyId = companyInfo.companyId!;
final companyUsers =
dataService
.getAllUsers()
.where((user) => user.companyId == companyId)
.toList();
if (companyUsers.isNotEmpty) {
filteredManagersPerCompany[index] =
companyUsers.map((user) => user.name).toList();
} else {
filteredManagersPerCompany[index] = ['없음'];
}
} else {
filteredManagersPerCompany[index] = ['없음'];
}
}
if (selectedManagersPerCompany[index] != null &&
!filteredManagersPerCompany[index].contains(
selectedManagersPerCompany[index],
)) {
selectedManagersPerCompany[index] =
filteredManagersPerCompany[index].isNotEmpty
? filteredManagersPerCompany[index][0]
: null;
}
updateManagersStateAtIndex(index);
// 첫 번째 회사인 경우 기존 필드와 동기화
if (index == 0) {
filteredManagers = List.from(filteredManagersPerCompany[0]);
hasManagers = hasManagersPerCompany[0];
_selectedManager = selectedManagersPerCompany[0];
}
}
// 담당자 있는지 상태 업데이트
void updateManagersState() {
hasManagers =
filteredManagers.isNotEmpty &&
!(filteredManagers.length == 1 && filteredManagers[0] == '없음');
}
// 특정 인덱스의 담당자 상태 업데이트
void updateManagersStateAtIndex(int index) {
hasManagersPerCompany[index] =
filteredManagersPerCompany[index].isNotEmpty &&
!(filteredManagersPerCompany[index].length == 1 &&
filteredManagersPerCompany[index][0] == '없음');
}
// 출고 회사 추가
void addCompany() {
// 이미 선택된 회사 제외한 리스트 생성
List<String> availableCompanies = List.from(companies);
for (String? company in selectedCompanies) {
if (company != null) {
availableCompanies.remove(company);
}
}
// 새 드롭다운 추가
selectedCompanies.add(null);
availableCompaniesPerDropdown.add(availableCompanies);
selectedManagersPerCompany.add(null);
filteredManagersPerCompany.add(List.from(managers));
hasManagersPerCompany.add(false);
}
// 가능한 회사 목록 업데이트
void updateAvailableCompanies() {
// 각 드롭다운에 대해 사용 가능한 회사 목록 업데이트
for (int i = 0; i < selectedCompanies.length; i++) {
List<String> availableCompanies = List.from(companies);
// 이미 선택된 회사 제외
for (int j = 0; j < selectedCompanies.length; j++) {
if (i != j && selectedCompanies[j] != null) {
availableCompanies.remove(selectedCompanies[j]);
}
}
availableCompaniesPerDropdown[i] = availableCompanies;
}
}
// 선택 장비로 초기화
void initializeWithSelectedEquipment(Equipment equipment) {
manufacturer = equipment.manufacturer;
name = equipment.name;
category = equipment.category;
subCategory = equipment.subCategory;
subSubCategory = equipment.subSubCategory;
serialNumber = equipment.serialNumber ?? '';
barcode = equipment.barcode ?? '';
quantity = equipment.quantity;
hasSerialNumber = serialNumber.isNotEmpty;
inDate = equipment.inDate;
remarkController.text = equipment.remark ?? '';
}
// 회사/지점 표시 이름을 통해 CompanyBranchInfo 객체 찾기
CompanyBranchInfo? _findCompanyInfoByDisplayName(String displayName) {
for (var info in companiesWithBranches) {
if (info.name == displayName) {
return info;
}
}
return null;
}
// 출고 정보 저장 (UI에서 호출)
void saveEquipmentOut(Function(String) onSuccess, Function(String) onError) {
if (formKey.currentState?.validate() != true) {
onError('폼 유효성 검사 실패');
return;
}
formKey.currentState?.save();
// 선택된 회사가 없는지 확인
bool hasAnySelectedCompany = selectedCompanies.any(
(company) => company != null,
);
if (!hasAnySelectedCompany) {
onError('최소 하나의 출고 회사를 선택해주세요');
return;
}
// 기존 방식으로 첫 번째 회사 정보 처리
String? companyName;
if (selectedCompanies.isNotEmpty && selectedCompanies[0] != null) {
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[0]!,
);
if (companyInfo != null) {
companyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
companyName = selectedCompanies[0]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
} else {
onError('최소 하나의 출고 회사를 선택해주세요');
return;
}
if (isEditMode && equipmentOutId != null) {
final equipmentOut = dataService.getEquipmentOutById(equipmentOutId!);
if (equipmentOut != null) {
final updatedEquipmentOut = EquipmentOut(
id: equipmentOut.id,
equipment: equipmentOut.equipment,
outDate: equipmentOut.outDate,
status: returnType == '재입고' ? 'I' : 'R',
company: companyName,
manager: equipmentOut.manager,
license: equipmentOut.license,
returnDate: returnDate,
returnType: returnType,
remark: remarkController.text.trim(),
);
dataService.updateEquipmentOut(updatedEquipmentOut);
onSuccess('장비 출고 상태 변경 완료');
} else {
onError('출고 정보를 찾을 수 없습니다');
}
} else {
if (selectedEquipments != null && selectedEquipments!.isNotEmpty) {
// 여러 회사에 각각 출고 처리
List<String> successCompanies = [];
// 선택된 모든 회사에 대해 출고 처리
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
// 해당 회사에 모든 장비 출고 처리
for (final equipmentData in selectedEquipments!) {
final equipment = equipmentData['equipment'] as Equipment;
final equipmentInId = equipmentData['equipmentInId'] as int;
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.changeEquipmentStatus(equipmentInId, newEquipmentOut);
}
successCompanies.add(companyInfo?.name ?? curCompanyName);
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 다중 장비 출고 처리 완료');
}
} else if (selectedEquipmentInId != null) {
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: (hasSerialNumber) ? serialNumber : null,
barcode: barcode.isNotEmpty ? barcode : null,
quantity: quantity,
inDate: inDate,
remark: remarkController.text.trim(),
);
// 선택된 모든 회사에 대해 출고 처리
List<String> successCompanies = [];
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.changeEquipmentStatus(
selectedEquipmentInId!,
newEquipmentOut,
);
successCompanies.add(companyInfo?.name ?? curCompanyName);
break; // 한 장비는 한 회사에만 출고
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 장비 출고 처리 완료');
}
} else {
final equipment = Equipment(
manufacturer: manufacturer,
name: name,
category: category,
subCategory: subCategory,
subSubCategory: subSubCategory,
serialNumber: null,
barcode: null,
quantity: 1,
inDate: inDate,
remark: remarkController.text.trim(),
);
// 선택된 모든 회사에 대해 출고 처리
List<String> successCompanies = [];
for (int i = 0; i < selectedCompanies.length; i++) {
if (selectedCompanies[i] == null) continue;
CompanyBranchInfo? companyInfo = _findCompanyInfoByDisplayName(
selectedCompanies[i]!,
);
String curCompanyName;
if (companyInfo != null) {
curCompanyName =
companyInfo.isMainCompany
? companyInfo
.originalName // 본사인 경우 회사 원래 이름
: "${companyInfo.originalName} (${companyInfo.branchId})"; // 지점인 경우 지점 정보 포함
} else {
curCompanyName = selectedCompanies[i]!.replaceAll(
RegExp(r' \(.*\)\$'),
'',
);
}
String? curManager = selectedManagersPerCompany[i];
if (curManager == null || curManager == '없음') {
// 담당자 없는 회사는 건너뛰기
continue;
}
final newEquipmentOut = EquipmentOut(
equipment: equipment,
outDate: outDate,
company: curCompanyName,
manager: curManager,
license: selectedLicense,
remark: remarkController.text.trim(),
);
dataService.addEquipmentOut(newEquipmentOut);
successCompanies.add(companyInfo?.name ?? curCompanyName);
}
if (successCompanies.isEmpty) {
onError('모든 회사에 담당자가 없어 출고 처리할 수 없습니다');
} else {
onSuccess('${successCompanies.join(", ")} 회사로 새 출고 장비 추가 완료');
}
}
}
}
// 날짜 포맷 유틸리티
String formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
void dispose() {
remarkController.dispose();
}
}
/// 회사 및 지점 정보를 저장하는 클래스
class CompanyBranchInfo {
final int? id;
final String name; // 표시용 이름 (회사명 + 지점명 또는 회사명 (유형))
final String originalName; // 원래 이름 (회사 본사명 또는 지점명)
final String? displayName; // UI에 표시할 이름 (주로 지점명)
final bool isMainCompany; // 본사인지 지점인지 구분
final int? companyId; // 회사 ID
final int? branchId; // 지점 ID
final String? parentCompanyName; // 부모 회사명 (지점인 경우)
CompanyBranchInfo({
required this.id,
required this.name,
required this.originalName,
this.displayName,
required this.isMainCompany,
required this.companyId,
required this.branchId,
this.parentCompanyName,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,696 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart';
import 'package:superport/screens/equipment/widgets/equipment_table.dart';
import 'package:superport/utils/equipment_display_helper.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/main_layout.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
// 장비 목록 화면 (UI만 담당, 상태/로직/헬퍼/위젯 분리)
class EquipmentListScreen extends StatefulWidget {
final String currentRoute;
const EquipmentListScreen({super.key, this.currentRoute = Routes.equipment});
@override
State<EquipmentListScreen> createState() => _EquipmentListScreenState();
}
class _EquipmentListScreenState extends State<EquipmentListScreen> {
late final EquipmentListController _controller;
bool _showDetailedColumns = true;
final ScrollController _horizontalScrollController = ScrollController();
final ScrollController _verticalScrollController = ScrollController();
int _currentPage = 1;
final int _pageSize = 10;
String _searchKeyword = '';
String _appliedSearchKeyword = '';
@override
void initState() {
super.initState();
_controller = EquipmentListController(dataService: MockDataService());
_controller.loadData();
WidgetsBinding.instance.addPostFrameCallback((_) {
_adjustColumnsForScreenSize();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_setDefaultFilterByRoute();
}
@override
void didUpdateWidget(EquipmentListScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentRoute != widget.currentRoute) {
_setDefaultFilterByRoute();
}
}
@override
void dispose() {
_horizontalScrollController.dispose();
_verticalScrollController.dispose();
super.dispose();
}
// 라우트에 따라 기본 필터 설정
void _setDefaultFilterByRoute() {
String? newFilter;
if (widget.currentRoute == Routes.equipmentInList) {
newFilter = EquipmentStatus.in_;
} else if (widget.currentRoute == Routes.equipmentOutList) {
newFilter = EquipmentStatus.out;
} else if (widget.currentRoute == Routes.equipmentRentList) {
newFilter = EquipmentStatus.rent;
} else if (widget.currentRoute == Routes.equipment) {
newFilter = null;
}
if ((newFilter != _controller.selectedStatusFilter) ||
widget.currentRoute != Routes.equipment) {
setState(() {
_controller.selectedStatusFilter = newFilter;
_controller.loadData();
});
}
}
// 화면 크기에 따라 컬럼 표시 조정
void _adjustColumnsForScreenSize() {
final width = MediaQuery.of(context).size.width;
setState(() {
_showDetailedColumns = width > 900;
});
}
// 상태 필터 변경
void _onStatusFilterChanged(String? status) {
setState(() {
_controller.changeStatusFilter(status);
});
}
// 장비 선택/해제
void _onEquipmentSelected(int? id, String status, bool? isSelected) {
setState(() {
_controller.selectEquipment(id, status, isSelected);
});
}
// 출고 처리 버튼 핸들러
void _handleOutEquipment() async {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('출고할 장비를 선택해주세요.')));
return;
}
// 선택된 장비들의 요약 정보를 가져와서 출고 폼으로 전달
final selectedEquipmentsSummary =
_controller.getSelectedEquipmentsSummary();
final result = await Navigator.pushNamed(
context,
Routes.equipmentOutAdd,
arguments: {'selectedEquipments': selectedEquipmentsSummary},
);
if (result == true) {
setState(() {
_controller.loadData();
});
}
}
// 대여 처리 버튼 핸들러
void _handleRentEquipment() async {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('대여할 장비를 선택해주세요.')));
return;
}
// 선택된 장비들의 요약 정보를 가져와서 대여 폼으로 전달
final selectedEquipmentsSummary =
_controller.getSelectedEquipmentsSummary();
// 현재는 대여 기능이 준비되지 않았으므로 간단히 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${selectedEquipmentsSummary.length}개 장비 대여 기능은 준비 중입니다.',
),
),
);
}
// 폐기 처리 버튼 핸들러
void _handleDisposeEquipment() {
if (_controller.getSelectedInStockCount() == 0) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('폐기할 장비를 선택해주세요.')));
return;
}
// 선택된 장비들의 요약 정보를 가져옴
final selectedEquipmentsSummary =
_controller.getSelectedEquipmentsSummary();
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('폐기 확인'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'선택한 ${selectedEquipmentsSummary.length}개 장비를 폐기하시겠습니까?',
),
const SizedBox(height: 16),
const Text(
'폐기할 장비 목록:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...selectedEquipmentsSummary.map((equipmentData) {
final equipment = equipmentData['equipment'] as Equipment;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'${equipment.manufacturer} ${equipment.name} (${equipment.quantity}개)',
style: const TextStyle(fontSize: 14),
),
);
}).toList(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
// 여기에 폐기 로직 추가 예정
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('폐기 기능은 준비 중입니다.')),
);
Navigator.pop(context);
},
child: const Text('폐기'),
),
],
),
);
}
// 카테고리 축약 표기 함수 (예: 컴... > 태... > 안드로...)
String _shortenCategory(String category) {
if (category.length <= 2) return category;
return category.substring(0, 2) + '...';
}
// 카테고리 툴팁 위젯 (UI만 담당, 축약 표기 적용)
Widget _buildCategoryWithTooltip(UnifiedEquipment equipment) {
final fullCategory = EquipmentDisplayHelper.formatCategory(
equipment.equipment.category,
equipment.equipment.subCategory,
equipment.equipment.subSubCategory,
);
// 축약 표기 적용
final shortCategory = [
_shortenCategory(equipment.equipment.category),
_shortenCategory(equipment.equipment.subCategory),
_shortenCategory(equipment.equipment.subSubCategory),
].join(' > ');
return Tooltip(message: fullCategory, child: Text(shortCategory));
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final maxContentWidth = screenWidth > 1200 ? 1200.0 : screenWidth - 32;
String screenTitle = '장비 목록';
if (widget.currentRoute == Routes.equipmentInList) {
screenTitle = '입고된 장비';
} else if (widget.currentRoute == Routes.equipmentOutList) {
screenTitle = '출고된 장비';
} else if (widget.currentRoute == Routes.equipmentRentList) {
screenTitle = '대여된 장비';
}
final int totalCount = _controller.equipments.length;
final List<UnifiedEquipment> filteredEquipments =
_appliedSearchKeyword.isEmpty
? _controller.equipments
: _controller.equipments.where((e) {
final keyword = _appliedSearchKeyword.toLowerCase();
// 모든 주요 필드에서 검색
return [
e.equipment.manufacturer,
e.equipment.name,
e.equipment.category,
e.equipment.subCategory,
e.equipment.subSubCategory,
e.equipment.serialNumber ?? '',
e.equipment.barcode ?? '',
e.equipment.remark ?? '',
e.equipment.warrantyLicense ?? '',
e.notes ?? '',
].any((field) => field.toLowerCase().contains(keyword));
}).toList();
final int filteredCount = filteredEquipments.length;
final int startIndex = (_currentPage - 1) * _pageSize;
final int endIndex =
(startIndex + _pageSize) > filteredCount
? filteredCount
: (startIndex + _pageSize);
final pagedEquipments = filteredEquipments.sublist(startIndex, endIndex);
// 선택된 장비 개수
final int selectedCount = _controller.getSelectedEquipmentCount();
final int selectedInCount = _controller.getSelectedInStockCount();
final int selectedOutCount = _controller.getSelectedEquipmentCountByStatus(
EquipmentStatus.out,
);
final int selectedRentCount = _controller.getSelectedEquipmentCountByStatus(
EquipmentStatus.rent,
);
return MainLayout(
title: screenTitle,
currentRoute: widget.currentRoute,
actions: [
IconButton(
icon: Icon(
_showDetailedColumns ? Icons.view_column : Icons.view_compact,
color: Colors.grey,
),
tooltip: _showDetailedColumns ? '간소화된 보기' : '상세 보기',
onPressed: () {
setState(() {
_showDetailedColumns = !_showDetailedColumns;
});
},
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
setState(() {
_controller.loadData();
_currentPage = 1;
});
},
color: Colors.grey,
),
],
child: Container(
width: maxContentWidth,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
screenTitle,
style: AppThemeTailwind.headingStyle,
),
),
if (selectedCount > 0)
Container(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$selectedCount개 선택됨',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (widget.currentRoute == Routes.equipmentInList)
Row(
children: [
ElevatedButton.icon(
onPressed:
selectedInCount > 0 ? _handleOutEquipment : null,
icon: const Icon(
Icons.exit_to_app,
color: Colors.white,
),
label: const Text(
'출고',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
disabledBackgroundColor: Colors.blue.withOpacity(0.5),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.pushNamed(
context,
Routes.equipmentInAdd,
);
if (result == true) {
setState(() {
_controller.loadData();
_currentPage = 1;
});
}
},
icon: const Icon(Icons.add, color: Colors.white),
label: const Text(
'입고',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
SizedBox(
width: 220,
child: Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(
hintText: '장비 검색',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 12,
),
),
onChanged: (value) {
setState(() {
_searchKeyword = value;
});
},
onSubmitted: (value) {
setState(() {
_appliedSearchKeyword = value;
_currentPage = 1;
});
},
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.arrow_forward),
tooltip: '검색',
onPressed: () {
setState(() {
_appliedSearchKeyword = _searchKeyword;
_currentPage = 1;
});
},
),
],
),
),
],
),
// 출고 목록 화면일 때 버튼들
if (widget.currentRoute == Routes.equipmentOutList)
Row(
children: [
ElevatedButton.icon(
onPressed:
selectedOutCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('재입고 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(
Icons.assignment_return,
color: Colors.white,
),
label: const Text(
'재입고',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor: Colors.green.withOpacity(
0.5,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed:
selectedOutCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('수리 요청 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(Icons.build, color: Colors.white),
label: const Text(
'수리 요청',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
disabledBackgroundColor: Colors.orange.withOpacity(
0.5,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
],
),
// 대여 목록 화면일 때 버튼들
if (widget.currentRoute == Routes.equipmentRentList)
Row(
children: [
ElevatedButton.icon(
onPressed:
selectedRentCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('대여 반납 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(
Icons.keyboard_return,
color: Colors.white,
),
label: const Text(
'반납',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor: Colors.green.withOpacity(
0.5,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed:
selectedRentCount > 0
? () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('대여 연장 기능은 준비 중입니다.'),
),
);
}
: null,
icon: const Icon(Icons.date_range, color: Colors.white),
label: const Text(
'연장',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
disabledBackgroundColor: Colors.blue.withOpacity(0.5),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
const SizedBox(height: 8),
Expanded(
child:
pagedEquipments.isEmpty
? const Center(child: Text('장비 정보가 없습니다.'))
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: maxContentWidth,
),
child: EquipmentTable(
equipments: pagedEquipments,
selectedEquipmentIds:
_controller.selectedEquipmentIds,
showDetailedColumns: _showDetailedColumns,
onEquipmentSelected: _onEquipmentSelected,
getOutEquipmentInfo:
_controller.getOutEquipmentInfo,
buildCategoryWithTooltip: _buildCategoryWithTooltip,
// 수정 버튼 동작: 입고 폼(수정 모드)로 이동
onEdit: (id, status) async {
if (status == EquipmentStatus.in_) {
final result = await Navigator.pushNamed(
context,
Routes.equipmentInEdit,
arguments: id,
);
if (result == true) {
setState(() {
_controller.loadData();
});
}
} else {
// 출고/대여 등은 별도 폼으로 이동 필요시 구현
}
},
// 삭제 버튼 동작: 삭제 다이얼로그 및 삭제 처리
onDelete: (id, status) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('삭제 확인'),
content: const Text('이 장비 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed:
() => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
setState(() {
// 입고/출고 상태에 따라 삭제 처리
if (status ==
EquipmentStatus.in_) {
MockDataService()
.deleteEquipmentIn(id);
} else if (status ==
EquipmentStatus.out) {
MockDataService()
.deleteEquipmentOut(id);
}
_controller.loadData();
});
Navigator.pop(context);
},
child: const Text('삭제'),
),
],
),
);
},
getSelectedInStockCount:
_controller.getSelectedInStockCount,
),
),
),
),
if (totalCount > _pageSize)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Pagination(
totalCount: filteredCount,
currentPage: _currentPage,
pageSize: _pageSize,
onPageChanged: (page) {
setState(() {
_currentPage = page;
});
},
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,805 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/services/mock_data_service.dart';
import 'package:superport/screens/equipment/controllers/equipment_out_form_controller.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_card.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
class EquipmentOutFormScreen extends StatefulWidget {
final int? equipmentOutId;
final Equipment? selectedEquipment;
final int? selectedEquipmentInId;
final List<Map<String, dynamic>>? selectedEquipments;
const EquipmentOutFormScreen({
Key? key,
this.equipmentOutId,
this.selectedEquipment,
this.selectedEquipmentInId,
this.selectedEquipments,
}) : super(key: key);
@override
State<EquipmentOutFormScreen> createState() => _EquipmentOutFormScreenState();
}
class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
late final EquipmentOutFormController _controller;
@override
void initState() {
super.initState();
_controller = EquipmentOutFormController(dataService: MockDataService());
_controller.isEditMode = widget.equipmentOutId != null;
_controller.equipmentOutId = widget.equipmentOutId;
_controller.selectedEquipment = widget.selectedEquipment;
_controller.selectedEquipmentInId = widget.selectedEquipmentInId;
_controller.selectedEquipments = widget.selectedEquipments;
_controller.loadDropdownData();
if (_controller.isEditMode) {
// 수정 모드: 기존 출고 정보 로드
// (이 부분은 실제 서비스에서 컨트롤러에 메서드 추가 필요)
} else if (widget.selectedEquipments != null &&
widget.selectedEquipments!.isNotEmpty) {
// 다중 선택 장비 있음: 별도 초기화 필요시 컨트롤러에서 처리
} else if (widget.selectedEquipment != null) {
_controller.initializeWithSelectedEquipment(widget.selectedEquipment!);
}
}
// 요약 테이블 위젯 - 다중 선택 장비에 대한 요약 테이블
Widget _buildSummaryTable() {
if (_controller.selectedEquipments == null ||
_controller.selectedEquipments!.isEmpty) {
return const SizedBox.shrink();
}
// 각 장비별로 전체 폭을 사용하는 리스트로 구현
return Container(
width: double.infinity, // 전체 폭 사용
child: Card(
elevation: 2,
margin: EdgeInsets.zero, // margin 제거
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'선택된 장비 목록 (${_controller.selectedEquipments!.length}개)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// 리스트 헤더
Row(
children: const [
Expanded(
flex: 2,
child: Text(
'제조사',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 2,
child: Text(
'장비명',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 1,
child: Text(
'수량',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 2,
child: Text(
'워런티 시작일',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
flex: 2,
child: Text(
'워런티 종료일',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
const Divider(),
// 리스트 본문
Column(
children: List.generate(_controller.selectedEquipments!.length, (
index,
) {
final equipmentData = _controller.selectedEquipments![index];
final equipment = equipmentData['equipment'] as Equipment;
// 워런티 날짜를 임시로 저장할 수 있도록 상태를 관리(컨트롤러에 리스트로 추가하거나, 여기서 임시로 관리)
// 여기서는 equipment 객체의 필드를 직접 수정(실제 서비스에서는 별도 상태 관리 필요)
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
Expanded(flex: 2, child: Text(equipment.manufacturer)),
Expanded(flex: 2, child: Text(equipment.name)),
Expanded(flex: 1, child: Text('${equipment.quantity}')),
Expanded(
flex: 2,
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate:
equipment.warrantyStartDate ??
DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
equipment.warrantyStartDate = picked;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatDate(equipment.warrantyStartDate),
style: const TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
),
),
),
Expanded(
flex: 2,
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate:
equipment.warrantyEndDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
equipment.warrantyEndDate = picked;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatDate(equipment.warrantyEndDate),
style: const TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
),
),
),
],
),
);
}),
),
],
),
),
),
);
}
// 날짜 포맷 유틸리티
String _formatDate(DateTime? date) {
if (date == null) return '정보 없음';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
// 담당자가 없거나 첫 번째 회사에 대한 담당자가 '없음'인 경우 등록 버튼 비활성화 조건
final bool canSubmit =
_controller.selectedCompanies.isNotEmpty &&
_controller.selectedCompanies[0] != null &&
_controller.hasManagersPerCompany[0] &&
_controller.filteredManagersPerCompany[0].first != '없음';
final int totalSelectedEquipments =
_controller.selectedEquipments?.length ?? 0;
return Scaffold(
appBar: AppBar(
title: Text(
_controller.isEditMode
? '장비 출고 수정'
: totalSelectedEquipments > 0
? '장비 출고 등록 (${totalSelectedEquipments}개)'
: '장비 출고 등록',
),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 장비 정보 요약 섹션
if (_controller.selectedEquipments != null &&
_controller.selectedEquipments!.isNotEmpty)
_buildSummaryTable()
else if (_controller.selectedEquipment != null)
// 단일 장비 요약 카드도 전체 폭으로 맞춤
Container(
width: double.infinity,
child: EquipmentSingleSummaryCard(
equipment: _controller.selectedEquipment!,
),
)
else
const SizedBox.shrink(),
// 요약 카드 아래 라디오 버튼 추가
const SizedBox(height: 12),
// 전체 폭을 사용하는 라디오 버튼
Container(width: double.infinity, child: _buildOutTypeRadio()),
const SizedBox(height: 16),
// 출고 정보 입력 섹션 (수정/등록)
_buildOutgoingInfoSection(context),
// 비고 입력란 추가
const SizedBox(height: 16),
FormFieldWrapper(
label: '비고',
isRequired: false,
child: RemarkInput(
controller: _controller.remarkController,
hint: '비고를 입력하세요',
minLines: 4,
),
),
const SizedBox(height: 24),
// 담당자 없음 경고 메시지
if (_controller.selectedCompanies.isNotEmpty &&
_controller.selectedCompanies[0] != null &&
(!_controller.hasManagersPerCompany[0] ||
_controller.filteredManagersPerCompany[0].first ==
'없음'))
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red.shade300),
),
child: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Expanded(
child: Text(
'선택한 회사에 등록된 담당자가 없습니다. 담당자를 먼저 등록해야 합니다.',
style: TextStyle(color: Colors.red),
),
),
],
),
),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
canSubmit
? () {
// 각 회사별 담당자를 첫 번째 항목으로 설정
for (
int i = 0;
i < _controller.selectedCompanies.length;
i++
) {
if (_controller.selectedCompanies[i] != null &&
_controller.hasManagersPerCompany[i] &&
_controller
.filteredManagersPerCompany[i]
.isNotEmpty &&
_controller
.filteredManagersPerCompany[i]
.first !=
'없음') {
_controller.selectedManagersPerCompany[i] =
_controller
.filteredManagersPerCompany[i]
.first;
}
}
_controller.saveEquipmentOut(
(msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context, true);
},
(err) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(err),
duration: const Duration(seconds: 2),
),
);
},
);
}
: null,
style:
canSubmit
? AppThemeTailwind.primaryButtonStyle
: 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),
),
),
),
),
],
),
),
),
),
);
}
// 출고 정보 입력 섹션 위젯 (등록/수정 공통)
Widget _buildOutgoingInfoSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('출고 정보', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 출고일
_buildDateField(
context,
label: '출고일',
date: _controller.outDate,
onDateChanged: (picked) {
setState(() {
_controller.outDate = picked;
});
},
),
// 출고 회사 영역 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('출고 회사', style: TextStyle(fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () {
setState(() {
_controller.addCompany();
});
},
icon: const Icon(Icons.add_circle_outline, size: 18),
label: const Text('출고 회사 추가'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
const SizedBox(height: 4),
// 동적 출고 회사 드롭다운 목록
...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:
_controller.availableCompaniesPerDropdown[index]
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: _buildCompanyDropdownItem(item),
),
)
.toList(),
validator: (value) {
if (index == 0 && (value == null || value.isEmpty)) {
return '출고 회사를 선택해주세요';
}
return null;
},
onChanged:
(index == 0 ||
_controller.selectedCompanies[index - 1] != null)
? (value) {
setState(() {
_controller.selectedCompanies[index] = value;
_controller.filterManagersByCompanyAtIndex(
value,
index,
);
_controller.updateAvailableCompanies();
});
}
: null,
),
);
}),
// 각 회사별 담당자 선택 목록
...List.generate(_controller.selectedCompanies.length, (index) {
// 회사가 선택된 경우에만 담당자 표시
if (_controller.selectedCompanies[index] != null) {
// 회사 정보 가져오기
final companyInfo = _controller.companiesWithBranches.firstWhere(
(info) => info.name == _controller.selectedCompanies[index],
orElse:
() => CompanyBranchInfo(
id: 0,
name: _controller.selectedCompanies[index]!,
originalName: _controller.selectedCompanies[index]!,
isMainCompany: true,
companyId: 0,
branchId: null,
),
);
// 실제 회사/지점 정보를 ID로 가져오기
Company? company;
Branch? branch;
if (companyInfo.companyId != null) {
company = _controller.dataService.getCompanyById(
companyInfo.companyId!,
);
if (!companyInfo.isMainCompany &&
companyInfo.branchId != null &&
company != null) {
final branches = company.branches;
if (branches != null) {
branch = branches.firstWhere(
(b) => b.id == companyInfo.branchId,
orElse:
() => Branch(
companyId: companyInfo.companyId!,
name: companyInfo.originalName,
),
);
}
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'담당자 정보 (${_controller.selectedCompanies[index]})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 15,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child:
company != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 본사/지점 정보 표시
if (companyInfo.isMainCompany &&
company.contactName != null &&
company.contactName!.isNotEmpty)
Text(
'${company.contactName} ${company.contactPosition ?? ""} ${company.contactPhone ?? ""} ${company.contactEmail ?? ""}',
style: AppThemeTailwind.bodyStyle,
),
if (!companyInfo.isMainCompany &&
branch != null &&
branch.contactName != null &&
branch.contactName!.isNotEmpty)
Text(
'${branch.contactName} ${branch.contactPosition ?? ""} ${branch.contactPhone ?? ""} ${branch.contactEmail ?? ""}',
style: AppThemeTailwind.bodyStyle,
),
const SizedBox(height: 8),
// 담당자 목록에서 실제 담당자 정보만 표시하는 부분은 제거
],
)
: Text(
'회사 정보를 불러올 수 없습니다.',
style: TextStyle(
color: Colors.red.shade400,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
} else {
return const SizedBox.shrink();
}
}),
// 유지 보수(라이센스) 선택
_buildDropdownField(
label: '유지 보수', // 텍스트 변경
value: _controller.selectedLicense,
items: _controller.licenses,
hint: '유지 보수를 선택하세요', // 텍스트 변경
onChanged: (value) {
setState(() {
_controller.selectedLicense = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '유지 보수를 선택해주세요'; // 텍스트 변경
}
return null;
},
),
],
);
}
// 날짜 선택 필드 위젯
Widget _buildDateField(
BuildContext context, {
required String label,
required DateTime date,
required ValueChanged<DateTime> onDateChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null && picked != date) {
onDateChanged(picked);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_controller.formatDate(date),
style: AppThemeTailwind.bodyStyle,
),
const Icon(Icons.calendar_today, size: 20),
],
),
),
),
const SizedBox(height: 12),
],
);
}
// 드롭다운 필드 위젯
Widget _buildDropdownField({
required String label,
required String? value,
required List<String> items,
required String hint,
required ValueChanged<String?>? onChanged,
required String? Function(String?) validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(hintText: hint),
items:
items
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: Text(item),
),
)
.toList(),
validator: validator,
onChanged: onChanged,
),
const SizedBox(height: 12),
],
);
}
// 회사 이름을 표시하는 위젯 (지점 포함)
Widget _buildCompanyDropdownItem(String item) {
final TextStyle defaultStyle = TextStyle(
color: Colors.black87,
fontSize: 14,
fontWeight: FontWeight.normal,
);
// 컨트롤러에서 해당 항목에 대한 정보 확인
final companyInfoList =
_controller.companiesWithBranches
.where((info) => info.name == item)
.toList();
// 회사 정보가 존재하고 지점인 경우
if (companyInfoList.isNotEmpty && !companyInfoList[0].isMainCompany) {
final companyInfo = companyInfoList[0];
final parentCompanyName = companyInfo.parentCompanyName ?? '';
final branchName = companyInfo.displayName ?? companyInfo.originalName;
// Row 대신 RichText 사용 - 지점 표시
return RichText(
text: TextSpan(
style: defaultStyle, // 기본 스타일 설정
children: [
WidgetSpan(
child: Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Colors.grey,
),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: ' ', style: defaultStyle),
TextSpan(
text: parentCompanyName, // 회사명
style: defaultStyle,
),
TextSpan(text: ' ', style: defaultStyle),
TextSpan(
text: branchName, // 지점명
style: const TextStyle(
color: Colors.indigo,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
],
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}
// 일반 회사명 (본사)
return RichText(
text: TextSpan(
style: defaultStyle, // 기본 스타일 설정
children: [
WidgetSpan(
child: Icon(Icons.business, size: 16, color: Colors.black54),
alignment: PlaceholderAlignment.middle,
),
TextSpan(text: ' ', style: defaultStyle),
TextSpan(
text: item,
style: defaultStyle.copyWith(fontWeight: FontWeight.w500),
),
],
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}
// 회사 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: AppThemeTailwind.bodyStyle,
),
);
}
return userWidgets;
}
// 출고/대여/폐기 라디오 버튼 위젯
Widget _buildOutTypeRadio() {
// 출고 유형 리스트
final List<String> outTypes = ['출고', '대여', '폐기'];
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children:
outTypes.map((type) {
return Row(
children: [
Radio<String>(
value: type,
groupValue: _controller.outType, // 컨트롤러에서 현재 선택값 관리
onChanged: (value) {
setState(() {
_controller.outType = value!;
});
},
),
Text(type),
],
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
/// 자동완성 텍스트 필드 위젯
///
/// 입력, 드롭다운, 포커스, 필터링, 선택 기능을 모두 포함한다.
class AutocompleteTextField extends StatefulWidget {
final String label;
final String value;
final List<String> items;
final bool isRequired;
final String hintText;
final void Function(String) onChanged;
final void Function(String) onSelected;
final FocusNode? focusNode;
const AutocompleteTextField({
Key? key,
required this.label,
required this.value,
required this.items,
required this.onChanged,
required this.onSelected,
this.isRequired = false,
this.hintText = '',
this.focusNode,
}) : super(key: key);
@override
State<AutocompleteTextField> createState() => _AutocompleteTextFieldState();
}
class _AutocompleteTextFieldState extends State<AutocompleteTextField> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
late List<String> _filteredItems;
bool _showDropdown = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
_focusNode = widget.focusNode ?? FocusNode();
_filteredItems = List.from(widget.items);
_controller.addListener(_onTextChanged);
_focusNode.addListener(() {
setState(() {
if (_focusNode.hasFocus) {
_showDropdown = _filteredItems.isNotEmpty;
} else {
_showDropdown = false;
}
});
});
}
@override
void didUpdateWidget(covariant AutocompleteTextField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _controller.text) {
_controller.text = widget.value;
}
if (widget.items != oldWidget.items) {
_filteredItems = List.from(widget.items);
}
}
@override
void dispose() {
if (widget.focusNode == null) {
_focusNode.dispose();
}
_controller.dispose();
super.dispose();
}
// 입력값 변경 시 필터링
void _onTextChanged() {
final text = _controller.text;
setState(() {
if (text.isEmpty) {
_filteredItems = List.from(widget.items);
} else {
_filteredItems =
widget.items
.where(
(item) => item.toLowerCase().contains(text.toLowerCase()),
)
.toList();
// 시작 부분이 일치하는 항목 우선 정렬
_filteredItems.sort((a, b) {
bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase());
bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase());
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
return a.compareTo(b);
});
}
_showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus;
widget.onChanged(text);
});
}
void _handleSelect(String value) {
setState(() {
_controller.text = value;
_showDropdown = false;
});
widget.onSelected(value);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
TextFormField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hintText,
),
validator: (value) {
if (widget.isRequired && (value == null || value.isEmpty)) {
return '${widget.label}을(를) 입력해주세요';
}
return null;
},
onSaved: (value) {
widget.onSelected(value ?? '');
},
),
if (_showDropdown)
Positioned(
top: 50,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
return InkWell(
onTap: () => _handleSelect(_filteredItems[index]),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(_filteredItems[index]),
),
);
},
),
),
),
],
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
// 출고 정보(회사, 담당자, 라이센스 등)를 아이콘과 함께 표시하는 위젯
class EquipmentOutInfoIcon extends StatelessWidget {
final String infoType; // company, manager, license 등
final String text;
const EquipmentOutInfoIcon({
super.key,
required this.infoType,
required this.text,
});
@override
Widget build(BuildContext context) {
// infoType에 따라 아이콘 결정
IconData iconData;
switch (infoType) {
case 'company':
iconData = Icons.business;
break;
case 'manager':
iconData = Icons.person;
break;
case 'license':
iconData = Icons.book;
break;
default:
iconData = Icons.info;
}
// 아이콘과 텍스트를 Row로 표시
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(iconData, size: 14, color: Colors.grey[700]),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(fontSize: 13, color: Colors.grey[800]),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:superport/utils/constants.dart';
// 장비 상태에 따라 칩(Chip) 위젯을 반환하는 함수형 위젯
class EquipmentStatusChip extends StatelessWidget {
final String status;
const EquipmentStatusChip({super.key, required this.status});
@override
Widget build(BuildContext context) {
// 상태별 칩 색상 및 텍스트 지정
Color backgroundColor;
String statusText;
switch (status) {
case EquipmentStatus.in_:
backgroundColor = Colors.green;
statusText = '입고';
break;
case EquipmentStatus.out:
backgroundColor = Colors.orange;
statusText = '출고';
break;
case EquipmentStatus.rent:
backgroundColor = Colors.blue;
statusText = '대여';
break;
case EquipmentStatus.repair:
backgroundColor = Colors.blue;
statusText = '수리중';
break;
case EquipmentStatus.damaged:
backgroundColor = Colors.red;
statusText = '손상';
break;
case EquipmentStatus.lost:
backgroundColor = Colors.purple;
statusText = '분실';
break;
case EquipmentStatus.etc:
backgroundColor = Colors.grey;
statusText = '기타';
break;
default:
backgroundColor = Colors.grey;
statusText = '알 수 없음';
}
// 칩 위젯 반환
return Chip(
label: Text(
statusText,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
backgroundColor: backgroundColor,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 5),
);
}
}

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart';
// 다중 선택 장비 요약 카드
class EquipmentMultiSummaryCard extends StatelessWidget {
final List<Map<String, dynamic>> selectedEquipments;
const EquipmentMultiSummaryCard({
super.key,
required this.selectedEquipments,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'선택된 장비 목록 (${selectedEquipments.length}개)',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...selectedEquipments.map((equipmentData) {
final equipment = equipmentData['equipment'] as Equipment;
return EquipmentSingleSummaryCard(equipment: equipment);
}).toList(),
],
);
}
}
// 단일 장비 요약 카드
class EquipmentSingleSummaryCard extends StatelessWidget {
final Equipment equipment;
const EquipmentSingleSummaryCard({super.key, required this.equipment});
// 날짜 포맷 유틸리티
String _formatDate(DateTime? date) {
if (date == null) return '정보 없음';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Card(
elevation: 3,
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
Icon(
Icons.inventory,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
Expanded(
child: Text(
equipment.name.isNotEmpty ? equipment.name : '이름 없음',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade300),
),
child: Text(
'수량: ${equipment.quantity}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Colors.blue.shade800,
),
),
),
],
),
const Divider(thickness: 1.5),
EquipmentSummaryRow(
label: '제조사',
value:
equipment.manufacturer.isNotEmpty
? equipment.manufacturer
: '정보 없음',
),
EquipmentSummaryRow(
label: '카테고리',
value:
equipment.category.isNotEmpty
? '${equipment.category} > ${equipment.subCategory} > ${equipment.subSubCategory}'
: '정보 없음',
),
EquipmentSummaryRow(
label: '시리얼 번호',
value:
(equipment.serialNumber != null &&
equipment.serialNumber!.isNotEmpty)
? equipment.serialNumber!
: '정보 없음',
),
EquipmentSummaryRow(
label: '출고 수량',
value: equipment.quantity.toString(),
),
EquipmentSummaryRow(
label: '입고일',
value: _formatDate(equipment.inDate),
),
// 워런티 정보 추가
if (equipment.warrantyLicense != null &&
equipment.warrantyLicense!.isNotEmpty)
EquipmentSummaryRow(
label: '워런티 라이센스',
value: equipment.warrantyLicense!,
),
EquipmentSummaryRow(
label: '워런티 시작일',
value: _formatDate(equipment.warrantyStartDate),
),
EquipmentSummaryRow(
label: '워런티 종료일',
value: _formatDate(equipment.warrantyEndDate),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
// 장비 요약 정보 행 위젯 (SRP, 재사용성)
class EquipmentSummaryRow extends StatelessWidget {
final String label;
final String value;
const EquipmentSummaryRow({
super.key,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 15,
color: value == '정보 없음' ? Colors.grey.shade600 : Colors.black,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/screens/equipment/widgets/equipment_status_chip.dart';
import 'package:superport/screens/equipment/widgets/equipment_out_info.dart';
import 'package:superport/utils/equipment_display_helper.dart';
// 장비 목록 테이블 위젯 (SRP, 재사용성 강화)
class EquipmentTable extends StatelessWidget {
final List<UnifiedEquipment> equipments;
final Set<String> selectedEquipmentIds;
final bool showDetailedColumns;
final void Function(int? id, String status, bool? isSelected)
onEquipmentSelected;
final String Function(int equipmentId, String infoType) getOutEquipmentInfo;
final Widget Function(UnifiedEquipment equipment) buildCategoryWithTooltip;
final void Function(int id, String status) onEdit;
final void Function(int id, String status) onDelete;
final int Function() getSelectedInStockCount;
const EquipmentTable({
super.key,
required this.equipments,
required this.selectedEquipmentIds,
required this.showDetailedColumns,
required this.onEquipmentSelected,
required this.getOutEquipmentInfo,
required this.buildCategoryWithTooltip,
required this.onEdit,
required this.onDelete,
required this.getSelectedInStockCount,
});
// 출고 정보(간소화 모드) 위젯
Widget _buildCompactOutInfo(int equipmentId) {
final company = getOutEquipmentInfo(equipmentId, 'company');
final manager = getOutEquipmentInfo(equipmentId, 'manager');
final license = getOutEquipmentInfo(equipmentId, 'license');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
EquipmentOutInfoIcon(infoType: 'company', text: company),
const SizedBox(height: 2),
EquipmentOutInfoIcon(infoType: 'manager', text: manager),
const SizedBox(height: 2),
EquipmentOutInfoIcon(infoType: 'license', text: license),
],
);
}
// 카테고리 툴팁 위젯 (UI만 담당, 축약 표기 적용)
Widget _buildCategoryWithTooltip(UnifiedEquipment equipment) {
// 한글 라벨로 표기
final fullCategory =
'대분류: ${equipment.equipment.category} / 중분류: ${equipment.equipment.subCategory} / 소분류: ${equipment.equipment.subSubCategory}';
final shortCategory = [
_shortenCategory(equipment.equipment.category),
_shortenCategory(equipment.equipment.subCategory),
_shortenCategory(equipment.equipment.subSubCategory),
].join(' > ');
return Tooltip(message: fullCategory, child: Text(shortCategory));
}
// 카테고리 축약 표기 함수 (예: 컴...)
String _shortenCategory(String category) {
if (category.length <= 2) return category;
return category.substring(0, 2) + '...';
}
@override
Widget build(BuildContext context) {
return DataTable(
headingRowHeight: 48,
dataRowMinHeight: 48,
dataRowMaxHeight: 60,
columnSpacing: 10,
horizontalMargin: 16,
columns: [
const DataColumn(label: SizedBox(width: 32, child: Text('선택'))),
const DataColumn(label: SizedBox(width: 32, child: Text('번호'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 60, child: Text('제조사'))),
const DataColumn(label: SizedBox(width: 90, child: Text('장비명'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 110, child: Text('분류'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 60, child: Text('장비 유형'))),
if (showDetailedColumns)
const DataColumn(label: SizedBox(width: 70, child: Text('시리얼번호'))),
const DataColumn(label: SizedBox(width: 38, child: Text('수량'))),
const DataColumn(label: SizedBox(width: 80, child: Text('변경 일자'))),
const DataColumn(label: SizedBox(width: 44, child: Text('상태'))),
if (showDetailedColumns) ...[
const DataColumn(label: SizedBox(width: 90, child: Text('출고 회사'))),
const DataColumn(label: SizedBox(width: 60, child: Text('담당자'))),
const DataColumn(label: SizedBox(width: 60, child: Text('라이센스'))),
] else
const DataColumn(label: SizedBox(width: 110, child: Text('출고 정보'))),
const DataColumn(label: SizedBox(width: 60, child: Text('관리'))),
],
rows:
equipments.asMap().entries.map((entry) {
final index = entry.key;
final equipment = entry.value;
final bool isInStock = equipment.status == 'I';
final bool isOutStock = equipment.status == 'O';
return DataRow(
color: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) =>
index % 2 == 0 ? Colors.grey[50] : null,
),
cells: [
DataCell(
Checkbox(
value: selectedEquipmentIds.contains(
'${equipment.id}:${equipment.status}',
),
onChanged:
(isSelected) => onEquipmentSelected(
equipment.id,
equipment.status,
isSelected,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
DataCell(Text('${index + 1}')),
if (showDetailedColumns)
DataCell(
Text(
EquipmentDisplayHelper.formatManufacturer(
equipment.equipment.manufacturer,
),
),
),
DataCell(
Text(
EquipmentDisplayHelper.formatEquipmentName(
equipment.equipment.name,
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (showDetailedColumns)
DataCell(buildCategoryWithTooltip(equipment)),
if (showDetailedColumns)
DataCell(
Text(
equipment.status == 'I' &&
equipment is UnifiedEquipment &&
equipment.type != null
? equipment.type!
: '-',
),
),
if (showDetailedColumns)
DataCell(
Text(
EquipmentDisplayHelper.formatSerialNumber(
equipment.equipment.serialNumber,
),
),
),
DataCell(
Text(
'${equipment.equipment.quantity}',
textAlign: TextAlign.center,
),
),
DataCell(
Text(EquipmentDisplayHelper.formatDate(equipment.date)),
),
DataCell(EquipmentStatusChip(status: equipment.status)),
if (showDetailedColumns) ...[
DataCell(
Text(
isOutStock
? getOutEquipmentInfo(equipment.id!, 'company')
: '-',
),
),
DataCell(
Text(
isOutStock
? getOutEquipmentInfo(equipment.id!, 'manager')
: '-',
),
),
DataCell(
Text(
isOutStock
? getOutEquipmentInfo(equipment.id!, 'license')
: '-',
),
),
] else
DataCell(
isOutStock
? _buildCompactOutInfo(equipment.id!)
: const Text('-'),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: Colors.blue,
size: 20,
),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(5),
onPressed:
() => onEdit(equipment.id!, equipment.status),
),
IconButton(
icon: const Icon(
Icons.delete,
color: Colors.red,
size: 20,
),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(5),
onPressed:
() => onDelete(equipment.id!, equipment.status),
),
],
),
),
],
);
}).toList(),
);
}
}