refactor: 회사 폼 UI 개선 및 코드 정리
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

- 담당자 연락처 필드를 드롭다운 + 입력 방식으로 분리
- 사용자 폼과 동일한 전화번호 UI 패턴 적용
- 미사용 위젯 파일 4개 정리 (branch_card, contact_info_* 등)
- 파일명 통일성 확보 (branch_edit_screen → branch_form, company_form_simplified → company_form)
- 네이밍 일관성 개선으로 유지보수성 향상
This commit is contained in:
JiWoong Sul
2025-08-18 17:57:16 +09:00
parent 93bceb8a6c
commit 6d745051b5
37 changed files with 2743 additions and 2446 deletions

View File

@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
import 'package:superport/screens/company/controllers/branch_edit_form_controller.dart';
import 'package:superport/utils/validators.dart';
/// 지점 정보 관리 화면 (등록/수정)
/// User/Warehouse Location 화면과 동일한 패턴으로 구현
class BranchFormScreen extends StatefulWidget {
final Map<String, dynamic> arguments;
const BranchFormScreen({Key? key, required this.arguments}) : super(key: key);
@override
State<BranchFormScreen> createState() => _BranchFormScreenState();
}
class _BranchFormScreenState extends State<BranchFormScreen> {
late final BranchEditFormController _controller;
@override
void initState() {
super.initState();
// arguments에서 정보 추출
final companyId = widget.arguments['companyId'] as int;
final branchId = widget.arguments['branchId'] as int;
final parentCompanyName = widget.arguments['parentCompanyName'] as String;
_controller = BranchEditFormController(
companyId: companyId,
branchId: branchId,
parentCompanyName: parentCompanyName,
);
// 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.loadBranchData();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 저장 처리
Future<void> _onSave() async {
if (_controller.isLoading) return;
final success = await _controller.saveBranch();
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('지점 정보가 수정되었습니다.'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true);
} else if (_controller.error != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error!),
backgroundColor: Colors.red,
),
);
}
}
/// 취소 처리 (변경사항 확인)
void _onCancel() {
if (_controller.hasChanges()) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('변경사항 확인'),
content: const Text('변경된 내용이 있습니다. 저장하지 않고 나가시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('계속 수정'),
),
TextButton(
onPressed: () {
Navigator.pop(context); // 다이얼로그 닫기
Navigator.pop(context); // 화면 닫기
},
child: const Text('나가기'),
),
],
),
);
} else {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${_controller.parentCompanyName} 지점 수정'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _onCancel,
),
),
body: ListenableBuilder(
listenable: _controller,
builder: (context, child) {
// 로딩 상태
if (_controller.isLoading && _controller.originalBranch == null) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('지점 정보를 불러오는 중...'),
],
),
);
}
// 에러 상태
if (_controller.error != null && _controller.originalBranch == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const SizedBox(height: 16),
Text(_controller.error!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _controller.loadBranchData,
child: const Text('다시 시도'),
),
],
),
);
}
// 폼 화면
return Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 지점명 (필수)
FormFieldWrapper(
label: "지점명 *",
child: TextFormField(
controller: _controller.nameController,
decoration: const InputDecoration(
hintText: '지점명을 입력하세요',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '지점명을 입력하세요';
}
if (value.trim().length < 2) {
return '지점명은 2자 이상 입력하세요';
}
return null;
},
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// 주소 (선택)
FormFieldWrapper(
label: "주소",
child: TextFormField(
controller: _controller.addressController,
decoration: const InputDecoration(
hintText: '지점 주소를 입력하세요',
border: OutlineInputBorder(),
),
maxLines: 2,
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// 담당자명 (선택)
FormFieldWrapper(
label: "담당자명",
child: TextFormField(
controller: _controller.managerNameController,
decoration: const InputDecoration(
hintText: '담당자명을 입력하세요',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// 담당자 연락처 (선택)
FormFieldWrapper(
label: "담당자 연락처",
child: TextFormField(
controller: _controller.managerPhoneController,
decoration: const InputDecoration(
hintText: '010-0000-0000',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9-]')),
],
validator: (value) {
if (value != null && value.trim().isNotEmpty) {
return validatePhoneNumber(value);
}
return null;
},
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// 비고 (선택)
FormFieldWrapper(
label: "비고",
child: TextFormField(
controller: _controller.remarkController,
decoration: const InputDecoration(
hintText: '추가 정보나 메모를 입력하세요',
border: OutlineInputBorder(),
),
maxLines: 3,
textInputAction: TextInputAction.done,
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
// 리셋 버튼
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: _controller.hasChanges()
? _controller.resetForm
: null,
child: const Text('초기화'),
),
),
const SizedBox(width: 12),
// 취소 버튼
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: _onCancel,
child: const Text('취소'),
),
),
const SizedBox(width: 12),
// 저장 버튼
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: _controller.isLoading ? null : _onSave,
style: ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(48),
),
child: _controller.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'수정 완료',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const SizedBox(height: 16),
],
),
),
),
);
},
),
);
}
}

View File

@@ -1,299 +1,116 @@
/// 회사 등록 및 수정 화면
///
/// SRP(단일 책임 원칙)에 따라 컴포넌트를 분리하여 구현한 리팩토링 버전
/// - 컨트롤러: CompanyFormController - 비즈니스 로직 담당
/// - 위젯:
/// - CompanyFormHeader: 회사명 및 주소 입력
/// - ContactInfoForm: 담당자 정보 입력
/// - BranchCard: 지점 정보 카드
/// - CompanyNameAutocomplete: 회사명 자동완성
/// - MapDialog: 지도 다이얼로그
/// - DuplicateCompanyDialog: 중복 회사 확인 다이얼로그
/// - CompanyTypeSelector: 회사 유형 선택 라디오 버튼
/// - 유틸리티:
/// - PhoneUtils: 전화번호 관련 유틸리티
import 'package:flutter/material.dart';
// import 'package:superport/models/address_model.dart'; // 사용되지 않는 import
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:superport/models/company_model.dart';
// import 'package:superport/screens/common/custom_widgets.dart'; // 사용되지 않는 import
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
import 'package:superport/screens/company/controllers/company_form_controller.dart';
// import 'package:superport/screens/company/widgets/branch_card.dart'; // 사용되지 않는 import
import 'package:superport/screens/company/widgets/company_form_header.dart';
import 'package:superport/screens/company/widgets/contact_info_form.dart';
import 'package:superport/screens/company/widgets/duplicate_company_dialog.dart';
import 'package:superport/screens/company/widgets/map_dialog.dart';
import 'package:superport/screens/company/widgets/branch_form_widget.dart';
// import 'package:superport/services/mock_data_service.dart'; // Mock 서비스 제거
import 'dart:async';
import 'dart:math' as math;
import 'package:superport/screens/company/controllers/branch_form_controller.dart';
import 'package:superport/core/config/environment.dart' as env;
/// 회사 유형 선택 위젯 (체크박스)
class CompanyTypeSelector extends StatelessWidget {
final List<CompanyType> selectedTypes;
final Function(CompanyType, bool) onTypeChanged;
const CompanyTypeSelector({
Key? key,
required this.selectedTypes,
required this.onTypeChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('회사 유형', style: ShadcnTheme.labelMedium),
const SizedBox(height: 8),
Row(
children: [
// 고객사 체크박스
Checkbox(
value: selectedTypes.contains(CompanyType.customer),
onChanged: (checked) {
onTypeChanged(CompanyType.customer, checked ?? false);
},
),
const Text('고객사'),
const SizedBox(width: 24),
// 파트너사 체크박스
Checkbox(
value: selectedTypes.contains(CompanyType.partner),
onChanged: (checked) {
onTypeChanged(CompanyType.partner, checked ?? false);
},
),
const Text('파트너사'),
],
),
],
),
);
}
}
import 'package:superport/utils/validators.dart';
import 'package:superport/utils/phone_utils.dart';
/// 회사 등록/수정 화면
/// User/Warehouse Location 화면과 동일한 FormFieldWrapper 패턴 사용
class CompanyFormScreen extends StatefulWidget {
final Map? args;
const CompanyFormScreen({Key? key, this.args}) : super(key: key);
@override
_CompanyFormScreenState createState() => _CompanyFormScreenState();
State<CompanyFormScreen> createState() => _CompanyFormScreenState();
}
class _CompanyFormScreenState extends State<CompanyFormScreen> {
late CompanyFormController _controller;
bool isBranch = false;
String? mainCompanyName;
final TextEditingController _addressController = TextEditingController();
final TextEditingController _phoneNumberController = TextEditingController();
int? companyId;
int? branchId;
bool isBranch = false;
// 전화번호 관련 변수
String _selectedPhonePrefix = '010';
List<String> _phonePrefixes = PhoneUtils.getCommonPhonePrefixes();
@override
void initState() {
super.initState();
// controller는 didChangeDependencies에서 초기화
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// arguments 처리
final args = widget.args;
if (args != null) {
isBranch = args['isBranch'] ?? false;
mainCompanyName = args['mainCompanyName'];
companyId = args['companyId'];
branchId = args['branchId'];
isBranch = args['isBranch'] ?? false;
}
// API 모드 확인
final useApi = env.Environment.useApi;
debugPrint('📌 회사 폼 초기화 - API 모드: $useApi, companyId: $companyId');
_controller = CompanyFormController(
companyId: companyId,
useApi: true, // 항상 API 사용
useApi: true,
);
// 일반 회사 수정 모드일 때 데이터 로드
if (!isBranch && companyId != null) {
debugPrint('📌 회사 데이터 로드 시작...');
// 수정 모드일 때 데이터 로드
if (companyId != null && !isBranch) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.loadCompanyData().then((_) {
debugPrint('📌 회사 데이터 로드 완료, UI 갱신');
if (mounted) {
setState(() {
debugPrint('📌 setState 호출됨');
debugPrint('📌 nameController.text: "${_controller.nameController.text}"');
debugPrint('📌 contactNameController.text: "${_controller.contactNameController.text}"');
});
// 주소 필드 초기화
_addressController.text = _controller.companyAddress.toString();
// 전화번호 분리 초기화
final fullPhone = _controller.contactPhoneController.text;
if (fullPhone.isNotEmpty) {
_selectedPhonePrefix = PhoneUtils.extractPhonePrefix(fullPhone, _phonePrefixes);
_phoneNumberController.text = PhoneUtils.extractPhoneNumberWithoutPrefix(fullPhone, _phonePrefixes);
}
setState(() {});
}
}).catchError((error) {
debugPrint('❌ 회사 데이터 로드 실패: $error');
});
});
}
// 지점 수정 모드일 때 branchId로 branch 정보 세팅
if (isBranch && branchId != null) {
// Mock 서비스 제거 - API를 통해 데이터 로드
// 디버그: 진입 시 companyId, branchId 정보 출력
print('[DEBUG] 지점 수정 진입: companyId=$companyId, branchId=$branchId');
// TODO: API를 통해 회사 데이터 로드 필요
// 아래 코드는 Mock 서비스 제거로 인해 주석 처리됨
/*
if (false) { // 임시로 비활성화
print(
'[DEBUG] 불러온 company.name=${company.name}, branches=${company.branches!.map((b) => 'id:${b.id}, name:${b.name}, remark:${b.remark}').toList()}',
);
final branch = company.branches!.firstWhere(
(b) => b.id == branchId,
orElse: () => company.branches!.first,
);
print(
'[DEBUG] 선택된 branch: id=${branch.id}, name=${branch.name}, remark=${branch.remark}',
);
// 폼 컨트롤러의 각 필드에 branch 정보 세팅
_controller.nameController.text = branch.name;
_controller.companyAddress = branch.address;
_controller.contactNameController.text = branch.contactName ?? '';
_controller.contactPositionController.text =
branch.contactPosition ?? '';
_controller.selectedPhonePrefix = extractPhonePrefix(
branch.contactPhone ?? '',
_controller.phonePrefixes,
);
_controller
.contactPhoneController
.text = extractPhoneNumberWithoutPrefix(
branch.contactPhone ?? '',
_controller.phonePrefixes,
);
_controller.contactEmailController.text = branch.contactEmail ?? '';
// 지점 단일 입력만 허용 (branchControllers 초기화)
_controller.branchControllers.clear();
_controller.branchControllers.add(
BranchFormController(
branch: branch,
positions: _controller.positions,
phonePrefixes: _controller.phonePrefixes,
),
);
}
*/
}
}
@override
void dispose() {
_addressController.dispose();
_phoneNumberController.dispose();
_controller.dispose();
super.dispose();
}
// 지점 추가 후 스크롤 처리 (branchControllers 기반)
void _scrollToAddedBranchCard() {
if (_controller.branchControllers.isEmpty ||
!_controller.scrollController.hasClients) {
return;
}
// 추가 버튼 위치까지 스크롤 - 지점 추가 버튼이 있는 위치를 계산하여 그 위치로 스크롤
final double additionalOffset = 80.0;
final maxPos = _controller.scrollController.position.maxScrollExtent;
final currentPos = _controller.scrollController.position.pixels;
final targetPos = math.min(currentPos + additionalOffset, maxPos - 20.0);
_controller.scrollController.animateTo(
targetPos,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutQuad,
);
}
// 지점 추가
void _addBranch() {
setState(() {
_controller.addBranch();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 100), () {
_scrollToAddedBranchCard();
Future.delayed(const Duration(milliseconds: 300), () {
// 마지막 지점의 포커스 노드로 포커스 이동
if (_controller.branchControllers.isNotEmpty) {
_controller.branchControllers.last.focusNode.requestFocus();
}
});
});
});
}
// 회사 저장
/// 회사 저장
Future<void> _saveCompany() async {
// 지점 수정 모드일 때의 처리
if (isBranch && branchId != null) {
// 로딩 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
try {
final success = await _controller.saveBranch(branchId!);
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
if (success) {
Navigator.pop(context, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('지점 저장에 실패했습니다.')),
);
}
}
} catch (e) {
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('오류가 발생했습니다: $e')),
);
}
}
if (!_controller.formKey.currentState!.validate()) {
return;
}
// 기존 회사 저장 로직
final duplicateCompany = await _controller.checkDuplicateCompany();
if (duplicateCompany != null) {
DuplicateCompanyDialog.show(context, duplicateCompany);
return;
}
// 주소 업데이트
_controller.updateCompanyAddress(
Address.fromFullAddress(_addressController.text)
);
// 전화번호 합치기
final fullPhoneNumber = PhoneUtils.getFullPhoneNumber(_selectedPhonePrefix, _phoneNumberController.text);
_controller.contactPhoneController.text = fullPhoneNumber;
// 로딩 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
builder: (context) => const Center(child: CircularProgressIndicator()),
);
try {
final success = await _controller.saveCompany();
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
if (success) {
// 성공 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(companyId != null ? '회사 정보가 수정되었습니다.' : '회사가 등록되었습니다.'),
backgroundColor: Colors.green,
),
);
// 리스트 화면으로 돌아가기
Navigator.pop(context, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
@@ -320,215 +137,264 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
@override
Widget build(BuildContext context) {
final isEditMode = companyId != null;
final String title =
isBranch
? '${mainCompanyName ?? ''} 지점 정보 수정'
: (isEditMode ? '회사 정보 수정' : '회사 등록');
final String nameLabel = isBranch ? '지점명' : '회사명';
final String nameHint = isBranch ? '지점명을 입력하세요' : '회사명을 입력하세요';
final title = isEditMode ? '회사 정보 수정' : '회사 등록';
// 지점 수정 모드일 때는 BranchFormWidget만 단독 노출
if (isBranch && branchId != null) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
child: BranchFormWidget(
controller: _controller.branchControllers[0],
index: 0,
onRemove: null,
onAddressChanged: (address) {
setState(() {
_controller.updateBranchAddress(0, address);
});
},
),
// 회사 유형 선택
FormFieldWrapper(
label: "회사 유형",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckboxListTile(
title: const Text('고객사'),
value: _controller.selectedCompanyTypes.contains(CompanyType.customer),
onChanged: (checked) {
setState(() {
_controller.toggleCompanyType(CompanyType.customer, checked ?? false);
});
},
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('파트너사'),
value: _controller.selectedCompanyTypes.contains(CompanyType.partner),
onChanged: (checked) {
setState(() {
_controller.toggleCompanyType(CompanyType.partner, checked ?? false);
});
},
contentPadding: EdgeInsets.zero,
),
],
),
),
// 저장 버튼
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ElevatedButton(
onPressed: _saveCompany,
style: ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'수정 완료',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 16),
// 회사명 (필수)
FormFieldWrapper(
label: "회사명 *",
child: TextFormField(
controller: _controller.nameController,
decoration: const InputDecoration(
hintText: '회사명을 입력하세요',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '회사명을 입력하세요';
}
if (value.trim().length < 2) {
return '회사명은 2자 이상 입력하세요';
}
return null;
},
textInputAction: TextInputAction.next,
),
),
],
),
),
),
);
}
// ... 기존 본사/신규 등록 모드 렌더링
return GestureDetector(
onTap: () {
setState(() {
if (_controller.showCompanyNameDropdown) {
_controller.showCompanyNameDropdown = false;
}
});
FocusScope.of(context).unfocus();
},
child: Scaffold(
appBar: AppBar(title: Text(title)),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
controller: _controller.scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회사 유형 선택 (체크박스)
CompanyTypeSelector(
selectedTypes: _controller.selectedCompanyTypes,
onTypeChanged: (type, checked) {
setState(() {
_controller.toggleCompanyType(type, checked);
});
},
const SizedBox(height: 16),
// 주소 (선택)
FormFieldWrapper(
label: "주소",
child: TextFormField(
controller: _addressController,
decoration: const InputDecoration(
hintText: '회사 주소를 입력하세요',
border: OutlineInputBorder(),
),
maxLines: 2,
textInputAction: TextInputAction.next,
),
// 회사 기본 정보 헤더 (회사명/지점명 + 주소)
CompanyFormHeader(
nameController: _controller.nameController,
nameFocusNode: _controller.nameFocusNode,
companyNames: _controller.companyNames,
filteredCompanyNames: _controller.filteredCompanyNames,
showCompanyNameDropdown:
_controller.showCompanyNameDropdown,
onCompanyNameSelected: (name) {
setState(() {
_controller.selectCompanyName(name);
});
),
const SizedBox(height: 16),
// 담당자명 (필수)
FormFieldWrapper(
label: "담당자명 *",
child: TextFormField(
controller: _controller.contactNameController,
decoration: const InputDecoration(
hintText: '담당자명을 입력하세요',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '담당자명을 입력하세요';
}
return null;
},
onShowMapPressed: () {
final fullAddress = _controller.companyAddress.toString();
MapDialog.show(context, fullAddress);
},
onNameSaved: (value) {},
onAddressChanged: (address) {
setState(() {
_controller.updateCompanyAddress(address);
});
},
initialAddress: _controller.companyAddress,
nameLabel: nameLabel,
nameHint: nameHint,
remarkController: _controller.remarkController,
textInputAction: TextInputAction.next,
),
// 담당자 정보
ContactInfoForm(
contactNameController: _controller.contactNameController,
contactPositionController:
_controller.contactPositionController,
contactPhoneController: _controller.contactPhoneController,
contactEmailController: _controller.contactEmailController,
positions: _controller.positions,
selectedPhonePrefix: _controller.selectedPhonePrefix,
phonePrefixes: _controller.phonePrefixes,
onPhonePrefixChanged: (value) {
setState(() {
_controller.selectedPhonePrefix = value;
});
},
onNameSaved: (value) {},
onPositionSaved: (value) {},
onPhoneSaved: (value) {},
onEmailSaved: (value) {},
),
const SizedBox(height: 16),
// 담당자 직급 (선택)
FormFieldWrapper(
label: "담당자 직급",
child: TextFormField(
controller: _controller.contactPositionController,
decoration: const InputDecoration(
hintText: '담당자 직급을 입력하세요',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
// 지점 정보(하단) 및 +지점추가 버튼은 본사/신규 등록일 때만 노출
if (!(isBranch && branchId != null)) ...[
if (_controller.branchControllers.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
child: Text(
'지점 정보',
style: ShadcnTheme.headingH6,
),
const SizedBox(height: 16),
// 담당자 연락처 (필수) - 사용자 폼과 동일한 패턴
FormFieldWrapper(
label: "담당자 연락처 *",
child: Row(
children: [
// 접두사 드롭다운 (010, 02, 031 등)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: DropdownButton<String>(
value: _selectedPhonePrefix,
items: _phonePrefixes.map((prefix) {
return DropdownMenuItem(
value: prefix,
child: Text(prefix),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedPhonePrefix = value;
});
}
},
underline: Container(), // 밑줄 제거
),
),
if (_controller.branchControllers.isNotEmpty)
for (
int i = 0;
i < _controller.branchControllers.length;
i++
)
BranchFormWidget(
controller: _controller.branchControllers[i],
index: i,
onRemove: () {
setState(() {
_controller.removeBranch(i);
});
},
onAddressChanged: (address) {
setState(() {
_controller.updateBranchAddress(i, address);
});
},
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: ElevatedButton.icon(
onPressed: _addBranch,
icon: const Icon(Icons.add),
label: const Text('지점 추가'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
const SizedBox(width: 8),
const Text('-', style: TextStyle(fontSize: 16)),
const SizedBox(width: 8),
// 전화번호 입력 (7-8자리)
Expanded(
child: TextFormField(
controller: _phoneNumberController,
decoration: const InputDecoration(
hintText: '1234-5678',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
TextInputFormatter.withFunction((oldValue, newValue) {
final formatted = PhoneUtils.formatPhoneNumberByPrefix(
_selectedPhonePrefix,
newValue.text,
);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}),
],
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '전화번호를 입력하세요';
}
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
if (digitsOnly.length < 7) {
return '전화번호는 7-8자리 숫자를 입력해주세요';
}
return null;
},
textInputAction: TextInputAction.next,
),
),
],
),
),
const SizedBox(height: 16),
// 담당자 이메일 (필수)
FormFieldWrapper(
label: "담당자 이메일 *",
child: TextFormField(
controller: _controller.contactEmailController,
decoration: const InputDecoration(
hintText: 'example@company.com',
border: OutlineInputBorder(),
),
],
// 저장 버튼 추가
Padding(
padding: const EdgeInsets.only(top: 24.0, bottom: 16.0),
child: ElevatedButton(
onPressed: _saveCompany,
style: ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
isEditMode ? '수정 완료' : '등록 완료',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '담당자 이메일을 입력하세요';
}
return validateEmail(value);
},
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// 비고 (선택)
FormFieldWrapper(
label: "비고",
child: TextFormField(
controller: _controller.remarkController,
decoration: const InputDecoration(
hintText: '추가 정보나 메모를 입력하세요',
border: OutlineInputBorder(),
),
maxLines: 3,
textInputAction: TextInputAction.done,
),
),
const SizedBox(height: 32),
// 저장 버튼
ElevatedButton(
onPressed: _saveCompany,
style: ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
],
),
child: Text(
isEditMode ? '수정 완료' : '등록 완료',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 16),
],
),
),
),
),
);
}
}
}

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'dart:async';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/company_item_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
@@ -104,18 +105,38 @@ class _CompanyListState extends State<CompanyList> {
);
}
/// Branch 객체를 Company 객체로 변환
Company _convertBranchToCompany(Branch branch) {
return Company(
id: branch.id,
name: branch.name,
address: branch.address,
contactName: branch.contactName,
contactPosition: branch.contactPosition,
contactPhone: branch.contactPhone,
contactEmail: branch.contactEmail,
companyTypes: [],
remark: branch.remark,
/// 지점 삭제 처리
void _deleteBranch(int companyId, int branchId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('지점 삭제 확인'),
content: const Text('이 지점 정보를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await _controller.deleteBranch(companyId, branchId);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('삭제'),
),
],
),
);
}
@@ -177,23 +198,161 @@ class _CompanyListState extends State<CompanyList> {
);
}
/// 회사 이름 표시 (지점인 경우 본사명 포함)
Widget _buildCompanyNameText(
Company company,
bool isBranch, {
String? mainCompanyName,
}) {
if (isBranch && mainCompanyName != null) {
/// CompanyItem의 계층적 이름 표시
Widget _buildDisplayNameText(CompanyItem item) {
if (item.isBranch) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: '$mainCompanyName > ', style: ShadcnTheme.bodyMuted),
TextSpan(text: company.name, style: ShadcnTheme.bodyMedium),
TextSpan(text: '${item.parentCompanyName} > ', style: ShadcnTheme.bodyMuted),
TextSpan(text: item.name, style: ShadcnTheme.bodyMedium),
],
),
);
} else {
return Text(company.name, style: ShadcnTheme.bodyMedium);
return Text(item.name, style: ShadcnTheme.bodyMedium);
}
}
/// 활성 상태 배지 생성
Widget _buildStatusBadge(bool isActive) {
return ShadcnBadge(
text: isActive ? '활성' : '비활성',
variant: isActive
? ShadcnBadgeVariant.success
: ShadcnBadgeVariant.secondary,
size: ShadcnBadgeSize.small,
);
}
/// 날짜 포맷팅
String _formatDate(DateTime? date) {
if (date == null) return '-';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 담당자 정보 통합 표시 (이름 + 직책)
Widget _buildContactInfo(CompanyItem item) {
final name = item.contactName ?? '-';
final position = item.contactPosition;
if (position != null && position.isNotEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
style: ShadcnTheme.bodySmall.copyWith(fontWeight: FontWeight.w500),
),
Text(
position,
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
fontSize: 11,
),
),
],
);
} else {
return Text(name, style: ShadcnTheme.bodySmall);
}
}
/// 연락처 정보 통합 표시 (전화 + 이메일)
Widget _buildContactDetails(CompanyItem item) {
final phone = item.contactPhone ?? '-';
final email = item.contactEmail;
if (email != null && email.isNotEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
phone,
style: ShadcnTheme.bodySmall,
),
Text(
email,
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
fontSize: 11,
),
),
],
);
} else {
return Text(phone, style: ShadcnTheme.bodySmall);
}
}
/// 파트너/고객 플래그 표시
Widget _buildPartnerCustomerFlags(CompanyItem item) {
if (item.isBranch) {
return Text('-', style: ShadcnTheme.bodySmall);
}
final flags = <Widget>[];
if (item.isPartner) {
flags.add(ShadcnBadge(
text: '파트너',
variant: ShadcnBadgeVariant.companyPartner,
size: ShadcnBadgeSize.small,
));
}
if (item.isCustomer) {
flags.add(ShadcnBadge(
text: '고객',
variant: ShadcnBadgeVariant.companyCustomer,
size: ShadcnBadgeSize.small,
));
}
if (flags.isEmpty) {
return Text('-', style: ShadcnTheme.bodySmall);
}
return Wrap(
spacing: 4,
runSpacing: 2,
children: flags,
);
}
/// 등록일/수정일 표시
Widget _buildDateInfo(CompanyItem item) {
final createdAt = item.createdAt;
final updatedAt = item.updatedAt;
if (createdAt == null) {
return Text('-', style: ShadcnTheme.bodySmall);
}
final created = _formatDate(createdAt);
if (updatedAt != null && updatedAt != createdAt) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'등록: $created',
style: ShadcnTheme.bodySmall,
),
Text(
'수정: ${_formatDate(updatedAt)}',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
fontSize: 11,
),
),
],
);
} else {
return Text(created, style: ShadcnTheme.bodySmall);
}
}
@@ -203,38 +362,18 @@ class _CompanyListState extends State<CompanyList> {
value: _controller,
child: Consumer<CompanyListController>(
builder: (context, controller, child) {
// 본사와 지점 구분하기 위한 데이터 준비
final List<Map<String, dynamic>> displayCompanies = [];
for (final company in controller.filteredCompanies) {
displayCompanies.add({
'company': company,
'isBranch': false,
'mainCompanyName': null,
});
if (company.branches != null) {
for (final branch in company.branches!) {
displayCompanies.add({
'branch': branch,
'companyId': company.id,
'isBranch': true,
'mainCompanyName': company.name,
});
}
}
}
// Controller가 이미 페이지크된 데이터를 제공
final List<Map<String, dynamic>> pagedCompanies = displayCompanies;
final int totalCount = controller.total; // 실제 전체 개수 사용
// CompanyItem 데이터 직접 사용 (복잡한 변환 로직 제거)
final companyItems = controller.companyItems;
final int totalCount = controller.total;
print('🔍 [VIEW DEBUG] 페이지네이션 상태');
print(' • Controller items: ${controller.companies.length}');
print('🔍 [VIEW DEBUG] CompanyItem 페이지네이션 상태');
print(' • CompanyItem items: ${controller.companyItems.length}');
print(' • 전체 개수: ${controller.total}');
print(' • 현재 페이지: ${controller.currentPage}');
print(' • 페이지 크기: ${controller.pageSize}');
// 로딩 상태
if (controller.isLoading && controller.companies.isEmpty) {
if (controller.isLoading && controller.companyItems.isEmpty) {
return const StandardLoadingState(message: '회사 데이터를 불러오는 중...');
}
@@ -306,7 +445,7 @@ class _CompanyListState extends State<CompanyList> {
// 데이터 테이블
dataTable:
displayCompanies.isEmpty
companyItems.isEmpty
? StandardEmptyState(
title:
controller.searchQuery.isNotEmpty
@@ -326,23 +465,19 @@ class _CompanyListState extends State<CompanyList> {
std_table.DataColumn(label: '번호', flex: 1),
std_table.DataColumn(label: '회사명', flex: 3),
std_table.DataColumn(label: '구분', flex: 1),
std_table.DataColumn(label: '유형', flex: 2),
std_table.DataColumn(label: '연락처', flex: 2),
std_table.DataColumn(label: '주소', flex: 3),
std_table.DataColumn(label: '담당자', flex: 2),
std_table.DataColumn(label: '연락처', flex: 3),
std_table.DataColumn(label: '파트너/고객', flex: 2),
std_table.DataColumn(label: '상태', flex: 1),
std_table.DataColumn(label: '등록/수정일', flex: 2),
std_table.DataColumn(label: '비고', flex: 2),
std_table.DataColumn(label: '관리', flex: 2),
],
rows: [
...pagedCompanies.asMap().entries.map((entry) {
...companyItems.asMap().entries.map((entry) {
final int index = ((controller.currentPage - 1) * controller.pageSize) + entry.key;
final companyData = entry.value;
final bool isBranch = companyData['isBranch'] as bool;
final Company company =
isBranch
? _convertBranchToCompany(
companyData['branch'] as Branch,
)
: companyData['company'] as Company;
final String? mainCompanyName =
companyData['mainCompanyName'] as String?;
final CompanyItem item = entry.value;
return std_table.StandardDataRow(
index: index,
@@ -350,8 +485,13 @@ class _CompanyListState extends State<CompanyList> {
std_table.DataColumn(label: '번호', flex: 1),
std_table.DataColumn(label: '회사명', flex: 3),
std_table.DataColumn(label: '구분', flex: 1),
std_table.DataColumn(label: '유형', flex: 2),
std_table.DataColumn(label: '연락처', flex: 2),
std_table.DataColumn(label: '주소', flex: 3),
std_table.DataColumn(label: '담당자', flex: 2),
std_table.DataColumn(label: '연락처', flex: 3),
std_table.DataColumn(label: '파트너/고객', flex: 2),
std_table.DataColumn(label: '상태', flex: 1),
std_table.DataColumn(label: '등록/수정일', flex: 2),
std_table.DataColumn(label: '비고', flex: 2),
std_table.DataColumn(label: '관리', flex: 2),
],
cells: [
@@ -360,82 +500,89 @@ class _CompanyListState extends State<CompanyList> {
'${index + 1}',
style: ShadcnTheme.bodySmall,
),
// 회사명
_buildCompanyNameText(
company,
isBranch,
mainCompanyName: mainCompanyName,
),
// 구분
// 회사명 (계층적 표시)
_buildDisplayNameText(item),
// 구분 (본사/지점 배지)
Align(
alignment: Alignment.centerLeft,
child: _buildCompanyTypeLabel(isBranch),
child: _buildCompanyTypeLabel(item.isBranch),
),
// 유형
Align(
alignment: Alignment.centerLeft,
child: _buildCompanyTypeChips(company.companyTypes),
),
// 연락처
// 주소
Text(
company.contactPhone ?? '-',
item.address.isNotEmpty ? item.address : '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
// 관리
// 담당자 (이름 + 직책)
_buildContactInfo(item),
// 연락처 (전화 + 이메일)
_buildContactDetails(item),
// 파트너/고객 플래그
Align(
alignment: Alignment.centerLeft,
child: _buildPartnerCustomerFlags(item),
),
// 상태
Align(
alignment: Alignment.centerLeft,
child: _buildStatusBadge(item.isActive),
),
// 등록/수정일
_buildDateInfo(item),
// 비고
Text(
item.remark ?? '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
// 관리 (액션 버튼들)
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isBranch &&
company.branches != null &&
company.branches!.isNotEmpty) ...[
ShadcnButton(
text: '지점',
onPressed:
() => _showBranchDialog(company),
variant: ShadcnButtonVariant.ghost,
size: ShadcnButtonSize.small,
),
const SizedBox(width: ShadcnTheme.spacing1),
],
std_table.StandardActionButtons(
onEdit:
company.id != null
? () {
if (isBranch) {
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId':
companyData['companyId'],
'isBranch': true,
'mainCompanyName':
mainCompanyName,
'branchId': company.id,
},
).then((result) {
if (result == true)
controller.refresh();
});
} else {
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId': company.id,
'isBranch': false,
},
).then((result) {
if (result == true)
controller.refresh();
});
}
}
: null,
onDelete:
(!isBranch && company.id != null)
? () => _deleteCompany(company.id!)
: null,
onEdit: item.id != null
? () {
if (item.isBranch) {
// 지점 수정 - 별도 화면으로 이동 (Phase 3에서 구현)
// TODO: Phase 3에서 별도 지점 수정 화면 구현
Navigator.pushNamed(
context,
'/company/branch/edit',
arguments: {
'companyId': item.parentCompanyId,
'branchId': item.id,
'parentCompanyName': item.parentCompanyName,
},
).then((result) {
if (result == true) controller.refresh();
});
} else {
// 본사 수정
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId': item.id,
'isBranch': false,
},
).then((result) {
if (result == true) controller.refresh();
});
}
}
: null,
onDelete: item.id != null
? () {
if (item.isBranch) {
// 지점 삭제
_deleteBranch(item.parentCompanyId!, item.id!);
} else {
// 본사 삭제
_deleteCompany(item.id!);
}
}
: null,
),
],
),

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/core/utils/error_handler.dart';
/// 지점 정보 수정 컨트롤러 (단일 지점 전용)
/// 지점의 기본 정보만 수정할 수 있도록 단순화
class BranchEditFormController extends ChangeNotifier {
final CompanyService _companyService = GetIt.instance<CompanyService>();
// 식별 정보
final int companyId;
final int branchId;
final String parentCompanyName;
// 폼 관련
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// 텍스트 컨트롤러들
final TextEditingController nameController = TextEditingController();
final TextEditingController addressController = TextEditingController();
final TextEditingController managerNameController = TextEditingController();
final TextEditingController managerPhoneController = TextEditingController();
final TextEditingController remarkController = TextEditingController();
// 상태 관리
bool _isLoading = false;
String? _error;
Branch? _originalBranch;
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
Branch? get originalBranch => _originalBranch;
BranchEditFormController({
required this.companyId,
required this.branchId,
required this.parentCompanyName,
});
@override
void dispose() {
nameController.dispose();
addressController.dispose();
managerNameController.dispose();
managerPhoneController.dispose();
remarkController.dispose();
super.dispose();
}
/// 지점 데이터 로드
Future<void> loadBranchData() async {
_setLoading(true);
_clearError();
try {
final branch = await ErrorHandler.handleApiCall(
() => _companyService.getBranchDetail(companyId, branchId),
onError: (failure) {
throw failure;
},
);
if (branch != null) {
_originalBranch = branch;
_populateForm(branch);
} else {
_setError('지점 데이터를 불러올 수 없습니다');
}
} catch (e) {
_setError('지점 정보 로드 실패: ${e.toString()}');
} finally {
_setLoading(false);
}
}
/// 폼에 데이터 설정
void _populateForm(Branch branch) {
nameController.text = branch.name;
addressController.text = branch.address.toString();
managerNameController.text = branch.contactName ?? '';
managerPhoneController.text = branch.contactPhone ?? '';
remarkController.text = branch.remark ?? '';
}
/// 지점 정보 저장
Future<bool> saveBranch() async {
if (!formKey.currentState!.validate()) {
return false;
}
_setLoading(true);
_clearError();
try {
// Branch 객체 생성
final updatedBranch = Branch(
id: branchId,
companyId: companyId,
name: nameController.text.trim(),
address: Address.fromFullAddress(addressController.text.trim()),
contactName: managerNameController.text.trim().isEmpty
? null
: managerNameController.text.trim(),
contactPhone: managerPhoneController.text.trim().isEmpty
? null
: managerPhoneController.text.trim(),
remark: remarkController.text.trim().isEmpty
? null
: remarkController.text.trim(),
);
// API 호출
await ErrorHandler.handleApiCall(
() => _companyService.updateBranch(companyId, branchId, updatedBranch),
onError: (failure) {
throw failure;
},
);
return true;
} catch (e) {
_setError('지점 저장 실패: ${e.toString()}');
return false;
} finally {
_setLoading(false);
}
}
/// 입력 데이터 유효성 검증
bool hasChanges() {
if (_originalBranch == null) return false;
return nameController.text.trim() != _originalBranch!.name ||
addressController.text.trim() != _originalBranch!.address.toString() ||
managerNameController.text.trim() != (_originalBranch!.contactName ?? '') ||
managerPhoneController.text.trim() != (_originalBranch!.contactPhone ?? '') ||
remarkController.text.trim() != (_originalBranch!.remark ?? '');
}
/// 폼 리셋
void resetForm() {
if (_originalBranch != null) {
_populateForm(_originalBranch!);
notifyListeners();
}
}
// Private helper methods
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String error) {
_error = error;
notifyListeners();
}
void _clearError() {
_error = null;
notifyListeners();
}
}

View File

@@ -1,111 +1,90 @@
import 'package:flutter/material.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/utils/phone_utils.dart';
import 'package:superport/models/address_model.dart';
/// 지점(Branch) 폼 컨트롤러
///
/// 각 지점의 상태, 컨트롤러, 포커스, 드롭다운, 전화번호 등 관리를 담당
/// 회사 폼에서 사용되는 지점 관리 컨트롤러
/// 각 지점의 정보를 개별적으로 관리
class BranchFormController {
// 지점 데이터
Branch branch;
// 입력 컨트롤러
final TextEditingController nameController;
final TextEditingController contactNameController;
final TextEditingController contactPositionController;
final TextEditingController contactPhoneController;
final TextEditingController contactEmailController;
final TextEditingController remarkController;
// 포커스 노드
final FocusNode focusNode;
// 카드 키(위젯 식별용)
final GlobalKey cardKey;
// 직책 드롭다운 상태
final ValueNotifier<bool> positionDropdownNotifier;
// 전화번호 접두사
String selectedPhonePrefix;
// 직책 목록(공통 상수로 관리 권장)
Branch _branch;
final List<String> positions;
// 전화번호 접두사 목록(공통 상수로 관리 권장)
final List<String> phonePrefixes;
// 컨트롤러들
final TextEditingController nameController = TextEditingController();
final TextEditingController contactNameController = TextEditingController();
final TextEditingController contactPositionController = TextEditingController();
final TextEditingController contactPhoneController = TextEditingController();
final TextEditingController contactEmailController = TextEditingController();
final TextEditingController remarkController = TextEditingController();
final FocusNode focusNode = FocusNode();
// 전화번호 관련
String selectedPhonePrefix = '010';
BranchFormController({
required this.branch,
required Branch branch,
required this.positions,
required this.phonePrefixes,
}) : nameController = TextEditingController(text: branch.name),
contactNameController = TextEditingController(
text: branch.contactName ?? '',
),
contactPositionController = TextEditingController(
text: branch.contactPosition ?? '',
),
contactPhoneController = TextEditingController(
text: PhoneUtils.extractPhoneNumberWithoutPrefix(
branch.contactPhone ?? '',
phonePrefixes,
),
),
contactEmailController = TextEditingController(
text: branch.contactEmail ?? '',
),
remarkController = TextEditingController(text: branch.remark ?? ''),
focusNode = FocusNode(),
cardKey = GlobalKey(),
positionDropdownNotifier = ValueNotifier<bool>(false),
selectedPhonePrefix = PhoneUtils.extractPhonePrefix(
branch.contactPhone ?? '',
phonePrefixes,
);
}) : _branch = branch {
// 초기값 설정
nameController.text = branch.name;
contactNameController.text = branch.contactName ?? '';
contactPositionController.text = branch.contactPosition ?? '';
contactPhoneController.text = branch.contactPhone ?? '';
contactEmailController.text = branch.contactEmail ?? '';
remarkController.text = branch.remark ?? '';
// 전화번호 접두사 추출
if (branch.contactPhone != null && branch.contactPhone!.isNotEmpty) {
for (String prefix in phonePrefixes) {
if (branch.contactPhone!.startsWith(prefix)) {
selectedPhonePrefix = prefix;
contactPhoneController.text = branch.contactPhone!.substring(prefix.length);
break;
}
}
}
}
/// Branch 객체 getter
Branch get branch => _branch.copyWith(
name: nameController.text.trim(),
contactName: contactNameController.text.trim().isEmpty ? null : contactNameController.text.trim(),
contactPosition: contactPositionController.text.trim().isEmpty ? null : contactPositionController.text.trim(),
contactPhone: contactPhoneController.text.trim().isEmpty ? null : '$selectedPhonePrefix${contactPhoneController.text.trim()}',
contactEmail: contactEmailController.text.trim().isEmpty ? null : contactEmailController.text.trim(),
remark: remarkController.text.trim().isEmpty ? null : remarkController.text.trim(),
);
/// 주소 업데이트
void updateAddress(Address address) {
branch = branch.copyWith(address: address);
_branch = _branch.copyWith(address: address);
}
/// 필드별 값 업데이트
void updateField(String fieldName, String value) {
switch (fieldName) {
/// 필드 업데이트
void updateField(String field, String value) {
switch (field) {
case 'name':
branch = branch.copyWith(name: value);
nameController.text = value;
break;
case 'contactName':
branch = branch.copyWith(contactName: value);
contactNameController.text = value;
break;
case 'contactPosition':
branch = branch.copyWith(contactPosition: value);
contactPositionController.text = value;
break;
case 'contactPhone':
branch = branch.copyWith(
contactPhone: PhoneUtils.getFullPhoneNumber(
selectedPhonePrefix,
value,
),
);
contactPhoneController.text = value;
break;
case 'contactEmail':
branch = branch.copyWith(contactEmail: value);
contactEmailController.text = value;
break;
case 'remark':
branch = branch.copyWith(remark: value);
remarkController.text = value;
break;
}
}
/// 전화번호 접두사 변경
void updatePhonePrefix(String prefix) {
selectedPhonePrefix = prefix;
branch = branch.copyWith(
contactPhone: PhoneUtils.getFullPhoneNumber(
prefix,
contactPhoneController.text,
),
);
}
/// 리소스 해제
void dispose() {
nameController.dispose();
@@ -115,7 +94,5 @@ class BranchFormController {
contactEmailController.dispose();
remarkController.dispose();
focusNode.dispose();
positionDropdownNotifier.dispose();
// cardKey는 위젯에서 자동 관리
}
}
}

View File

@@ -15,6 +15,7 @@ import 'package:superport/models/company_model.dart';
// import 'package:superport/services/mock_data_service.dart'; // Mock 서비스 제거
import 'package:superport/services/company_service.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/utils/phone_utils.dart';
import 'dart:async';
import 'branch_form_controller.dart'; // 분리된 지점 컨트롤러 import
@@ -109,7 +110,10 @@ class CompanyFormController {
// 회사 데이터 로드 (수정 모드)
Future<void> loadCompanyData() async {
if (companyId == null) return;
if (companyId == null) {
debugPrint('❌ companyId가 null입니다');
return;
}
debugPrint('📝 loadCompanyData 시작 - ID: $companyId');
@@ -119,12 +123,18 @@ class CompanyFormController {
if (_useApi) {
debugPrint('📝 API에서 회사 정보 로드 중...');
company = await _companyService.getCompanyDetail(companyId!);
debugPrint('📝 API 응답 받음: ${company != null ? "성공" : "null"}');
} else {
debugPrint('📝 API만 사용 가능');
throw Exception('API를 통해만 데이터를 로드할 수 있습니다');
}
debugPrint('📝 로드된 회사: $company');
debugPrint('📝 로드된 회사 정보:');
debugPrint(' - ID: ${company?.id}');
debugPrint(' - 이름: ${company?.name}');
debugPrint(' - 담당자: ${company?.contactName}');
debugPrint(' - 연락처: ${company?.contactPhone}');
debugPrint(' - 이메일: ${company?.contactEmail}');
if (company != null) {
// 폼 필드에 데이터 설정
@@ -157,10 +167,12 @@ class CompanyFormController {
company.contactPhone!,
phonePrefixes,
);
debugPrint('📝 전화번호 설정: $selectedPhonePrefix-${contactPhoneController.text}');
}
// 회사 유형 설정
selectedCompanyTypes = company.companyTypes;
selectedCompanyTypes = List.from(company.companyTypes);
debugPrint('📝 회사 유형 설정: $selectedCompanyTypes');
// 지점 정보 설정
if (company.branches != null && company.branches!.isNotEmpty) {
@@ -174,16 +186,33 @@ class CompanyFormController {
),
);
}
debugPrint('📝 지점 설정 완료: ${branchControllers.length}');
}
debugPrint('📝 폼 필드 설정 완료:');
debugPrint(' - 회사명: ${nameController.text}');
debugPrint(' - 담당자: ${contactNameController.text}');
debugPrint(' - 이메일: ${contactEmailController.text}');
debugPrint(' - 회사명: "${nameController.text}"');
debugPrint(' - 담당자: "${contactNameController.text}"');
debugPrint(' - 이메일: "${contactEmailController.text}"');
debugPrint(' - 전화번호: "$selectedPhonePrefix-${contactPhoneController.text}"');
debugPrint(' - 지점 수: ${branchControllers.length}');
debugPrint(' - 회사 유형: $selectedCompanyTypes');
// 강제로 TextEditingController 리스너 트리거
nameController.notifyListeners();
contactNameController.notifyListeners();
contactPositionController.notifyListeners();
contactEmailController.notifyListeners();
contactPhoneController.notifyListeners();
remarkController.notifyListeners();
debugPrint('✅ 폼 데이터 로드 완료');
} else {
debugPrint('❌ 회사 정보가 null입니다');
}
} catch (e) {
} catch (e, stackTrace) {
debugPrint('❌ 회사 정보 로드 실패: $e');
debugPrint('❌ 스택 트레이스: $stackTrace');
rethrow;
}
}
@@ -325,6 +354,8 @@ class CompanyFormController {
? null
: branchControllers.map((c) => c.branch).toList(),
companyTypes: List.from(selectedCompanyTypes), // 복수 유형 저장
isPartner: selectedCompanyTypes.contains(CompanyType.partner),
isCustomer: selectedCompanyTypes.contains(CompanyType.customer),
);
if (_useApi) {

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/company_item_model.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/core/utils/error_handler.dart';
import 'package:superport/core/controllers/base_list_controller.dart';
@@ -8,7 +9,8 @@ import 'package:superport/data/models/common/pagination_params.dart';
/// 회사 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
class CompanyListController extends BaseListController<Company> {
/// CompanyItem 모델을 사용하여 회사와 지점을 통합 관리
class CompanyListController extends BaseListController<CompanyItem> {
late final CompanyService _companyService;
// 추가 상태 관리
@@ -20,8 +22,14 @@ class CompanyListController extends BaseListController<Company> {
bool _includeInactive = false; // 비활성 회사 포함 여부
// Getters
List<Company> get companies => items;
List<Company> get filteredCompanies => items;
List<CompanyItem> get companyItems => items;
List<CompanyItem> get filteredCompanyItems => items;
// 호환성을 위한 기존 getter (deprecated, 사용하지 말 것)
@deprecated
List<Company> get companies => items.where((item) => !item.isBranch).map((item) => item.company!).toList();
@deprecated
List<Company> get filteredCompanies => companies;
bool? get isActiveFilter => _isActiveFilter;
CompanyType? get typeFilter => _typeFilter;
bool get includeInactive => _includeInactive;
@@ -46,17 +54,16 @@ class CompanyListController extends BaseListController<Company> {
}
@override
Future<PagedResult<Company>> fetchData({
Future<PagedResult<CompanyItem>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// API 호출 - 회사 목록 조회 (PaginatedResponse 반환)
// API 호출 - 회사 목록 조회 (모든 필드 포함)
final response = await ErrorHandler.handleApiCall(
() => _companyService.getCompanies(
page: params.page,
perPage: params.perPage,
search: params.search,
isActive: _isActiveFilter,
includeInactive: _includeInactive,
),
onError: (failure) {
@@ -78,7 +85,10 @@ class CompanyListController extends BaseListController<Company> {
);
}
// PaginatedResponse를 PagedResult로 변환
// Company 리스트를 CompanyItem 리스트로 변환 (본사만, 지점은 제외)
final companyItems = response.items.map((company) => CompanyItem.headquarters(company)).toList();
// 서버에서 이미 페이지네이션 및 필터링이 완료된 데이터 사용
final meta = PaginationMeta(
currentPage: response.page,
perPage: response.size,
@@ -88,17 +98,40 @@ class CompanyListController extends BaseListController<Company> {
hasPrevious: !response.first,
);
return PagedResult(items: response.items, meta: meta);
return PagedResult(items: companyItems, meta: meta);
}
// 더 이상 사용하지 않는 메서드 - getCompanies() API는 지점 정보를 포함하지 않음
// /// Company 리스트를 CompanyItem 리스트로 확장 (본사 + 지점)
// List<CompanyItem> _expandCompaniesAndBranches(List<Company> companies) {
// final List<CompanyItem> items = [];
//
// for (final company in companies) {
// // 1. 본사 추가
// items.add(CompanyItem.headquarters(company));
//
// // 2. 지점들 추가
// if (company.branches != null && company.branches!.isNotEmpty) {
// for (final branch in company.branches!) {
// items.add(CompanyItem.branch(branch, company.name, company.id!));
// }
// }
// }
//
// return items;
// }
@override
bool filterItem(Company item, String query) {
bool filterItem(CompanyItem item, String query) {
final q = query.toLowerCase();
return item.name.toLowerCase().contains(q) ||
item.displayName.toLowerCase().contains(q) ||
(item.contactPhone?.toLowerCase().contains(q) ?? false) ||
(item.contactEmail?.toLowerCase().contains(q) ?? false) ||
(item.companyTypes.any((type) => type.name.toLowerCase().contains(q))) ||
(item.address.toString().toLowerCase().contains(q));
(item.address.toLowerCase().contains(q)) ||
(item.contactName?.toLowerCase().contains(q) ?? false) ||
(item.parentCompanyName?.toLowerCase().contains(q) ?? false);
}
// 회사 선택/선택 해제
@@ -157,7 +190,45 @@ class CompanyListController extends BaseListController<Company> {
},
);
updateItemLocally(company, (c) => c.id == company.id);
// CompanyItem에서 해당 회사 업데이트
// TODO: 지역적 업데이트 대신 전체 새로고침 사용
await refresh();
}
// 지점 추가
Future<void> addBranch(int companyId, Branch branch) async {
await ErrorHandler.handleApiCall<void>(
() => _companyService.createBranch(companyId, branch),
onError: (failure) {
throw failure;
},
);
await refresh();
}
// 지점 수정
Future<void> updateBranch(int companyId, int branchId, Branch branch) async {
await ErrorHandler.handleApiCall<void>(
() => _companyService.updateBranch(companyId, branchId, branch),
onError: (failure) {
throw failure;
},
);
await refresh();
}
// 지점 삭제
Future<void> deleteBranch(int companyId, int branchId) async {
await ErrorHandler.handleApiCall<void>(
() => _companyService.deleteBranch(companyId, branchId),
onError: (failure) {
throw failure;
},
);
await refresh();
}
// 회사 삭제

View File

@@ -1,141 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/screens/company/widgets/contact_info_widget.dart';
import 'package:superport/utils/validators.dart';
import 'package:superport/utils/phone_utils.dart';
class BranchCard extends StatefulWidget {
final GlobalKey cardKey;
final int index;
final Branch branch;
final TextEditingController nameController;
final TextEditingController contactNameController;
final TextEditingController contactPositionController;
final TextEditingController contactPhoneController;
final TextEditingController contactEmailController;
final FocusNode focusNode;
final List<String> positions;
final List<String> phonePrefixes;
final String selectedPhonePrefix;
final ValueChanged<String> onNameChanged;
final ValueChanged<Address> onAddressChanged;
final ValueChanged<String> onContactNameChanged;
final ValueChanged<String> onContactPositionChanged;
final ValueChanged<String> onContactPhoneChanged;
final ValueChanged<String> onContactEmailChanged;
final ValueChanged<String> onPhonePrefixChanged;
final VoidCallback onDelete;
const BranchCard({
Key? key,
required this.cardKey,
required this.index,
required this.branch,
required this.nameController,
required this.contactNameController,
required this.contactPositionController,
required this.contactPhoneController,
required this.contactEmailController,
required this.focusNode,
required this.positions,
required this.phonePrefixes,
required this.selectedPhonePrefix,
required this.onNameChanged,
required this.onAddressChanged,
required this.onContactNameChanged,
required this.onContactPositionChanged,
required this.onContactPhoneChanged,
required this.onContactEmailChanged,
required this.onPhonePrefixChanged,
required this.onDelete,
}) : super(key: key);
@override
_BranchCardState createState() => _BranchCardState();
}
class _BranchCardState extends State<BranchCard> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// 화면의 빈 공간 터치 시 포커스 해제
FocusScope.of(context).unfocus();
},
child: Card(
key: widget.cardKey,
margin: const EdgeInsets.only(bottom: 16.0),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'지점 #${widget.index + 1}',
style: ShadcnTheme.headingH6,
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: widget.onDelete,
),
],
),
const SizedBox(height: 8),
FormFieldWrapper(
label: '지점명',
isRequired: true,
child: TextFormField(
controller: widget.nameController,
focusNode: widget.focusNode,
decoration: const InputDecoration(hintText: '지점명을 입력하세요'),
onChanged: widget.onNameChanged,
validator: FormValidator.required('지점명은 필수입니다'),
),
),
AddressInput(
initialZipCode: widget.branch.address.zipCode,
initialRegion: widget.branch.address.region,
initialDetailAddress: widget.branch.address.detailAddress,
onAddressChanged: (zipCode, region, detailAddress) {
final address = Address(
zipCode: zipCode,
region: region,
detailAddress: detailAddress,
);
widget.onAddressChanged(address);
},
),
// 담당자 정보 - ContactInfoWidget 사용
ContactInfoWidget(
title: '담당자 정보',
contactNameController: widget.contactNameController,
contactPositionController: widget.contactPositionController,
contactPhoneController: widget.contactPhoneController,
contactEmailController: widget.contactEmailController,
positions: widget.positions,
selectedPhonePrefix: widget.selectedPhonePrefix,
phonePrefixes: widget.phonePrefixes,
onPhonePrefixChanged: widget.onPhonePrefixChanged,
onContactNameChanged: widget.onContactNameChanged,
onContactPositionChanged: widget.onContactPositionChanged,
onContactPhoneChanged: widget.onContactPhoneChanged,
onContactEmailChanged: widget.onContactEmailChanged,
compactMode: false, // compactMode를 false로 변경하여 한 줄로 표시
),
],
),
),
),
);
}
}

View File

@@ -1,112 +0,0 @@
import 'package:flutter/material.dart';
import '../controllers/branch_form_controller.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/company/widgets/contact_info_form.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
/// 지점 입력 폼 위젯
///
/// BranchFormController를 받아서 입력 필드, 드롭다운, 포커스, 전화번호 등 UI/상태를 관리한다.
class BranchFormWidget extends StatelessWidget {
final BranchFormController controller;
final int index;
final void Function()? onRemove;
final void Function(Address)? onAddressChanged;
const BranchFormWidget({
Key? key,
required this.controller,
required this.index,
this.onRemove,
this.onAddressChanged,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
key: controller.cardKey,
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: controller.nameController,
focusNode: controller.focusNode,
decoration: const InputDecoration(labelText: '지점명'),
onChanged: (value) => controller.updateField('name', value),
),
),
if (onRemove != null)
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: onRemove,
),
],
),
const SizedBox(height: 8),
// 주소 입력: 회사와 동일한 AddressInput 위젯 사용
AddressInput(
initialZipCode: controller.branch.address.zipCode,
initialRegion: controller.branch.address.region,
initialDetailAddress: controller.branch.address.detailAddress,
isRequired: false,
onAddressChanged: (zipCode, region, detailAddress) {
controller.updateAddress(
Address(
zipCode: zipCode,
region: region,
detailAddress: detailAddress,
),
);
if (onAddressChanged != null) {
onAddressChanged!(
Address(
zipCode: zipCode,
region: region,
detailAddress: detailAddress,
),
);
}
},
),
const SizedBox(height: 8),
// 담당자 정보 입력: ContactInfoForm 위젯으로 대체 (회사 담당자와 동일 UI)
ContactInfoForm(
contactNameController: controller.contactNameController,
contactPositionController: controller.contactPositionController,
contactPhoneController: controller.contactPhoneController,
contactEmailController: controller.contactEmailController,
positions: controller.positions,
selectedPhonePrefix: controller.selectedPhonePrefix,
phonePrefixes: controller.phonePrefixes,
onPhonePrefixChanged: (value) {
controller.updatePhonePrefix(value);
},
onNameSaved: (value) {
controller.updateField('contactName', value ?? '');
},
onPositionSaved: (value) {
controller.updateField('contactPosition', value ?? '');
},
onPhoneSaved: (value) {
controller.updateField('contactPhone', value ?? '');
},
onEmailSaved: (value) {
controller.updateField('contactEmail', value ?? '');
},
),
const SizedBox(height: 8),
// 비고 입력란
RemarkInput(controller: controller.remarkController),
],
),
),
);
}
}

View File

@@ -1,58 +0,0 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/company/widgets/contact_info_widget.dart';
/// 담당자 정보 폼
///
/// 회사 등록 및 수정 화면에서 사용되는 담당자 정보 입력 폼
/// 내부적으로 공통 ContactInfoWidget을 사용하여 코드 재사용성 확보
class ContactInfoForm extends StatelessWidget {
final TextEditingController contactNameController;
final TextEditingController contactPositionController;
final TextEditingController contactPhoneController;
final TextEditingController contactEmailController;
final List<String> positions;
final String selectedPhonePrefix;
final List<String> phonePrefixes;
final ValueChanged<String> onPhonePrefixChanged;
final ValueChanged<String?> onNameSaved;
final ValueChanged<String?> onPositionSaved;
final ValueChanged<String?> onPhoneSaved;
final ValueChanged<String?> onEmailSaved;
const ContactInfoForm({
Key? key,
required this.contactNameController,
required this.contactPositionController,
required this.contactPhoneController,
required this.contactEmailController,
required this.positions,
required this.selectedPhonePrefix,
required this.phonePrefixes,
required this.onPhonePrefixChanged,
required this.onNameSaved,
required this.onPositionSaved,
required this.onPhoneSaved,
required this.onEmailSaved,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// ContactInfoWidget을 사용하여 담당자 정보 UI 구성
return ContactInfoWidget(
contactNameController: contactNameController,
contactPositionController: contactPositionController,
contactPhoneController: contactPhoneController,
contactEmailController: contactEmailController,
positions: positions,
selectedPhonePrefix: selectedPhonePrefix,
phonePrefixes: phonePrefixes,
onPhonePrefixChanged: onPhonePrefixChanged,
// 각 콜백 함수를 ContactInfoWidget의 onChanged 콜백과 연결
onContactNameChanged: (value) => onNameSaved?.call(value),
onContactPositionChanged: (value) => onPositionSaved?.call(value),
onContactPhoneChanged: (value) => onPhoneSaved?.call(value),
onContactEmailChanged: (value) => onEmailSaved?.call(value),
);
}
}

View File

@@ -1,722 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:developer' as developer;
import 'package:superport/screens/common/custom_widgets.dart';
import 'package:superport/utils/validators.dart';
import 'package:superport/utils/phone_utils.dart';
import 'dart:math' as math;
/// 담당자 정보 위젯
///
/// 회사 및 지점의 담당자 정보를 입력받는 공통 위젯
/// SRP(단일 책임 원칙)에 따라 담당자 정보 입력 로직을 분리
class ContactInfoWidget extends StatefulWidget {
/// 위젯 제목
final String title;
/// 담당자 이름 컨트롤러
final TextEditingController contactNameController;
/// 담당자 직책 컨트롤러
final TextEditingController contactPositionController;
/// 담당자 전화번호 컨트롤러
final TextEditingController contactPhoneController;
/// 담당자 이메일 컨트롤러
final TextEditingController contactEmailController;
/// 직책 목록
final List<String> positions;
/// 선택된 전화번호 접두사
final String selectedPhonePrefix;
/// 전화번호 접두사 목록
final List<String> phonePrefixes;
/// 직책 컴팩트 모드 (Row 또는 Column 레이아웃 결정)
final bool compactMode;
/// 전화번호 접두사 변경 콜백
final ValueChanged<String> onPhonePrefixChanged;
/// 담당자 이름 변경 콜백
final ValueChanged<String> onContactNameChanged;
/// 담당자 직책 변경 콜백
final ValueChanged<String> onContactPositionChanged;
/// 담당자 전화번호 변경 콜백
final ValueChanged<String> onContactPhoneChanged;
/// 담당자 이메일 변경 콜백
final ValueChanged<String> onContactEmailChanged;
const ContactInfoWidget({
Key? key,
this.title = '담당자 정보',
required this.contactNameController,
required this.contactPositionController,
required this.contactPhoneController,
required this.contactEmailController,
required this.positions,
required this.selectedPhonePrefix,
required this.phonePrefixes,
required this.onPhonePrefixChanged,
required this.onContactNameChanged,
required this.onContactPositionChanged,
required this.onContactPhoneChanged,
required this.onContactEmailChanged,
this.compactMode = false,
}) : super(key: key);
@override
State<ContactInfoWidget> createState() => _ContactInfoWidgetState();
}
class _ContactInfoWidgetState extends State<ContactInfoWidget> {
bool _showPositionDropdown = false;
bool _showPhonePrefixDropdown = false;
final LayerLink _positionLayerLink = LayerLink();
final LayerLink _phonePrefixLayerLink = LayerLink();
OverlayEntry? _positionOverlayEntry;
OverlayEntry? _phonePrefixOverlayEntry;
final FocusNode _positionFocusNode = FocusNode();
final FocusNode _phonePrefixFocusNode = FocusNode();
@override
void initState() {
super.initState();
developer.log('ContactInfoWidget 초기화 완료', name: 'ContactInfoWidget');
_positionFocusNode.addListener(() {
if (_positionFocusNode.hasFocus) {
developer.log('직책 필드 포커스 얻음', name: 'ContactInfoWidget');
} else {
developer.log('직책 필드 포커스 잃음', name: 'ContactInfoWidget');
}
});
_phonePrefixFocusNode.addListener(() {
if (_phonePrefixFocusNode.hasFocus) {
developer.log('전화번호 접두사 필드 포커스 얻음', name: 'ContactInfoWidget');
} else {
developer.log('전화번호 접두사 필드 포커스 잃음', name: 'ContactInfoWidget');
}
});
}
@override
void dispose() {
_removeAllOverlays();
_positionFocusNode.dispose();
_phonePrefixFocusNode.dispose();
super.dispose();
}
void _togglePositionDropdown() {
developer.log(
'직책 드롭다운 토글: $_showPositionDropdown -> ${!_showPositionDropdown}',
name: 'ContactInfoWidget',
);
setState(() {
if (_showPositionDropdown) {
_removePositionOverlay();
} else {
_showPositionDropdown = true;
_showPhonePrefixDropdown = false;
_removePhonePrefixOverlay();
_showPositionOverlay();
}
});
}
void _togglePhonePrefixDropdown() {
developer.log(
'전화번호 접두사 드롭다운 토글: $_showPhonePrefixDropdown -> ${!_showPhonePrefixDropdown}',
name: 'ContactInfoWidget',
);
setState(() {
if (_showPhonePrefixDropdown) {
_removePhonePrefixOverlay();
} else {
_showPhonePrefixDropdown = true;
_showPositionDropdown = false;
_removePositionOverlay();
_showPhonePrefixOverlay();
}
});
}
void _removePositionOverlay() {
_positionOverlayEntry?.remove();
_positionOverlayEntry = null;
_showPositionDropdown = false;
}
void _removePhonePrefixOverlay() {
_phonePrefixOverlayEntry?.remove();
_phonePrefixOverlayEntry = null;
_showPhonePrefixDropdown = false;
}
void _removeAllOverlays() {
_removePositionOverlay();
_removePhonePrefixOverlay();
}
void _closeAllDropdowns() {
if (_showPositionDropdown || _showPhonePrefixDropdown) {
developer.log('모든 드롭다운 닫기', name: 'ContactInfoWidget');
setState(() {
_removeAllOverlays();
});
}
}
void _showPositionOverlay() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final availableHeight =
MediaQuery.of(context).size.height - offset.dy - 100;
final maxHeight = math.min(300.0, availableHeight);
_positionOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: 200,
child: CompositedTransformFollower(
link: _positionLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
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),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...widget.positions.map(
(position) => InkWell(
onTap: () {
developer.log(
'직책 선택됨: $position',
name: 'ContactInfoWidget',
);
setState(() {
widget.contactPositionController.text =
position;
widget.onContactPositionChanged(position);
_removePositionOverlay();
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(position),
),
),
),
InkWell(
onTap: () {
developer.log(
'직책 기타(직접 입력) 선택됨',
name: 'ContactInfoWidget',
);
_removePositionOverlay();
widget.contactPositionController.clear();
widget.onContactPositionChanged('');
_positionFocusNode.requestFocus();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: const Text('기타 (직접 입력)'),
),
),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_positionOverlayEntry!);
}
void _showPhonePrefixOverlay() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final availableHeight =
MediaQuery.of(context).size.height - offset.dy - 100;
final maxHeight = math.min(300.0, availableHeight);
_phonePrefixOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: 200,
child: CompositedTransformFollower(
link: _phonePrefixLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
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),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...widget.phonePrefixes.map(
(prefix) => InkWell(
onTap: () {
developer.log(
'전화번호 접두사 선택됨: $prefix',
name: 'ContactInfoWidget',
);
widget.onPhonePrefixChanged(prefix);
setState(() {
_removePhonePrefixOverlay();
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(prefix),
),
),
),
InkWell(
onTap: () {
developer.log(
'전화번호 접두사 직접 입력 선택됨',
name: 'ContactInfoWidget',
);
_removePhonePrefixOverlay();
_phonePrefixFocusNode.requestFocus();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: const Text('기타 (직접 입력)'),
),
),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_phonePrefixOverlayEntry!);
}
@override
Widget build(BuildContext context) {
developer.log(
'ContactInfoWidget 빌드 시작: 직책 드롭다운=$_showPositionDropdown, 전화번호 접두사 드롭다운=$_showPhonePrefixDropdown',
name: 'ContactInfoWidget',
);
// 컴팩트 모드에 따라 다른 레이아웃 생성
return FormFieldWrapper(
label: widget.title,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children:
widget.compactMode ? _buildCompactLayout() : _buildDefaultLayout(),
),
);
}
// 기본 레이아웃 (한 줄에 모든 필드 표시)
List<Widget> _buildDefaultLayout() {
return [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 담당자 이름
Expanded(
flex: 3,
child: TextFormField(
controller: widget.contactNameController,
decoration: const InputDecoration(
hintText: '이름',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
onTap: () {
developer.log('이름 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
onChanged: widget.onContactNameChanged,
),
),
const SizedBox(width: 8),
// 담당자 직책
Expanded(
flex: 2,
child: CompositedTransformTarget(
link: _positionLayerLink,
child: Stack(
children: [
TextFormField(
controller: widget.contactPositionController,
focusNode: _positionFocusNode,
decoration: InputDecoration(
hintText: '직책',
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down, size: 20),
padding: EdgeInsets.zero,
onPressed: () {
developer.log(
'직책 드롭다운 버튼 클릭됨',
name: 'ContactInfoWidget',
);
_togglePositionDropdown();
},
),
),
onTap: () {
// 필드를 터치했을 때는 드롭다운을 열지 않고 직접 입력 모드로 진입
_closeAllDropdowns();
},
onChanged: widget.onContactPositionChanged,
),
],
),
),
),
const SizedBox(width: 8),
// 전화번호 접두사
Expanded(
flex: 2,
child: CompositedTransformTarget(
link: _phonePrefixLayerLink,
child: Stack(
children: [
TextFormField(
controller: TextEditingController(
text: widget.selectedPhonePrefix,
),
focusNode: _phonePrefixFocusNode,
decoration: InputDecoration(
hintText: '국가번호',
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down, size: 20),
padding: EdgeInsets.zero,
onPressed: () {
developer.log(
'전화번호 접두사 드롭다운 버튼 클릭됨',
name: 'ContactInfoWidget',
);
_togglePhonePrefixDropdown();
},
),
),
onTap: () {
// 필드를 터치했을 때는 드롭다운을 열지 않고 직접 입력 모드로 진입
_closeAllDropdowns();
},
onChanged: (value) {
if (value.isNotEmpty) {
widget.onPhonePrefixChanged(value);
}
},
),
],
),
),
),
const SizedBox(width: 8),
// 담당자 전화번호
Expanded(
flex: 3,
child: TextFormField(
controller: widget.contactPhoneController,
decoration: const InputDecoration(
hintText: '전화번호',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
// 접두사에 따른 동적 포맷팅
TextInputFormatter.withFunction((oldValue, newValue) {
final formatted = PhoneUtils.formatPhoneNumberByPrefix(
widget.selectedPhonePrefix,
newValue.text,
);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}),
],
onTap: () {
developer.log('전화번호 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
validator: validatePhoneNumber,
onChanged: widget.onContactPhoneChanged,
),
),
const SizedBox(width: 8),
// 담당자 이메일
Expanded(
flex: 6,
child: TextFormField(
controller: widget.contactEmailController,
decoration: const InputDecoration(
hintText: '이메일',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
keyboardType: TextInputType.emailAddress,
onTap: () {
developer.log('이메일 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
validator: FormValidator.email(),
onChanged: widget.onContactEmailChanged,
),
),
],
),
];
}
// 컴팩트 레이아웃 (여러 줄에 필드 표시)
List<Widget> _buildCompactLayout() {
return [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 담당자 이름
Expanded(
child: TextFormField(
controller: widget.contactNameController,
decoration: const InputDecoration(
hintText: '담당자 이름',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
onTap: () {
developer.log('이름 필드 터치됨', name: 'ContactInfoWidget');
_closeAllDropdowns();
},
onChanged: widget.onContactNameChanged,
),
),
const SizedBox(width: 16),
// 담당자 직책
Expanded(
child: CompositedTransformTarget(
link: _positionLayerLink,
child: InkWell(
onTap: _togglePositionDropdown,
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: [
Expanded(
child: Text(
widget.contactPositionController.text.isEmpty
? '직책 선택'
: widget.contactPositionController.text,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
widget.contactPositionController.text.isEmpty
? Colors.grey.shade600
: Colors.black,
),
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
),
),
),
],
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 전화번호 (접두사 + 번호)
Expanded(
child: Row(
children: [
// 전화번호 접두사
CompositedTransformTarget(
link: _phonePrefixLayerLink,
child: InkWell(
onTap: _togglePhonePrefixDropdown,
child: Container(
width: 70,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 14,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(4),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.selectedPhonePrefix,
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
),
),
),
// 전화번호
Expanded(
child: TextFormField(
controller: widget.contactPhoneController,
decoration: const InputDecoration(
hintText: '전화번호',
border: OutlineInputBorder(
borderRadius: BorderRadius.horizontal(
left: Radius.zero,
right: Radius.circular(4),
),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
// 접두사에 따른 동적 포맷팅
TextInputFormatter.withFunction((oldValue, newValue) {
final formatted = PhoneUtils.formatPhoneNumberByPrefix(
widget.selectedPhonePrefix,
newValue.text,
);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}),
],
keyboardType: TextInputType.phone,
onTap: _closeAllDropdowns,
onChanged: widget.onContactPhoneChanged,
validator: validatePhoneNumber,
),
),
],
),
),
const SizedBox(width: 16),
// 이메일
Expanded(
child: TextFormField(
controller: widget.contactEmailController,
decoration: const InputDecoration(
hintText: '이메일 주소',
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 15,
),
),
keyboardType: TextInputType.emailAddress,
onTap: _closeAllDropdowns,
onChanged: widget.onContactEmailChanged,
validator: FormValidator.email(),
),
),
],
),
];
}
}

View File

@@ -71,6 +71,13 @@ class EquipmentInFormController extends ChangeNotifier {
List<String> warehouseLocations = [];
List<String> partnerCompanies = [];
// 새로운 필드들 (백엔드 API 구조 변경 대응)
int? currentCompanyId;
int? currentBranchId;
DateTime? lastInspectionDate;
DateTime? nextInspectionDate;
String? equipmentStatus;
final TextEditingController remarkController = TextEditingController();
EquipmentInFormController({this.equipmentInId}) {
@@ -234,6 +241,13 @@ class EquipmentInFormController extends ChangeNotifier {
warrantyStartDate = equipment.warrantyStartDate ?? DateTime.now();
warrantyEndDate = equipment.warrantyEndDate ?? DateTime.now().add(const Duration(days: 365));
// 새로운 필드들 설정 (백엔드 API에서 제공되면 사용, 아니면 기본값)
currentCompanyId = equipment.currentCompanyId;
currentBranchId = equipment.currentBranchId;
lastInspectionDate = equipment.lastInspectionDate;
nextInspectionDate = equipment.nextInspectionDate;
equipmentStatus = equipment.equipmentStatus ?? 'available'; // 기본값: 사용 가능
// 입고 관련 정보는 현재 API에서 제공하지 않으므로 기본값 사용
inDate = equipment.inDate ?? DateTime.now();
equipmentType = EquipmentType.new_;
@@ -337,6 +351,12 @@ class EquipmentInFormController extends ChangeNotifier {
warrantyLicense: warrantyLicense,
warrantyStartDate: warrantyStartDate,
warrantyEndDate: warrantyEndDate,
// 새로운 필드들 추가
currentCompanyId: currentCompanyId,
currentBranchId: currentBranchId,
lastInspectionDate: lastInspectionDate,
nextInspectionDate: nextInspectionDate,
equipmentStatus: equipmentStatus,
// 워런티 코드 저장 필요시 여기에 추가
);

View File

@@ -2374,6 +2374,184 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
),
),
// 현재 위치 및 상태 정보 섹션
const SizedBox(height: 24),
// 현재 회사 및 지점 정보
Row(
children: [
Expanded(
child: FormFieldWrapper(
label: '현재 회사',
required: false,
child: DropdownButtonFormField<String>(
value: _controller.currentCompanyId?.toString(),
decoration: const InputDecoration(
hintText: '현재 배치된 회사를 선택하세요',
),
items: const [
DropdownMenuItem(value: null, child: Text('선택하지 않음')),
// TODO: 실제 회사 목록으로 대체 필요
],
onChanged: (value) {
setState(() {
_controller.currentCompanyId = value != null ? int.tryParse(value) : null;
});
},
),
),
),
const SizedBox(width: 12),
Expanded(
child: FormFieldWrapper(
label: '현재 지점',
required: false,
child: DropdownButtonFormField<String>(
value: _controller.currentBranchId?.toString(),
decoration: const InputDecoration(
hintText: '현재 배치된 지점을 선택하세요',
),
items: const [
DropdownMenuItem(value: null, child: Text('선택하지 않음')),
// TODO: 실제 지점 목록으로 대체 필요
],
onChanged: (value) {
setState(() {
_controller.currentBranchId = value != null ? int.tryParse(value) : null;
});
},
),
),
),
],
),
// 점검 날짜 정보
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FormFieldWrapper(
label: '최근 점검일',
required: false,
child: InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _controller.lastInspectionDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() {
_controller.lastInspectionDate = 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.lastInspectionDate != null
? '${_controller.lastInspectionDate!.year}-${_controller.lastInspectionDate!.month.toString().padLeft(2, '0')}-${_controller.lastInspectionDate!.day.toString().padLeft(2, '0')}'
: '날짜를 선택하세요',
style: TextStyle(
color: _controller.lastInspectionDate != null
? Colors.black87
: Colors.grey.shade600,
),
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FormFieldWrapper(
label: '다음 점검일',
required: false,
child: InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _controller.nextInspectionDate ?? DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
_controller.nextInspectionDate = 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.nextInspectionDate != null
? '${_controller.nextInspectionDate!.year}-${_controller.nextInspectionDate!.month.toString().padLeft(2, '0')}-${_controller.nextInspectionDate!.day.toString().padLeft(2, '0')}'
: '날짜를 선택하세요',
style: TextStyle(
color: _controller.nextInspectionDate != null
? Colors.black87
: Colors.grey.shade600,
),
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
),
],
),
// 장비 상태
const SizedBox(height: 16),
FormFieldWrapper(
label: '장비 상태',
required: false,
child: DropdownButtonFormField<String>(
value: _controller.equipmentStatus,
decoration: const InputDecoration(
hintText: '장비 상태를 선택하세요',
),
items: const [
DropdownMenuItem(value: 'available', child: Text('사용 가능')),
DropdownMenuItem(value: 'inuse', child: Text('사용 중')),
DropdownMenuItem(value: 'maintenance', child: Text('유지보수')),
DropdownMenuItem(value: 'disposed', child: Text('폐기')),
],
onChanged: (value) {
setState(() {
_controller.equipmentStatus = value;
});
},
),
),
// 비고 입력란 추가
const SizedBox(height: 16),
FormFieldWrapper(

View File

@@ -759,12 +759,9 @@ class _EquipmentListState extends State<EquipmentList> {
if (_showDetailedColumns) {
totalWidth += 120; // 시리얼번호
totalWidth += 120; // 바코드
// 정보 (조건부)
if (pagedEquipments.any((e) => e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent)) {
totalWidth += 120; // 회사
totalWidth += 80; // 담당자
}
totalWidth += 120; // 현재 위치
totalWidth += 100; // 위치
totalWidth += 100; // 점검일
}
// padding 추가 (좌우 각 16px)
@@ -865,10 +862,11 @@ class _EquipmentListState extends State<EquipmentList> {
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 날짜
_buildHeaderCell('날짜', flex: 2, useExpanded: useExpanded, minWidth: 80),
// 출고 정보 (조건부)
if (_showDetailedColumns && hasOutOrRent) ...[
_buildHeaderCell('회사', flex: 3, useExpanded: useExpanded, minWidth: 120),
_buildHeaderCell('담당자', flex: 2, useExpanded: useExpanded, minWidth: 80),
// 상세 정보 (조건부)
if (_showDetailedColumns) ...[
_buildHeaderCell('현재 위치', flex: 3, useExpanded: useExpanded, minWidth: 120),
_buildHeaderCell('창고 위치', flex: 2, useExpanded: useExpanded, minWidth: 100),
_buildHeaderCell('점검일', flex: 2, useExpanded: useExpanded, minWidth: 100),
],
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90),
@@ -989,25 +987,34 @@ class _EquipmentListState extends State<EquipmentList> {
useExpanded: useExpanded,
minWidth: 80,
),
// 출고 정보 (조건부)
if (_showDetailedColumns && hasOutOrRent) ...[
// 상세 정보 (조건부)
if (_showDetailedColumns) ...[
// 현재 위치 (회사 + 지점)
_buildDataCell(
_buildTextWithTooltip(
'-', // TODO: 출고 정보 추가 필요
'-',
_buildCurrentLocationText(equipment),
_buildCurrentLocationText(equipment),
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 창고 위치
_buildDataCell(
Text(
'-', // TODO: 담당자 정보 추가 필요
equipment.warehouseLocation ?? '-',
style: ShadcnTheme.bodySmall,
),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
minWidth: 100,
),
// 점검일 (최근/다음)
_buildDataCell(
_buildInspectionDateWidget(equipment),
flex: 2,
useExpanded: useExpanded,
minWidth: 100,
),
],
// 관리
@@ -1325,4 +1332,50 @@ class _EquipmentListState extends State<EquipmentList> {
return items;
}
/// 현재 위치 텍스트 생성 (회사명 + 지점명)
String _buildCurrentLocationText(UnifiedEquipment equipment) {
final currentCompany = equipment.currentCompany ?? '-';
final currentBranch = equipment.currentBranch ?? '';
if (currentBranch.isNotEmpty) {
return '$currentCompany ($currentBranch)';
} else {
return currentCompany;
}
}
/// 점검일 위젯 생성 (최근 점검일/다음 점검일)
Widget _buildInspectionDateWidget(UnifiedEquipment equipment) {
final lastInspection = equipment.lastInspectionDate;
final nextInspection = equipment.nextInspectionDate;
String displayText = '-';
Color? textColor;
if (nextInspection != null) {
final now = DateTime.now();
final difference = nextInspection.difference(now).inDays;
if (difference < 0) {
displayText = '점검 필요';
textColor = Colors.red;
} else if (difference <= 30) {
displayText = '${difference}일 후';
textColor = Colors.orange;
} else {
displayText = '${nextInspection.month}/${nextInspection.day}';
textColor = Colors.green;
}
} else if (lastInspection != null) {
displayText = '${lastInspection.month}/${lastInspection.day}';
}
return Text(
displayText,
style: ShadcnTheme.bodySmall.copyWith(
color: textColor ?? ShadcnTheme.bodySmall.color,
),
);
}
}

View File

@@ -467,11 +467,36 @@ class _EquipmentOutFormScreenState extends State<EquipmentOutFormScreen> {
},
),
// 장비 상태 변경 (출고 시 'inuse'로 자동 설정)
const Text('장비 상태 설정', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: 'inuse', // 출고 시 기본값
decoration: const InputDecoration(
hintText: '출고 후 장비 상태',
labelText: '출고 후 상태 *',
),
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)),
const Text('출고 회사 *', style: TextStyle(fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () {
controller.addCompany();

View File

@@ -17,8 +17,19 @@ class WarehouseLocationFormController extends ChangeNotifier {
/// 비고 입력 컨트롤러
final TextEditingController remarkController = TextEditingController();
/// 주소 정보
Address _address = const Address();
/// 담당자명 입력 컨트롤러
final TextEditingController managerNameController = TextEditingController();
/// 담당자 연락처 입력 컨트롤러
final TextEditingController managerPhoneController = TextEditingController();
/// 수용량 입력 컨트롤러
final TextEditingController capacityController = TextEditingController();
/// 주소 입력 컨트롤러 (단일 필드)
final TextEditingController addressController = TextEditingController();
/// 백엔드 API에 맞는 단순 필드들 (주소는 단일 String)
/// 저장 중 여부
bool _isSaving = false;
@@ -53,7 +64,6 @@ class WarehouseLocationFormController extends ChangeNotifier {
}
// Getters
Address get address => _address;
bool get isSaving => _isSaving;
bool get isEditMode => _isEditMode;
int? get id => _id;
@@ -74,8 +84,11 @@ class WarehouseLocationFormController extends ChangeNotifier {
if (_originalLocation != null) {
nameController.text = _originalLocation!.name;
_address = _originalLocation!.address;
addressController.text = _originalLocation!.address ?? '';
remarkController.text = _originalLocation!.remark ?? '';
managerNameController.text = _originalLocation!.managerName ?? '';
managerPhoneController.text = _originalLocation!.managerPhone ?? '';
capacityController.text = _originalLocation!.capacity?.toString() ?? '';
}
} catch (e) {
_error = e.toString();
@@ -85,11 +98,6 @@ class WarehouseLocationFormController extends ChangeNotifier {
}
}
/// 주소 변경 처리
void updateAddress(Address newAddress) {
_address = newAddress;
notifyListeners();
}
/// 저장 처리 (추가/수정)
Future<bool> save() async {
@@ -103,8 +111,13 @@ class WarehouseLocationFormController extends ChangeNotifier {
final location = WarehouseLocation(
id: _isEditMode ? _id! : 0,
name: nameController.text.trim(),
address: _address,
address: addressController.text.trim().isEmpty ? null : addressController.text.trim(),
remark: remarkController.text.trim().isEmpty ? null : remarkController.text.trim(),
managerName: managerNameController.text.trim().isEmpty ? null : managerNameController.text.trim(),
managerPhone: managerPhoneController.text.trim().isEmpty ? null : managerPhoneController.text.trim(),
capacity: capacityController.text.trim().isEmpty ? null : int.tryParse(capacityController.text.trim()),
isActive: true, // 새로 생성 시 항상 활성화
createdAt: DateTime.now(),
);
if (_isEditMode) {
@@ -127,8 +140,11 @@ class WarehouseLocationFormController extends ChangeNotifier {
/// 폼 초기화
void resetForm() {
nameController.clear();
addressController.clear();
remarkController.clear();
_address = const Address();
managerNameController.clear();
managerPhoneController.clear();
capacityController.clear();
_error = null;
formKey.currentState?.reset();
notifyListeners();
@@ -145,9 +161,28 @@ class WarehouseLocationFormController extends ChangeNotifier {
return null;
}
String? validateAddress() {
if (_address.isEmpty) {
return '주소를 입력해주세요';
/// 수용량 유효성 검사
String? validateCapacity(String? value) {
if (value != null && value.isNotEmpty) {
final capacity = int.tryParse(value);
if (capacity == null) {
return '올바른 숫자를 입력해주세요';
}
if (capacity < 0) {
return '수용량은 0 이상이어야 합니다';
}
}
return null;
}
/// 전화번호 유효성 검사
String? validatePhoneNumber(String? value) {
if (value != null && value.isNotEmpty) {
// 기본적인 전화번호 형식 검사 (숫자, 하이픈 허용)
if (!RegExp(r'^[0-9-]+$').hasMatch(value)) {
return '올바른 전화번호 형식을 입력해주세요';
}
}
return null;
}
@@ -156,7 +191,11 @@ class WarehouseLocationFormController extends ChangeNotifier {
@override
void dispose() {
nameController.dispose();
addressController.dispose();
remarkController.dispose();
managerNameController.dispose();
managerPhoneController.dispose();
capacityController.dispose();
super.dispose();
}
}

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:flutter/services.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
@@ -81,47 +80,78 @@ class _WarehouseLocationFormScreenState
child: SingleChildScrollView(
padding: const EdgeInsets.all(UIConstants.formPadding),
child: FormSection(
title: '입고지 정보',
subtitle: '입고지의 기본 정보를 입력하세요',
title: '창고 정보',
subtitle: '창고의 기본 정보를 입력하세요',
children: [
// 입고지명 입력
FormFieldWrapper(
label: '입고지',
label: '창고',
required: true,
child: TextFormField(
controller: _controller.nameController,
decoration: const InputDecoration(
hintText: '입고지명을 입력하세요',
hintText: '창고명을 입력하세요',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '입고지명을 입력하세요';
return '창고명을 입력하세요';
}
return null;
},
),
),
// 주소 입력 (공통 위젯)
// 주소 입력 (단일 필드)
FormFieldWrapper(
label: '주소',
required: true,
child: AddressInput(
initialZipCode: _controller.address.zipCode,
initialRegion: _controller.address.region,
initialDetailAddress: _controller.address.detailAddress,
isRequired: true,
onAddressChanged: (zip, region, detail) {
setState(() {
_controller.updateAddress(
Address(
zipCode: zip,
region: region,
detailAddress: detail,
),
);
});
},
child: TextFormField(
controller: _controller.addressController,
decoration: const InputDecoration(
hintText: '주소를 입력하세요 (예: 경기도 용인시 기흥구 동백로 123)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
),
// 담당자명 입력
FormFieldWrapper(
label: '담당자명',
child: TextFormField(
controller: _controller.managerNameController,
decoration: const InputDecoration(
hintText: '담당자명을 입력하세요',
border: OutlineInputBorder(),
),
),
),
// 담당자 연락처 입력
FormFieldWrapper(
label: '담당자 연락처',
child: TextFormField(
controller: _controller.managerPhoneController,
decoration: const InputDecoration(
hintText: '연락처를 입력하세요 (예: 02-1234-5678)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
validator: _controller.validatePhoneNumber,
),
),
// 수용량 입력
FormFieldWrapper(
label: '수용량',
child: TextFormField(
controller: _controller.capacityController,
decoration: const InputDecoration(
hintText: '수용량을 입력하세요 (개)',
border: OutlineInputBorder(),
suffixText: '',
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
validator: _controller.validateCapacity,
),
),
// 비고 입력

View File

@@ -15,7 +15,7 @@ import 'package:superport/services/auth_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/widgets/auth_guard.dart';
/// shadcn/ui 스타일로 재설계된 입고지 관리 화면
/// shadcn/ui 스타일로 재설계된 창고 관리 화면
class WarehouseLocationList extends StatefulWidget {
const WarehouseLocationList({Key? key}) : super(key: key);
@@ -62,7 +62,7 @@ class _WarehouseLocationListState
_controller.refresh(); // Controller에서 페이지 리셋 처리
}
/// 입고지 추가 폼으로 이동
/// 창고 추가 폼으로 이동
void _navigateToAdd() async {
final result = await Navigator.pushNamed(
context,
@@ -73,7 +73,7 @@ class _WarehouseLocationListState
}
}
/// 입고지 수정 폼으로 이동
/// 창고 수정 폼으로 이동
void _navigateToEdit(WarehouseLocation location) async {
final result = await Navigator.pushNamed(
context,
@@ -91,7 +91,7 @@ class _WarehouseLocationListState
context: context,
builder:
(context) => AlertDialog(
title: const Text('입고지 삭제'),
title: const Text('창고 삭제'),
content: const Text('정말로 삭제하시겠습니까?'),
actions: [
TextButton(
@@ -110,6 +110,11 @@ class _WarehouseLocationListState
);
}
/// 날짜 포맷팅 함수
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
// Admin과 Manager만 접근 가능
@@ -130,13 +135,13 @@ class _WarehouseLocationListState
emptyMessage:
controller.searchQuery.isNotEmpty
? '검색 결과가 없습니다'
: '등록된 입고지가 없습니다',
: '등록된 창고가 없습니다',
emptyIcon: Icons.warehouse_outlined,
// 검색바
searchBar: UnifiedSearchBar(
controller: _searchController,
placeholder: '창고명, 주소로 검색',
placeholder: '창고명, 주소, 담당자로 검색',
onChanged: (value) => _controller.search(value),
onSearch: () => _controller.search(_searchController.text),
onClear: () {
@@ -149,7 +154,7 @@ class _WarehouseLocationListState
actionBar: StandardActionBar(
leftActions: [
ShadcnButton(
text: '입고지 추가',
text: '창고 추가',
onPressed: _navigateToAdd,
variant: ShadcnButtonVariant.primary,
textColor: Colors.white,
@@ -211,7 +216,7 @@ class _WarehouseLocationListState
action:
_controller.searchQuery.isEmpty
? StandardActionButtons.addButton(
text: '입고지 추가하기',
text: '창고 추가하기',
onPressed: _navigateToAdd,
)
: null,
@@ -246,16 +251,32 @@ class _WarehouseLocationListState
child: Text('번호', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 3,
child: Text('입고지', style: ShadcnTheme.bodyMedium),
flex: 2,
child: Text('창고', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 4,
flex: 3,
child: Text('주소', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 2,
child: Text('비고', style: ShadcnTheme.bodyMedium),
child: Text('담당자', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 2,
child: Text('연락처', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 1,
child: Text('수용량', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 1,
child: Text('상태', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 2,
child: Text('생성일', style: ShadcnTheme.bodyMedium),
),
Expanded(
flex: 1,
@@ -290,28 +311,85 @@ class _WarehouseLocationListState
style: ShadcnTheme.bodySmall,
),
),
// 입고지
// 창고
Expanded(
flex: 3,
flex: 2,
child: Text(
location.name,
style: ShadcnTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// 주소
Expanded(
flex: 4,
flex: 3,
child: Text(
'${location.address.region} ${location.address.detailAddress}',
location.address ?? '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
// 비고
// 담당자
Expanded(
flex: 2,
child: Text(
location.remark ?? '-',
location.managerName ?? '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
// 연락처
Expanded(
flex: 2,
child: Text(
location.managerPhone ?? '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
// 수용량
Expanded(
flex: 1,
child: Text(
location.capacity?.toString() ?? '-',
style: ShadcnTheme.bodySmall,
textAlign: TextAlign.center,
),
),
// 상태
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: location.isActive
? ShadcnTheme.success.withOpacity(0.1)
: ShadcnTheme.muted.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: location.isActive
? ShadcnTheme.success.withOpacity(0.3)
: ShadcnTheme.muted.withOpacity(0.3)
),
),
child: Text(
location.isActive ? '활성' : '비활성',
style: TextStyle(
fontSize: 10,
color: location.isActive
? ShadcnTheme.success
: ShadcnTheme.muted,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
),
// 생성일
Expanded(
flex: 2,
child: Text(
_formatDate(location.createdAt),
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),