feat: 장비 관리 API 통합 완료

- 장비 출고 API 연동 및 Provider 패턴 적용
- 장비 수정 API 연동 (데이터 로드 시 API 사용)
- 장비 삭제 API 연동 (Controller 메서드 추가)
- 장비 이력 조회 화면 추가 및 API 연동
- 모든 컨트롤러에 ChangeNotifier 패턴 적용
- 에러 처리 및 로딩 상태 관리 개선
- API/Mock 데이터 전환 가능 (Feature Flag)

진행률: 전체 API 통합 70%, 장비 관리 100% 완료
This commit is contained in:
JiWoong Sul
2025-07-24 17:11:05 +09:00
parent 1d1e38bcfa
commit 47bfa3a26a
9 changed files with 650 additions and 144 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
@@ -53,10 +54,16 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// 요약 테이블 위젯 - 다중 선택 장비에 대한 요약 테이블
Widget _buildSummaryTable() {
if (_controller.selectedEquipments == null ||
_controller.selectedEquipments!.isEmpty) {
Widget _buildSummaryTable(EquipmentOutFormController controller) {
if (controller.selectedEquipments == null ||
controller.selectedEquipments!.isEmpty) {
return const SizedBox.shrink();
}
@@ -72,7 +79,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'선택된 장비 목록 (${_controller.selectedEquipments!.length}개)',
'선택된 장비 목록 (${controller.selectedEquipments!.length}개)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@@ -122,10 +129,10 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
const Divider(),
// 리스트 본문
Column(
children: List.generate(_controller.selectedEquipments!.length, (
children: List.generate(controller.selectedEquipments!.length, (
index,
) {
final equipmentData = _controller.selectedEquipments![index];
final equipmentData = controller.selectedEquipments![index];
final equipment = equipmentData['equipment'] as Equipment;
// 워런티 날짜를 임시로 저장할 수 있도록 상태를 관리(컨트롤러에 리스트로 추가하거나, 여기서 임시로 관리)
// 여기서는 equipment 객체의 필드를 직접 수정(실제 서비스에서는 별도 상태 관리 필요)
@@ -149,9 +156,8 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
equipment.warrantyStartDate = picked;
});
equipment.warrantyStartDate = picked;
controller.notifyListeners();
}
},
child: Container(
@@ -185,9 +191,8 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
equipment.warrantyEndDate = picked;
});
equipment.warrantyEndDate = picked;
controller.notifyListeners();
}
},
child: Container(
@@ -229,18 +234,75 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
@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(
return ChangeNotifierProvider.value(
value: _controller,
child: Consumer<EquipmentOutFormController>(
builder: (context, controller, child) {
// 담당자가 없거나 첫 번째 회사에 대한 담당자가 '없음'인 경우 등록 버튼 비활성화 조건
final bool canSubmit =
controller.selectedCompanies.isNotEmpty &&
controller.selectedCompanies[0] != null &&
controller.hasManagersPerCompany[0] &&
controller.filteredManagersPerCompany[0].first != '없음';
final int totalSelectedEquipments =
controller.selectedEquipments?.length ?? 0;
// 로딩 상태 처리
if (controller.isLoading) {
return Scaffold(
appBar: AppBar(
title: const Text('장비 출고'),
),
body: const Center(
child: CircularProgressIndicator(),
),
);
}
// 에러 상태 처리
if (controller.errorMessage != null) {
return Scaffold(
appBar: AppBar(
title: const Text('장비 출고'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.shade400,
),
const SizedBox(height: 16),
Text(
'오류가 발생했습니다',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
controller.errorMessage!,
style: TextStyle(color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
controller.clearError();
controller.loadDropdownData();
},
child: const Text('다시 시도'),
),
],
),
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(
_controller.isEditMode
controller.isEditMode
? '장비 출고 수정'
: totalSelectedEquipments > 0
? '장비 출고 등록 (${totalSelectedEquipments}개)'
@@ -250,21 +312,21 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
key: controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 장비 정보 요약 섹션
if (_controller.selectedEquipments != null &&
_controller.selectedEquipments!.isNotEmpty)
_buildSummaryTable()
else if (_controller.selectedEquipment != null)
if (controller.selectedEquipments != null &&
controller.selectedEquipments!.isNotEmpty)
_buildSummaryTable(controller)
else if (controller.selectedEquipment != null)
// 단일 장비 요약 카드도 전체 폭으로 맞춤
Container(
width: double.infinity,
child: EquipmentSingleSummaryCard(
equipment: _controller.selectedEquipment!,
equipment: controller.selectedEquipment!,
),
)
else
@@ -272,27 +334,27 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 요약 카드 아래 라디오 버튼 추가
const SizedBox(height: 12),
// 전체 폭을 사용하는 라디오 버튼
Container(width: double.infinity, child: _buildOutTypeRadio()),
Container(width: double.infinity, child: _buildOutTypeRadio(controller)),
const SizedBox(height: 16),
// 출고 정보 입력 섹션 (수정/등록)
_buildOutgoingInfoSection(context),
_buildOutgoingInfoSection(context, controller),
// 비고 입력란 추가
const SizedBox(height: 16),
FormFieldWrapper(
label: '비고',
isRequired: false,
child: RemarkInput(
controller: _controller.remarkController,
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 ==
if (controller.selectedCompanies.isNotEmpty &&
controller.selectedCompanies[0] != null &&
(!controller.hasManagersPerCompany[0] ||
controller.filteredManagersPerCompany[0].first ==
'없음'))
Container(
padding: const EdgeInsets.all(8),
@@ -325,26 +387,26 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 각 회사별 담당자를 첫 번째 항목으로 설정
for (
int i = 0;
i < _controller.selectedCompanies.length;
i < controller.selectedCompanies.length;
i++
) {
if (_controller.selectedCompanies[i] != null &&
_controller.hasManagersPerCompany[i] &&
_controller
if (controller.selectedCompanies[i] != null &&
controller.hasManagersPerCompany[i] &&
controller
.filteredManagersPerCompany[i]
.isNotEmpty &&
_controller
.filteredManagersPerCompany[i]
.first !=
'없음') {
_controller.selectedManagersPerCompany[i] =
_controller
controller.selectedManagersPerCompany[i] =
controller
.filteredManagersPerCompany[i]
.first;
}
}
_controller.saveEquipmentOut(
controller.saveEquipmentOut(
(msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -375,7 +437,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
_controller.isEditMode ? '수정하기' : '등록하기',
controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
),
@@ -386,11 +448,14 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
),
),
),
);
},
),
);
}
// 출고 정보 입력 섹션 위젯 (등록/수정 공통)
Widget _buildOutgoingInfoSection(BuildContext context) {
Widget _buildOutgoingInfoSection(BuildContext context, EquipmentOutFormController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -399,12 +464,11 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 출고일
_buildDateField(
context,
controller,
label: '출고일',
date: _controller.outDate,
date: controller.outDate,
onDateChanged: (picked) {
setState(() {
_controller.outDate = picked;
});
controller.outDate = picked;
},
),
@@ -415,9 +479,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
const Text('출고 회사', style: TextStyle(fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () {
setState(() {
_controller.addCompany();
});
controller.addCompany();
},
icon: const Icon(Icons.add_circle_outline, size: 18),
label: const Text('출고 회사 추가'),
@@ -432,24 +494,24 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
const SizedBox(height: 4),
// 동적 출고 회사 드롭다운 목록
...List.generate(_controller.selectedCompanies.length, (index) {
...List.generate(controller.selectedCompanies.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: DropdownButtonFormField<String>(
value: _controller.selectedCompanies[index],
value: controller.selectedCompanies[index],
decoration: InputDecoration(
hintText: index == 0 ? '출고할 회사를 선택하세요' : '추가된 출고할 회사를 선택하세요',
// 이전 드롭다운에 값이 선택되지 않았으면 비활성화
enabled:
index == 0 ||
_controller.selectedCompanies[index - 1] != null,
controller.selectedCompanies[index - 1] != null,
),
items:
_controller.availableCompaniesPerDropdown[index]
controller.availableCompaniesPerDropdown[index]
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: _buildCompanyDropdownItem(item),
child: _buildCompanyDropdownItem(item, controller),
),
)
.toList(),
@@ -461,16 +523,14 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
},
onChanged:
(index == 0 ||
_controller.selectedCompanies[index - 1] != null)
controller.selectedCompanies[index - 1] != null)
? (value) {
setState(() {
_controller.selectedCompanies[index] = value;
_controller.filterManagersByCompanyAtIndex(
controller.selectedCompanies[index] = value;
controller.filterManagersByCompanyAtIndex(
value,
index,
);
_controller.updateAvailableCompanies();
});
controller.updateAvailableCompanies();
}
: null,
),
@@ -478,17 +538,17 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
}),
// 각 회사별 담당자 선택 목록
...List.generate(_controller.selectedCompanies.length, (index) {
...List.generate(controller.selectedCompanies.length, (index) {
// 회사가 선택된 경우에만 담당자 표시
if (_controller.selectedCompanies[index] != null) {
if (controller.selectedCompanies[index] != null) {
// 회사 정보 가져오기
final companyInfo = _controller.companiesWithBranches.firstWhere(
(info) => info.name == _controller.selectedCompanies[index],
final companyInfo = controller.companiesWithBranches.firstWhere(
(info) => info.name == controller.selectedCompanies[index],
orElse:
() => CompanyBranchInfo(
id: 0,
name: _controller.selectedCompanies[index]!,
originalName: _controller.selectedCompanies[index]!,
name: controller.selectedCompanies[index]!,
originalName: controller.selectedCompanies[index]!,
isMainCompany: true,
companyId: 0,
branchId: null,
@@ -500,7 +560,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
Branch? branch;
if (companyInfo.companyId != null) {
company = _controller.dataService.getCompanyById(
company = controller.dataService.getCompanyById(
companyInfo.companyId!,
);
if (!companyInfo.isMainCompany &&
@@ -526,7 +586,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'담당자 정보 (${_controller.selectedCompanies[index]})',
'담당자 정보 (${controller.selectedCompanies[index]})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
@@ -584,13 +644,11 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 유지 보수(라이센스) 선택
_buildDropdownField(
label: '유지 보수', // 텍스트 변경
value: _controller.selectedLicense,
items: _controller.licenses,
value: controller.selectedLicense,
items: controller.licenses,
hint: '유지 보수를 선택하세요', // 텍스트 변경
onChanged: (value) {
setState(() {
_controller.selectedLicense = value;
});
controller.selectedLicense = value;
},
validator: (value) {
if (value == null || value.isEmpty) {
@@ -605,7 +663,8 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 날짜 선택 필드 위젯
Widget _buildDateField(
BuildContext context, {
BuildContext context,
EquipmentOutFormController controller, {
required String label,
required DateTime date,
required ValueChanged<DateTime> onDateChanged,
@@ -637,7 +696,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_controller.formatDate(date),
controller.formatDate(date),
style: AppThemeTailwind.bodyStyle,
),
const Icon(Icons.calendar_today, size: 20),
@@ -685,7 +744,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
}
// 회사 이름을 표시하는 위젯 (지점 포함)
Widget _buildCompanyDropdownItem(String item) {
Widget _buildCompanyDropdownItem(String item, EquipmentOutFormController controller) {
final TextStyle defaultStyle = TextStyle(
color: Colors.black87,
fontSize: 14,
@@ -694,7 +753,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
// 컨트롤러에서 해당 항목에 대한 정보 확인
final companyInfoList =
_controller.companiesWithBranches
controller.companiesWithBranches
.where((info) => info.name == item)
.toList();
@@ -778,7 +837,7 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
}
// 출고/대여/폐기 라디오 버튼 위젯
Widget _buildOutTypeRadio() {
Widget _buildOutTypeRadio(EquipmentOutFormController controller) {
// 출고 유형 리스트
final List<String> outTypes = ['출고', '대여', '폐기'];
return Row(
@@ -789,11 +848,9 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
children: [
Radio<String>(
value: type,
groupValue: _controller.outType, // 컨트롤러에서 현재 선택값 관리
groupValue: controller.outType, // 컨트롤러에서 현재 선택값 관리
onChanged: (value) {
setState(() {
_controller.outType = value!;
});
controller.outType = value!;
},
),
Text(type),