Files
superport/lib/screens/equipment/equipment_out_form.dart
JiWoong Sul 6d745051b5
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled
refactor: 회사 폼 UI 개선 및 코드 정리
- 담당자 연락처 필드를 드롭다운 + 입력 방식으로 분리
- 사용자 폼과 동일한 전화번호 UI 패턴 적용
- 미사용 위젯 파일 4개 정리 (branch_card, contact_info_* 등)
- 파일명 통일성 확보 (branch_edit_screen → branch_form, company_form_simplified → company_form)
- 네이밍 일관성 개선으로 유지보수성 향상
2025-08-18 17:57:16 +09:00

880 lines
34 KiB
Dart

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/company_branch_info.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/equipment/controllers/equipment_out_form_controller.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_card.dart';
import 'package:superport/screens/equipment/widgets/equipment_summary_row.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
class EquipmentOutFormScreen extends StatefulWidget {
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();
_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!);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// 요약 테이블 위젯 - 다중 선택 장비에 대한 요약 테이블
Widget _buildSummaryTable(EquipmentOutFormController controller) {
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) {
equipment.warrantyStartDate = picked;
controller.notifyListeners();
}
},
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) {
equipment.warrantyEndDate = picked;
controller.notifyListeners();
}
},
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) {
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
? '장비 출고 수정'
: 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(controller)
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(controller)),
const SizedBox(height: 16),
// 출고 정보 입력 섹션 (수정/등록)
_buildOutgoingInfoSection(context, controller),
// 비고 입력란 추가
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(context).then((success) {
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('출고가 완료되었습니다.'),
duration: Duration(seconds: 2),
),
);
Navigator.pop(context, true);
}
});
}
: null,
style:
canSubmit
? ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
foregroundColor: Colors.white,
)
: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade300,
foregroundColor: Colors.grey.shade700,
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
controller.isEditMode ? '수정하기' : '등록하기',
style: const TextStyle(fontSize: 16),
),
),
),
),
],
),
),
),
),
);
},
),
);
}
// 출고 정보 입력 섹션 위젯 (등록/수정 공통)
Widget _buildOutgoingInfoSection(BuildContext context, EquipmentOutFormController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('출고 정보', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 출고일
_buildDateField(
context,
controller,
label: '출고일',
date: controller.outDate,
onDateChanged: (picked) {
controller.outDate = picked;
},
),
// 장비 상태 변경 (출고 시 'inuse'로 자동 설정)
const Text('장비 상태 설정', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: 'inuse', // 출고 시 기본값
decoration: const InputDecoration(
hintText: '출고 후 장비 상태',
labelText: '출고 후 상태 *',
),
items: const [
DropdownMenuItem(value: 'inuse', child: Text('사용 중')),
DropdownMenuItem(value: 'maintenance', child: Text('유지보수')),
],
onChanged: (value) {
// controller.equipmentStatus = value; // TODO: 컨트롤러에 추가 필요
},
validator: (value) {
if (value == null || value.isEmpty) {
return '출고 후 상태를 선택해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 출고 회사 영역 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('출고 회사 *', style: TextStyle(fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () {
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.name,
child: _buildCompanyDropdownItem(item.name, controller),
),
)
.toList(),
validator: (value) {
if (index == 0 && (value == null || value.isEmpty)) {
return '출고 회사를 선택해주세요';
}
return null;
},
onChanged:
(index == 0 ||
controller.selectedCompanies[index - 1] != null)
? (value) {
controller.selectedCompanies[index] = value;
controller.filterManagersByCompanyAtIndex(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) {
// TODO: 실제 CompanyService를 통해 회사 정보 가져오기
// company = await _companyService.getCompanyById(companyInfo.companyId!);
company = null; // 임시로 null 처리
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: ShadcnTheme.bodyMedium,
),
if (!companyInfo.isMainCompany &&
branch != null &&
branch.contactName != null &&
branch.contactName!.isNotEmpty)
Text(
'${branch.contactName} ${branch.contactPosition ?? ""} ${branch.contactPhone ?? ""} ${branch.contactEmail ?? ""}',
style: ShadcnTheme.bodyMedium,
),
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) {
controller.selectedLicense = value;
},
validator: (value) {
if (value == null || value.isEmpty) {
return '유지 보수를 선택해주세요'; // 텍스트 변경
}
return null;
},
),
],
);
}
// 날짜 선택 필드 위젯
Widget _buildDateField(
BuildContext context,
EquipmentOutFormController controller, {
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: ShadcnTheme.bodyMedium,
),
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, EquipmentOutFormController controller) {
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: ShadcnTheme.bodyMedium,
),
);
}
return userWidgets;
}
// 출고/대여/폐기 라디오 버튼 위젯
Widget _buildOutTypeRadio(EquipmentOutFormController controller) {
// 출고 유형 리스트
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) {
controller.outType = value!;
},
),
Text(type),
],
);
}).toList(),
);
}
}