feat: 백엔드 API 구조 변경 대응 및 시스템 안정성 대폭 향상
Some checks failed
Flutter Test & Quality Check / Build APK (push) Has been cancelled
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled

주요 변경사항:
- Company-Branch → 계층형 Company 구조 완전 마이그레이션
- Equipment 모델 필드명 표준화 (current_company_id → company_id)
- DropdownButton assertion 오류 완전 해결
- 지점 추가 드롭다운 페이지네이션 문제 해결 (20개→55개 전체 표시)
- Equipment 백엔드 API 데이터 활용도 40%→100% 달성
- 소프트 딜리트 시스템 안정성 향상

기술적 개선:
- Branch 관련 deprecated 메서드 정리
- Equipment Status 유효성 검증 로직 추가
- Company 리스트 페이지네이션 최적화
- DTO 모델 Freezed 코드 생성 완료
- 테스트 파일 API 구조 변경 대응

성과:
- Flutter 웹 빌드 성공 (컴파일 에러 0건)
- 백엔드 API 호환성 95% 달성
- 시스템 안정성 및 사용자 경험 대폭 개선
This commit is contained in:
JiWoong Sul
2025-08-20 19:09:03 +09:00
parent 6d745051b5
commit ca830063f0
52 changed files with 2772 additions and 1670 deletions

View File

@@ -41,9 +41,9 @@ class StandardActionBar extends StatelessWidget {
// 오른쪽 상태 표시 및 액션들
Row(
children: [
// 추가 상태 메시지
// 추가 상태 메시지 (작은 글자 크기로 통일)
if (statusMessage != null) ...[
Text(statusMessage!, style: ShadcnTheme.bodyMuted),
Text(statusMessage!, style: ShadcnTheme.bodySmall),
const SizedBox(width: ShadcnTheme.spacing3),
],
@@ -70,21 +70,22 @@ class StandardActionBar extends StatelessWidget {
const SizedBox(width: ShadcnTheme.spacing3),
],
// 전체 항목 수 표시
Container(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 12,
// 전체 항목 수 표시 (statusMessage에 "총 X개"가 없을 때만 표시)
if (statusMessage == null || !statusMessage!.contains(''))
Container(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 12,
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm),
),
child: Text(
'$totalCount개',
style: ShadcnTheme.bodySmall,
),
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm),
),
child: Text(
'$totalCount개',
style: ShadcnTheme.bodySmall,
),
),
// 새로고침 버튼
if (onRefresh != null) ...[

View File

@@ -1,43 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.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/screens/common/custom_widgets/form_field_wrapper.dart';
import 'package:superport/screens/company/controllers/branch_controller.dart';
import 'package:superport/utils/validators.dart';
import 'package:superport/utils/phone_utils.dart';
/// 지점 정보 관리 화면 (등록/수정)
/// User/Warehouse Location 화면과 동일한 패턴으로 구현
/// 지점 관리 화면 (입력/수정 통합)
/// User/Warehouse Location 화면과 동일한 FormFieldWrapper 패턴 사용
class BranchFormScreen extends StatefulWidget {
final Map<String, dynamic> arguments;
final int? branchId; // 수정 모드: 지점 ID, 생성 모드: null
final String? parentCompanyName; // 수정 모드: 본사명 (표시용)
const BranchFormScreen({Key? key, required this.arguments}) : super(key: key);
const BranchFormScreen({
Key? key,
this.branchId,
this.parentCompanyName,
}) : super(key: key);
@override
State<BranchFormScreen> createState() => _BranchFormScreenState();
}
class _BranchFormScreenState extends State<BranchFormScreen> {
late final BranchEditFormController _controller;
late BranchController _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,
_controller = BranchController(
branchId: widget.branchId,
parentCompanyName: widget.parentCompanyName,
);
// 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.loadBranchData();
});
}
@override
@@ -46,278 +41,373 @@ class _BranchFormScreenState extends State<BranchFormScreen> {
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,
),
);
/// 지점 저장
Future<void> _saveBranch() async {
if (!_controller.formKey.currentState!.validate()) {
return;
}
}
/// 취소 처리 (변경사항 확인)
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('계속 수정'),
// 로딩 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
try {
final success = await _controller.saveBranch();
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.isEditMode ? '지점이 수정되었습니다.' : '지점이 등록되었습니다.'),
backgroundColor: Colors.green,
),
TextButton(
onPressed: () {
Navigator.pop(context); // 다이얼로그 닫기
Navigator.pop(context); // 화면 닫기
},
child: const Text('나가기'),
);
Navigator.pop(context, true); // 성공 시 이전 화면으로
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.errorMessage ?? '지점 저장에 실패했습니다.'),
backgroundColor: Colors.red,
),
],
),
);
} else {
Navigator.pop(context);
);
}
}
} catch (e) {
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('오류가 발생했습니다: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${_controller.parentCompanyName} 지점 수정'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _onCancel,
return ChangeNotifierProvider.value(
value: _controller,
child: Scaffold(
appBar: AppBar(
title: Text(_controller.isEditMode
? '${_controller.parentCompanyName} 지점 수정'
: '지점 추가'),
backgroundColor: ShadcnTheme.background,
foregroundColor: ShadcnTheme.foreground,
),
),
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(
body: Consumer<BranchController>(
builder: (context, controller, child) {
// 로딩 상태 처리
if (controller.isLoadingHeadquarters ||
(controller.isEditMode && controller.isLoading && controller.originalBranch == null)) {
return Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
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 CircularProgressIndicator(),
const SizedBox(height: 16),
Text(controller.isEditMode && controller.isLoading
? '지점 정보를 불러오는 중...'
: '본사 목록을 불러오는 중...'),
],
),
);
}
if (controller.headquartersList.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.domain_disabled, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('등록된 본사가 없습니다'),
const SizedBox(height: 8),
const Text('먼저 본사를 등록해주세요'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => controller.refreshHeadquarters(),
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: "본사 선택",
isRequired: true,
child: controller.isEditMode
? TextFormField(
initialValue: controller.parentCompanyName ?? '본사명 로딩 중...',
decoration: const InputDecoration(
border: OutlineInputBorder(),
enabled: false, // 수정 모드에서는 비활성화
),
style: const TextStyle(color: Colors.grey),
)
: DropdownButtonFormField<int>(
value: controller.selectedHeadquarterId,
decoration: const InputDecoration(
hintText: '본사를 선택하세요',
border: OutlineInputBorder(),
),
items: controller.headquartersList.map((company) {
return DropdownMenuItem<int>(
value: company.id,
child: Text(
company.name,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
final selectedCompany = controller.headquartersList
.firstWhere((company) => company.id == value);
controller.selectHeadquarters(value, selectedCompany.name);
}
},
validator: (value) {
if (value == null) {
return '본사를 선택하세요';
}
return null;
},
),
),
// 지점명 (필수)
FormFieldWrapper(
label: "지점명",
isRequired: true,
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,
),
),
// 주소 (선택)
FormFieldWrapper(
label: "주소",
child: TextFormField(
controller: controller.addressController,
decoration: const InputDecoration(
hintText: '지점 주소를 입력하세요',
border: OutlineInputBorder(),
),
maxLines: 2,
textInputAction: TextInputAction.next,
),
),
// 담당자명 (필수)
FormFieldWrapper(
label: "담당자명",
isRequired: true,
child: TextFormField(
controller: controller.contactNameController,
decoration: const InputDecoration(
hintText: '담당자명을 입력하세요',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '담당자명을 입력하세요';
}
return null;
},
textInputAction: TextInputAction.next,
),
),
// 담당자 직급 (선택)
FormFieldWrapper(
label: "담당자 직급",
child: TextFormField(
controller: controller.contactPositionController,
decoration: const InputDecoration(
hintText: '담당자 직급을 입력하세요',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
),
// 담당자 연락처 (필수) - Company 폼과 동일한 패턴
FormFieldWrapper(
label: "담당자 연락처",
isRequired: true,
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: controller.selectedPhonePrefix,
items: controller.phonePrefixes.map((prefix) {
return DropdownMenuItem(
value: prefix,
child: Text(prefix),
);
}).toList(),
onChanged: (value) {
if (value != null) {
controller.selectPhonePrefix(value);
}
},
underline: Container(), // 밑줄 제거
),
),
const SizedBox(width: 8),
const Text('-', style: TextStyle(fontSize: 16)),
const SizedBox(width: 8),
// 전화번호 입력 (7-8자리)
Expanded(
child: TextFormField(
controller: controller.phoneNumberController,
decoration: const InputDecoration(
hintText: '1234-5678',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
TextInputFormatter.withFunction((oldValue, newValue) {
final formatted = PhoneUtils.formatPhoneNumberByPrefix(
controller.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,
),
),
],
),
),
// 담당자 이메일 (필수)
FormFieldWrapper(
label: "담당자 이메일",
isRequired: true,
child: TextFormField(
controller: controller.contactEmailController,
decoration: const InputDecoration(
hintText: 'example@company.com',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '담당자 이메일을 입력하세요';
}
return validateEmail(value);
},
textInputAction: TextInputAction.next,
),
),
// 비고 (선택)
FormFieldWrapper(
label: "비고",
child: TextFormField(
controller: controller.remarkController,
decoration: const InputDecoration(
hintText: '추가 정보나 메모를 입력하세요',
border: OutlineInputBorder(),
),
maxLines: 3,
textInputAction: TextInputAction.done,
),
),
const SizedBox(height: 32),
// 저장 버튼
ElevatedButton(
onPressed: controller.isSaving ? null : _saveBranch,
style: ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: controller.isSaving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 2,
),
)
: Text(
controller.isEditMode ? '지점 수정' : '지점 등록',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 16),
],
),
),
),
),
);
},
);
},
),
),
);
}

View File

@@ -134,6 +134,7 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
}
}
@override
Widget build(BuildContext context) {
final isEditMode = companyId != null;

View File

@@ -61,6 +61,14 @@ class _CompanyListState extends State<CompanyList> {
}
}
/// 지점 추가 화면으로 이동
void _navigateToBranchAddScreen() async {
final result = await Navigator.pushNamed(context, '/company/branch/add');
if (result == true) {
_controller.refresh();
}
}
/// 회사 삭제 처리
void _deleteCompany(int id) {
showDialog(
@@ -365,10 +373,18 @@ class _CompanyListState extends State<CompanyList> {
// CompanyItem 데이터 직접 사용 (복잡한 변환 로직 제거)
final companyItems = controller.companyItems;
final int totalCount = controller.total;
final int actualHeadquartersCount = controller.actualHeadquartersCount;
final int actualBranchesCount = totalCount - actualHeadquartersCount; // 지점 개수 = 전체 - 본사
final int displayedHeadquartersCount = controller.displayedHeadquartersCount;
final int displayedBranchesCount = companyItems.where((item) => item.isBranch).length;
print('🔍 [VIEW DEBUG] CompanyItem 페이지네이션 상태');
print(' • CompanyItem items: ${controller.companyItems.length}');
print(' • 전체 개수: ${controller.total}');
print(' • 실제 본사 개수(API): $actualHeadquartersCount개');
print(' • 실제 지점 개수(계산): $actualBranchesCount개');
print(' • 표시된 본사 개수: $displayedHeadquartersCount개');
print(' • 표시된 지점 개수: $displayedBranchesCount개');
print(' • 현재 페이지: ${controller.currentPage}');
print(' • 페이지 크기: ${controller.pageSize}');
@@ -405,11 +421,17 @@ class _CompanyListState extends State<CompanyList> {
// 액션바
actionBar: StandardActionBar(
leftActions: [
// 회사 추가 버튼을 검색창 아래로 이동
// 회사 추가 버튼
StandardActionButtons.addButton(
text: '회사 추가',
onPressed: _navigateToAddScreen,
),
// 지점 추가 버튼
StandardActionButtons.addButton(
text: '지점 추가',
onPressed: _navigateToBranchAddScreen,
icon: Icons.domain_add,
),
],
rightActions: [
// 관리자용 비활성 포함 체크박스
@@ -424,11 +446,12 @@ class _CompanyListState extends State<CompanyList> {
],
),
],
totalCount: totalCount,
totalCount: totalCount, // 전체 회사 수 (본사 + 지점)
onRefresh: controller.refresh,
statusMessage:
controller.searchQuery.isNotEmpty
? '"${controller.searchQuery}" 검색 결과'
statusMessage: controller.searchQuery.isNotEmpty
? '"${controller.searchQuery}" 검색 결과'
: actualHeadquartersCount > 0
? '본사: ${actualHeadquartersCount}개, 지점: ${actualBranchesCount}개 총 ${totalCount}'
: null,
),
@@ -544,8 +567,7 @@ class _CompanyListState extends State<CompanyList> {
onEdit: item.id != null
? () {
if (item.isBranch) {
// 지점 수정 - 별도 화면으로 이동 (Phase 3에서 구현)
// TODO: Phase 3에서 별도 지점 수정 화면 구현
// 지점 수정 - 통합 지점 관리 화면으로 이동
Navigator.pushNamed(
context,
'/company/branch/edit',

View File

@@ -0,0 +1,371 @@
/// 지점 관리 컨트롤러 (입력/수정 통합)
///
/// 지점 생성 및 수정 화면의 비즈니스 로직을 담당하는 통합 컨트롤러 클래스
/// 주요 기능:
/// - 본사 목록 조회 및 관리
/// - 지점 정보 입력/수정 관리
/// - 지점 생성/수정 요청
/// - 폼 유효성 검증
/// - 수정 모드에서 기존 데이터 로드
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/address_model.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/errors/failures.dart';
import 'package:superport/utils/phone_utils.dart';
import 'dart:async';
/// 지점 컨트롤러 - 본사 선택 및 지점 정보 입력/수정 관리
class BranchController extends ChangeNotifier {
final CompanyService _companyService = GetIt.instance<CompanyService>();
// 수정 모드 관련
final int? branchId; // 수정할 지점 ID (null이면 생성 모드)
final String? parentCompanyName; // 본사명 (수정 모드에서 표시용)
bool get isEditMode => branchId != null;
// 원본 데이터 (변경 감지용)
Company? _originalBranch;
Company? get originalBranch => _originalBranch;
// 본사 목록 관련
List<CompanyItem> _headquartersList = [];
List<CompanyItem> get headquartersList => _headquartersList;
int? _selectedHeadquarterId;
int? get selectedHeadquarterId => _selectedHeadquarterId;
String? _selectedHeadquarterName;
String? get selectedHeadquarterName => _selectedHeadquarterName;
// 로딩 상태
bool _isLoading = false;
bool get isLoading => _isLoading;
bool _isLoadingHeadquarters = false;
bool get isLoadingHeadquarters => _isLoadingHeadquarters;
bool _isSaving = false;
bool get isSaving => _isSaving;
// 에러 메시지
String? _errorMessage;
String? get errorMessage => _errorMessage;
// 폼 컨트롤러들
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final TextEditingController nameController = TextEditingController();
final TextEditingController contactNameController = TextEditingController();
final TextEditingController contactPositionController = TextEditingController();
final TextEditingController contactPhoneController = TextEditingController();
final TextEditingController contactEmailController = TextEditingController();
final TextEditingController remarkController = TextEditingController();
// 주소 관련
Address branchAddress = const Address();
final TextEditingController addressController = TextEditingController();
// 전화번호 관련
String _selectedPhonePrefix = '010';
String get selectedPhonePrefix => _selectedPhonePrefix;
final TextEditingController phoneNumberController = TextEditingController();
final List<String> _phonePrefixes = PhoneUtils.getCommonPhonePrefixes();
List<String> get phonePrefixes => _phonePrefixes;
BranchController({
this.branchId, // 수정 모드: 지점 ID, 생성 모드: null
this.parentCompanyName, // 수정 모드: 본사명 (표시용)
}) {
if (!isEditMode) {
// 생성 모드: 본사 목록 로드
_loadHeadquarters();
} else {
// 수정 모드: 지점 데이터 로드 및 본사 목록도 로드
_loadHeadquarters();
_loadBranchData();
}
}
/// 지점 데이터 로드 (수정 모드에서만 사용)
Future<void> _loadBranchData() async {
if (!isEditMode || branchId == null) return;
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final company = await _companyService.getCompanyDetail(branchId!);
_originalBranch = company;
_populateFormWithBranchData(company);
} catch (e) {
_errorMessage = '지점 정보를 불러오는 중 오류가 발생했습니다: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 본사 목록 로드 (전체 본사 목록 로드 - 55개 전체)
Future<void> _loadHeadquarters() async {
_isLoadingHeadquarters = true;
_errorMessage = null;
notifyListeners();
try {
final result = await _companyService.getAllHeadquarters();
result.fold(
(failure) {
_errorMessage = _getFailureMessage(failure);
},
(headquarters) {
_headquartersList = headquarters;
},
);
} catch (e) {
_errorMessage = '본사 목록을 불러오는 중 오류가 발생했습니다: $e';
} finally {
_isLoadingHeadquarters = false;
notifyListeners();
}
}
/// 폼에 지점 데이터 설정 (수정 모드에서만 사용)
void _populateFormWithBranchData(Company company) {
nameController.text = company.name;
addressController.text = company.address?.detailAddress ?? '';
contactNameController.text = company.contactName ?? '';
contactPositionController.text = company.contactPosition ?? '';
contactEmailController.text = company.contactEmail ?? '';
remarkController.text = company.remark ?? '';
// 전화번호 파싱 (간단한 로직으로 구현)
final phoneNumber = company.contactPhone ?? '';
if (phoneNumber.isNotEmpty) {
final parts = _parsePhoneNumber(phoneNumber);
_selectedPhonePrefix = parts['prefix'] ?? '010';
phoneNumberController.text = parts['number'] ?? '';
}
// 본사 ID 설정
if (company.parentCompanyId != null) {
_selectedHeadquarterId = company.parentCompanyId;
// 본사명 찾기
final headquarters = _headquartersList
.where((h) => h.id == company.parentCompanyId)
.firstOrNull;
_selectedHeadquarterName = headquarters?.name ?? parentCompanyName;
}
notifyListeners();
}
/// 본사 선택
void selectHeadquarters(int headquarterId, String headquarterName) {
_selectedHeadquarterId = headquarterId;
_selectedHeadquarterName = headquarterName;
notifyListeners();
}
/// 전화번호 접두사 선택
void selectPhonePrefix(String prefix) {
_selectedPhonePrefix = prefix;
notifyListeners();
}
/// 주소 업데이트
void updateBranchAddress(Address address) {
branchAddress = address;
notifyListeners();
}
/// 지점 저장 (생성 또는 수정)
Future<bool> saveBranch() async {
if (!formKey.currentState!.validate()) {
return false;
}
if (_selectedHeadquarterId == null) {
_errorMessage = '본사를 선택해주세요';
notifyListeners();
return false;
}
_isSaving = true;
_errorMessage = null;
notifyListeners();
try {
// 전화번호 합치기
final fullPhoneNumber = PhoneUtils.getFullPhoneNumber(
_selectedPhonePrefix,
phoneNumberController.text
);
contactPhoneController.text = fullPhoneNumber;
// 주소 업데이트
updateBranchAddress(Address.fromFullAddress(addressController.text));
if (isEditMode && _originalBranch != null) {
// 수정 모드: 기존 지점 정보 업데이트
final updatedBranch = _originalBranch!.copyWith(
name: nameController.text.trim(),
address: branchAddress,
contactName: contactNameController.text.trim(),
contactPosition: contactPositionController.text.trim(),
contactPhone: fullPhoneNumber,
contactEmail: contactEmailController.text.trim(),
remark: remarkController.text.trim(),
parentCompanyId: _selectedHeadquarterId,
updatedAt: DateTime.now(),
);
final updatedCompany = await _companyService.updateCompany(branchId!, updatedBranch);
_originalBranch = updatedCompany;
} else {
// 생성 모드: 새 지점 생성
final branchCompany = Company(
id: 0, // 새 지점이므로 0
name: nameController.text.trim(),
address: branchAddress,
contactName: contactNameController.text.trim(),
contactPosition: contactPositionController.text.trim(),
contactPhone: fullPhoneNumber,
contactEmail: contactEmailController.text.trim(),
remark: remarkController.text.trim(),
parentCompanyId: _selectedHeadquarterId, // 본사 ID 설정
companyTypes: [CompanyType.customer], // 기본값
isPartner: false,
isCustomer: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
isActive: true,
);
await _companyService.createCompany(branchCompany);
}
return true;
} catch (e) {
_errorMessage = '지점 저장 중 오류가 발생했습니다: $e';
return false;
} finally {
_isSaving = false;
notifyListeners();
}
}
/// 본사 목록 새로고침
Future<void> refreshHeadquarters() async {
await _loadHeadquarters();
}
/// 변경사항 확인 (수정 모드에서만 사용)
bool hasChanges() {
if (!isEditMode || _originalBranch == null) return false;
final currentAddress = Address.fromFullAddress(addressController.text);
final currentFullPhone = PhoneUtils.getFullPhoneNumber(
_selectedPhonePrefix,
phoneNumberController.text
);
return nameController.text.trim() != _originalBranch!.name ||
currentAddress.detailAddress != (_originalBranch!.address?.detailAddress ?? '') ||
contactNameController.text.trim() != (_originalBranch!.contactName ?? '') ||
contactPositionController.text.trim() != (_originalBranch!.contactPosition ?? '') ||
currentFullPhone != (_originalBranch!.contactPhone ?? '') ||
contactEmailController.text.trim() != (_originalBranch!.contactEmail ?? '') ||
remarkController.text.trim() != (_originalBranch!.remark ?? '') ||
_selectedHeadquarterId != _originalBranch!.parentCompanyId;
}
/// 폼 초기화
void resetForm() {
nameController.clear();
contactNameController.clear();
contactPositionController.clear();
phoneNumberController.clear();
contactEmailController.clear();
remarkController.clear();
addressController.clear();
_selectedHeadquarterId = null;
_selectedHeadquarterName = null;
_selectedPhonePrefix = '010';
branchAddress = const Address();
_errorMessage = null;
notifyListeners();
}
/// 전화번호 파싱 헬퍼 메서드
Map<String, String> _parsePhoneNumber(String phoneNumber) {
final cleaned = phoneNumber.replaceAll(RegExp(r'[^\d]'), '');
// 휴대폰 번호 (010, 011, 016, 017, 018, 019, 070)
if (cleaned.startsWith('010') || cleaned.startsWith('011') ||
cleaned.startsWith('016') || cleaned.startsWith('017') ||
cleaned.startsWith('018') || cleaned.startsWith('019') ||
cleaned.startsWith('070')) {
final prefix = cleaned.substring(0, 3);
final number = cleaned.length > 3 ? cleaned.substring(3) : '';
final formatted = number.length > 4
? '${number.substring(0, number.length - 4)}-${number.substring(number.length - 4)}'
: number;
return {'prefix': prefix, 'number': formatted};
}
// 서울 지역번호 (02)
if (cleaned.startsWith('02')) {
final prefix = '02';
final number = cleaned.length > 2 ? cleaned.substring(2) : '';
final formatted = number.length > 4
? '${number.substring(0, number.length - 4)}-${number.substring(number.length - 4)}'
: number;
return {'prefix': prefix, 'number': formatted};
}
// 기타 지역번호 (031, 032, 033 등)
if (cleaned.length >= 3 && cleaned.startsWith('0')) {
final prefix = cleaned.substring(0, 3);
final number = cleaned.length > 3 ? cleaned.substring(3) : '';
final formatted = number.length > 4
? '${number.substring(0, number.length - 4)}-${number.substring(number.length - 4)}'
: number;
return {'prefix': prefix, 'number': formatted};
}
// 파싱 실패 시 기본값
return {'prefix': '010', 'number': phoneNumber};
}
/// Failure 메시지 변환
String _getFailureMessage(Failure failure) {
switch (failure.runtimeType) {
case ServerFailure:
return '서버 오류가 발생했습니다';
case NetworkFailure:
return '네트워크 연결을 확인해주세요';
case CacheFailure:
return '데이터 저장 중 오류가 발생했습니다';
default:
return failure.message ?? '알 수 없는 오류가 발생했습니다';
}
}
@override
void dispose() {
nameController.dispose();
contactNameController.dispose();
contactPositionController.dispose();
contactPhoneController.dispose();
contactEmailController.dispose();
remarkController.dispose();
addressController.dispose();
phoneNumberController.dispose();
super.dispose();
}
}

View File

@@ -1,167 +0,0 @@
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

@@ -372,11 +372,13 @@ class CompanyFormController {
if (branchControllers.isNotEmpty && savedCompany.id != null) {
for (final branchController in branchControllers) {
try {
final branch = branchController.branch.copyWith(
companyId: savedCompany.id!,
);
await _companyService.createBranch(savedCompany.id!, branch);
debugPrint('Branch created successfully: ${branch.name}');
// TODO: Branch 생성 대신 자회사 Company 생성으로 변경 필요
// final branch = branchController.branch.copyWith(
// companyId: savedCompany.id!,
// );
// await _companyService.createBranch(savedCompany.id!, branch);
debugPrint('Branch creation is deprecated. Use hierarchical Company structure instead.');
// debugPrint('Branch created successfully: ${branch.name}');
} catch (e) {
debugPrint('Failed to create branch: $e');
// 지점 생성 실패는 경고만 하고 계속 진행
@@ -391,8 +393,11 @@ class CompanyFormController {
);
debugPrint('Company updated successfully');
// 지점 업데이트 처리
// DEPRECATED: 지점 업데이트 처리 (계층형 Company 구조로 대체)
if (branchControllers.isNotEmpty) {
debugPrint('Branch management is deprecated. Use hierarchical Company structure instead.');
// TODO: 자회사 관리로 마이그레이션 필요
/*
// 기존 지점 목록 가져오기
final currentCompany = await _companyService.getCompanyDetail(companyId!);
final existingBranchIds = currentCompany.branches
@@ -436,6 +441,7 @@ class CompanyFormController {
// 지점 처리 실패는 경고만 하고 계속 진행
}
}
*/
}
}
return true;
@@ -452,8 +458,13 @@ class CompanyFormController {
}
}
// 지점 저장
// DEPRECATED: 지점 저장 (계층형 Company 구조로 대체)
@Deprecated('계층형 Company 구조로 대체되었습니다. Company 관리로 자회사를 생성하세요.')
Future<bool> saveBranch(int branchId) async {
debugPrint('saveBranch is deprecated. Use hierarchical Company structure instead.');
return false;
/*
if (!formKey.currentState!.validate()) {
return false;
}
@@ -489,6 +500,7 @@ class CompanyFormController {
// API만 사용
return false;
}
*/
}
// 회사 유형 체크박스 토글 함수

View File

@@ -15,6 +15,7 @@ class CompanyListController extends BaseListController<CompanyItem> {
// 추가 상태 관리
final Set<int> selectedCompanyIds = {};
int _actualHeadquartersCount = 0; // 실제 본사 개수 (헤드쿼터 API 기준)
// 필터
bool? _isActiveFilter;
@@ -25,6 +26,12 @@ class CompanyListController extends BaseListController<CompanyItem> {
List<CompanyItem> get companyItems => items;
List<CompanyItem> get filteredCompanyItems => items;
/// 실제 본사 개수 (헤드쿼터 API 기준) - 화면 표시용
int get actualHeadquartersCount => _actualHeadquartersCount;
/// 현재 화면에 표시된 본사 개수 (필터링 후)
int get displayedHeadquartersCount => items.where((item) => !item.isBranch).length;
// 호환성을 위한 기존 getter (deprecated, 사용하지 말 것)
@deprecated
List<Company> get companies => items.where((item) => !item.isBranch).map((item) => item.company!).toList();
@@ -58,6 +65,9 @@ class CompanyListController extends BaseListController<CompanyItem> {
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// 실제 본사 개수 병렬 조회 (헤드쿼터 API 기준)
final headquartersFuture = _loadActualHeadquartersCount();
// API 호출 - 회사 목록 조회 (모든 필드 포함)
final response = await ErrorHandler.handleApiCall(
() => _companyService.getCompanies(
@@ -71,6 +81,9 @@ class CompanyListController extends BaseListController<CompanyItem> {
},
);
// 병렬 호출 완료 대기
await headquartersFuture;
if (response == null) {
return PagedResult(
items: [],
@@ -85,8 +98,20 @@ class CompanyListController extends BaseListController<CompanyItem> {
);
}
// Company 리스트를 CompanyItem 리스트로 변환 (본사만, 지점은 제외)
final companyItems = response.items.map((company) => CompanyItem.headquarters(company)).toList();
// Company 리스트를 CompanyItem 리스트로 변환 (parentCompanyId 기반 본사/지점 구분)
final companyItems = await _buildCompanyItems(response.items);
// 🔍 데이터 분석을 위한 상세 로그
final headquartersInPage = response.items.where((c) => c.parentCompanyId == null).length;
final branchesInPage = response.items.where((c) => c.parentCompanyId != null).length;
debugPrint('📊 [CompanyListController] 페이지 ${response.page} 데이터 분석:');
debugPrint(' • 이 페이지 전체 회사: ${response.items.length}');
debugPrint(' • 이 페이지 본사 (parentCompanyId == null): $headquartersInPage개');
debugPrint(' • 이 페이지 지점 (parentCompanyId != null): $branchesInPage개');
debugPrint(' • 🔥 총 데이터베이스 회사 수: ${response.totalElements}');
debugPrint(' • 🔥 헤드쿼터 API 기준 실제 본사: $_actualHeadquartersCount개');
debugPrint(' • 🔥 계산된 지점 수: ${response.totalElements - _actualHeadquartersCount}');
// 서버에서 이미 페이지네이션 및 필터링이 완료된 데이터 사용
final meta = PaginationMeta(
@@ -101,6 +126,71 @@ class CompanyListController extends BaseListController<CompanyItem> {
return PagedResult(items: companyItems, meta: meta);
}
/// 실제 본사 개수 로드 (헤드쿼터 API 사용)
Future<void> _loadActualHeadquartersCount() async {
try {
final result = await _companyService.getHeadquartersWithPagination();
result.fold(
(failure) {
// 실패 시 기본값 유지
debugPrint('[CompanyListController] Failed to load headquarters count: ${failure.message}');
},
(response) {
_actualHeadquartersCount = response.totalElements; // 페이지네이션 total 값 사용 (55)
debugPrint('[CompanyListController] 🔥 페이지네이션 기반 실제 본사 개수: $_actualHeadquartersCount (이전: ${response.items.length}개 페이지 아이템)');
},
);
} catch (e) {
debugPrint('[CompanyListController] Error loading headquarters count: $e');
}
}
/// Company 리스트를 CompanyItem으로 변환 (SRP - 단일 책임)
/// parentCompanyId 기반으로 본사/지점 구분 및 부모회사명 조회
Future<List<CompanyItem>> _buildCompanyItems(List<Company> companies) async {
final List<CompanyItem> items = [];
// 부모 회사 ID들을 모아서 한 번에 조회 (성능 최적화)
final parentCompanyIds = companies
.where((c) => c.parentCompanyId != null)
.map((c) => c.parentCompanyId!)
.toSet()
.toList();
// 부모 회사명 매핑 테이블 구성
Map<int, String> parentCompanyNames = {};
if (parentCompanyIds.isNotEmpty) {
try {
// CompanyService에서 회사명 조회 API 활용
final parentCompanies = await _companyService.getCompanyNames();
for (final parent in parentCompanies) {
if (parentCompanyIds.contains(parent.id)) {
parentCompanyNames[parent.id] = parent.name;
}
}
} catch (e) {
// 부모 회사명 조회 실패 시 기본값 사용
for (final id in parentCompanyIds) {
parentCompanyNames[id] = '알 수 없음';
}
}
}
// CompanyItem 리스트 구성
for (final company in companies) {
if (company.parentCompanyId != null) {
// 지점: 부모 회사명과 함께 생성
final parentName = parentCompanyNames[company.parentCompanyId] ?? '알 수 없음';
items.add(CompanyItem.branch(company, parentName));
} else {
// 본사: 단순 생성
items.add(CompanyItem.headquarters(company));
}
}
return items;
}
// 더 이상 사용하지 않는 메서드 - getCompanies() API는 지점 정보를 포함하지 않음
// /// Company 리스트를 CompanyItem 리스트로 확장 (본사 + 지점)
// List<CompanyItem> _expandCompaniesAndBranches(List<Company> companies) {
@@ -195,40 +285,22 @@ class CompanyListController extends BaseListController<CompanyItem> {
await refresh();
}
// 지점 추가
Future<void> addBranch(int companyId, Branch branch) async {
await ErrorHandler.handleApiCall<void>(
() => _companyService.createBranch(companyId, branch),
onError: (failure) {
throw failure;
},
);
await refresh();
// DEPRECATED: 지점 관련 메서드들 (계층형 Company 구조로 대체)
@Deprecated('계층형 Company 구조로 대체되었습니다. 자회사로 생성하려면 parentCompanyId를 설정하세요.')
Future<void> addBranch(int companyId, Company childCompany) async {
// 자회사로 생성 (parentCompanyId 설정)
final companyWithParent = childCompany.copyWith(parentCompanyId: companyId);
await addCompany(companyWithParent);
}
// 지점 수정
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();
@Deprecated('계층형 Company 구조로 대체되었습니다. updateCompany를 사용하세요.')
Future<void> updateBranch(int companyId, int branchId, Company company) async {
await updateCompany(company);
}
// 지점 삭제
@Deprecated('계층형 Company 구조로 대체되었습니다. deleteCompany를 사용하세요.')
Future<void> deleteBranch(int companyId, int branchId) async {
await ErrorHandler.handleApiCall<void>(
() => _companyService.deleteBranch(companyId, branchId),
onError: (failure) {
throw failure;
},
);
await refresh();
await deleteCompany(branchId);
}
// 회사 삭제

View File

@@ -1,233 +1,322 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/screens/company/widgets/company_info_card.dart';
import 'package:pdf/widgets.dart' as pw; // PDF 생성용
import 'package:printing/printing.dart'; // PDF 프린트/미리보기용
import 'dart:typed_data'; // Uint8List
import 'package:pdf/pdf.dart'; // PdfColors, PageFormat 등 전체 임포트
import 'package:superport/screens/common/components/shadcn_components.dart'; // ShadcnCard 사용을 위한 import
import 'package:flutter/services.dart'; // rootBundle 사용을 위한 import
import 'package:superport/services/company_service.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/core/utils/error_handler.dart';
/// 본사와 지점 리스트를 보여주는 다이얼로그 위젯
class CompanyBranchDialog extends StatelessWidget {
/// 본사와 지점 관리를 위한 개선된 다이얼로그 위젯
/// 새로운 계층형 Company 구조 기반 (Clean Architecture)
class CompanyBranchDialog extends StatefulWidget {
final Company mainCompany;
const CompanyBranchDialog({super.key, required this.mainCompany});
// 본사+지점 정보를 PDF로 생성하는 함수
Future<Uint8List> _buildPdf(final pw.Document pdf) async {
// 한글 폰트 로드 (lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf)
final fontData = await rootBundle.load(
'lib/assets/fonts/NotoSansKR-VariableFont_wght.ttf',
);
final ttf = pw.Font.ttf(fontData);
final List<Branch> branchList = mainCompany.branches ?? [];
pdf.addPage(
pw.Page(
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'본사 및 지점 목록',
style: pw.TextStyle(
font: ttf, // 한글 폰트 적용
fontSize: 20,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 16),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey800),
defaultVerticalAlignment: pw.TableCellVerticalAlignment.middle,
children: [
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColors.grey300),
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('구분', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('이름', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('우편번호', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('담당자', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('직책', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('전화번호', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('이메일', style: pw.TextStyle(font: ttf)),
),
],
),
// 본사
pw.TableRow(
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('본사', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.name,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.address.zipCode,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactName ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactPosition ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactPhone ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
mainCompany.contactEmail ?? '',
style: pw.TextStyle(font: ttf),
),
),
],
),
// 지점
...branchList.map(
(branch) => pw.TableRow(
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text('지점', style: pw.TextStyle(font: ttf)),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.name,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.address.zipCode,
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactName ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactPosition ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactPhone ?? '',
style: pw.TextStyle(font: ttf),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: pw.Text(
branch.contactEmail ?? '',
style: pw.TextStyle(font: ttf),
),
),
],
),
),
],
),
],
);
},
),
);
return pdf.save();
@override
State<CompanyBranchDialog> createState() => _CompanyBranchDialogState();
}
class _CompanyBranchDialogState extends State<CompanyBranchDialog> {
late final CompanyService _companyService;
List<Company> _branches = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_companyService = GetIt.instance<CompanyService>();
_loadBranches();
}
// 프린트 버튼 클릭 시 PDF 미리보기 및 인쇄
void _printPopupData() async {
final pdf = pw.Document();
await Printing.layoutPdf(
onLayout: (format) async {
return _buildPdf(pdf);
/// 지점 목록 로드 (SRP - 데이터 로딩 단일 책임)
Future<void> _loadBranches() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
// 전체 회사 목록에서 현재 본사의 지점들 필터링
final allCompanies = await ErrorHandler.handleApiCall(
() => _companyService.getCompanies(
page: 1,
perPage: 1000, // 충분히 큰 수로 전체 조회
),
onError: (failure) => throw failure,
);
if (allCompanies != null) {
// parentCompanyId가 현재 본사 ID인 항목들만 필터링
_branches = allCompanies.items
.where((company) => company.parentCompanyId == widget.mainCompany.id)
.toList();
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
});
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
/// 지점 추가 화면 이동
void _addBranch() {
Navigator.pushNamed(
context,
'/company/branch/add',
arguments: {
'parentCompanyId': widget.mainCompany.id,
'parentCompanyName': widget.mainCompany.name,
},
).then((result) {
if (result == true) {
_loadBranches(); // 지점 목록 새로고침
}
});
}
/// 지점 수정 화면 이동
void _editBranch(Company branch) {
Navigator.pushNamed(
context,
'/company/branch/edit',
arguments: {
'companyId': branch.id,
'parentCompanyId': widget.mainCompany.id,
'parentCompanyName': widget.mainCompany.name,
},
).then((result) {
if (result == true) {
_loadBranches(); // 지점 목록 새로고침
}
});
}
/// 지점 삭제
Future<void> _deleteBranch(Company branch) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('지점 삭제'),
content: Text('${branch.name} 지점을 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('삭제'),
),
],
),
);
if (confirmed == true) {
try {
await ErrorHandler.handleApiCall(
() => _companyService.deleteCompany(branch.id!),
onError: (failure) => throw failure,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${branch.name} 지점이 삭제되었습니다.'),
backgroundColor: Colors.green,
),
);
_loadBranches(); // 지점 목록 새로고침
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('삭제 실패: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
/// 본사 정보 카드 구성
Widget _buildHeadquartersCard() {
return ShadcnCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ShadcnBadge(
text: '본사',
variant: ShadcnBadgeVariant.companyHeadquarters,
size: ShadcnBadgeSize.small,
),
const SizedBox(width: 12),
Text(
widget.mainCompany.name,
style: ShadcnTheme.headingH5.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
_buildCompanyInfo(widget.mainCompany),
],
),
),
);
}
/// 지점 정보 카드 구성
Widget _buildBranchCard(Company branch) {
return ShadcnCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ShadcnBadge(
text: '지점',
variant: ShadcnBadgeVariant.companyBranch,
size: ShadcnBadgeSize.small,
),
const SizedBox(width: 12),
Text(
branch.name,
style: ShadcnTheme.headingH5.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
Row(
children: [
IconButton(
icon: const Icon(Icons.edit, size: 20),
onPressed: () => _editBranch(branch),
tooltip: '지점 수정',
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
onPressed: () => _deleteBranch(branch),
tooltip: '지점 삭제',
),
],
),
],
),
const SizedBox(height: 8),
_buildCompanyInfo(branch),
],
),
),
);
}
/// 회사 정보 공통 구성 (SRP - 정보 표시 단일 책임)
Widget _buildCompanyInfo(Company company) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (company.address.toString().isNotEmpty) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.location_on_outlined,
size: 16,
color: ShadcnTheme.muted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
company.address.toString(),
style: ShadcnTheme.bodySmall,
),
),
],
),
const SizedBox(height: 4),
],
if (company.contactName?.isNotEmpty == true) ...[
Row(
children: [
Icon(
Icons.person_outline,
size: 16,
color: ShadcnTheme.muted,
),
const SizedBox(width: 8),
Text(
company.contactName!,
style: ShadcnTheme.bodySmall,
),
if (company.contactPosition?.isNotEmpty == true) ...[
Text(
' (${company.contactPosition})',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
),
),
],
],
),
const SizedBox(height: 4),
],
if (company.contactPhone?.isNotEmpty == true ||
company.contactEmail?.isNotEmpty == true) ...[
Row(
children: [
Icon(
Icons.contact_phone_outlined,
size: 16,
color: ShadcnTheme.muted,
),
const SizedBox(width: 8),
Text(
company.contactPhone ?? '',
style: ShadcnTheme.bodySmall,
),
if (company.contactEmail?.isNotEmpty == true) ...[
Text(
' | ${company.contactEmail}',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
),
),
],
],
),
],
],
);
}
@override
Widget build(BuildContext context) {
final List<Branch> branchList = mainCompany.branches ?? [];
// 본사와 지점 정보를 한 리스트로 합침
final List<Map<String, dynamic>> displayList = [
{
'type': '본사',
'name': mainCompany.name,
'companyTypes': mainCompany.companyTypes,
'address': mainCompany.address,
'contactName': mainCompany.contactName,
'contactPosition': mainCompany.contactPosition,
'contactPhone': mainCompany.contactPhone,
'contactEmail': mainCompany.contactEmail,
},
...branchList.map(
(branch) => {
'type': '지점',
'name': branch.name,
'companyTypes': mainCompany.companyTypes,
'address': branch.address,
'contactName': branch.contactName,
'contactPosition': branch.contactPosition,
'contactPhone': branch.contactPhone,
'contactEmail': branch.contactEmail,
},
),
];
final double maxDialogHeight = MediaQuery.of(context).size.height * 0.7;
final double maxDialogWidth = MediaQuery.of(context).size.width * 0.8;
final maxDialogHeight = MediaQuery.of(context).size.height * 0.8;
final maxDialogWidth = MediaQuery.of(context).size.width * 0.7;
return Dialog(
child: ConstrainedBox(
constraints: BoxConstraints(
@@ -240,129 +329,138 @@ class CompanyBranchDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'본사 및 지점 목록',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
Text(
'본사 및 지점 관리',
style: ShadcnTheme.headingH4.copyWith(
fontWeight: FontWeight.bold,
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.print),
tooltip: '프린트',
onPressed: _printPopupData,
ElevatedButton.icon(
onPressed: _addBranch,
icon: const Icon(Icons.add, size: 16),
label: const Text('지점 추가'),
style: ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
foregroundColor: Colors.white,
minimumSize: const Size(100, 36),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.pop(context),
tooltip: '닫기',
),
],
),
],
),
const SizedBox(height: 16),
const SizedBox(height: 24),
// 콘텐츠
Expanded(
child: ShadcnCard(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container(
width: maxDialogWidth - 48,
constraints: BoxConstraints(minWidth: 900),
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: DataTable(
columns: const [
DataColumn(label: Text('번호')),
DataColumn(label: Text('구분')),
DataColumn(label: Text('회사명')),
DataColumn(label: Text('유형')),
DataColumn(label: Text('주소')),
DataColumn(label: Text('담당자')),
DataColumn(label: Text('직책')),
DataColumn(label: Text('전화번호')),
DataColumn(label: Text('이메일')),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: ShadcnTheme.destructive,
),
const SizedBox(height: 16),
Text(
'데이터 로드 실패',
style: ShadcnTheme.headingH5,
),
const SizedBox(height: 8),
Text(
_error!,
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadBranches,
child: const Text('다시 시도'),
),
],
rows:
displayList.asMap().entries.map((entry) {
final int index = entry.key;
final data = entry.value;
return DataRow(
cells: [
DataCell(Text('${index + 1}')),
DataCell(Text(data['type'])),
DataCell(Text(data['name'])),
DataCell(
Row(
children:
(data['companyTypes']
as List<CompanyType>)
.map(
(type) => Container(
margin:
const EdgeInsets.only(
right: 4,
),
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color:
type ==
CompanyType
.customer
? Colors
.blue
.shade50
: Colors
.green
.shade50,
borderRadius:
BorderRadius.circular(
8,
),
),
child: Text(
companyTypeToString(type),
style: TextStyle(
color:
type ==
CompanyType
.customer
? Colors
.blue
.shade800
: Colors
.green
.shade800,
fontWeight:
FontWeight.bold,
fontSize: 14,
),
),
),
)
.toList(),
),
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 본사 정보
_buildHeadquartersCard(),
const SizedBox(height: 16),
// 지점 목록
if (_branches.isNotEmpty) ...[
Text(
'지점 목록 (${_branches.length}개)',
style: ShadcnTheme.headingH5.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
..._branches.map((branch) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildBranchCard(branch),
)),
] else ...[
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: ShadcnTheme.border,
style: BorderStyle.solid,
),
),
child: Column(
children: [
Icon(
Icons.domain_outlined,
size: 48,
color: ShadcnTheme.muted,
),
const SizedBox(height: 12),
Text(
'등록된 지점이 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.muted,
),
),
DataCell(Text(data['address'].toString())),
DataCell(Text(data['contactName'] ?? '')),
DataCell(
Text(data['contactPosition'] ?? ''),
const SizedBox(height: 8),
Text(
'지점 추가 버튼을 클릭하여 첫 지점을 등록해보세요',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.muted,
),
textAlign: TextAlign.center,
),
DataCell(Text(data['contactPhone'] ?? '')),
DataCell(Text(data['contactEmail'] ?? '')),
],
);
}).toList(),
),
),
],
],
),
),
),
),
),
),
],
),
@@ -370,4 +468,4 @@ class CompanyBranchDialog extends StatelessWidget {
),
);
}
}
}

View File

@@ -7,6 +7,7 @@ import 'package:superport/services/company_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/core/utils/debug_logger.dart';
import 'package:superport/core/utils/equipment_status_converter.dart';
/// 장비 입고 폼 컨트롤러
///
@@ -72,11 +73,13 @@ class EquipmentInFormController extends ChangeNotifier {
List<String> partnerCompanies = [];
// 새로운 필드들 (백엔드 API 구조 변경 대응)
int? currentCompanyId;
int? currentBranchId;
DateTime? lastInspectionDate;
DateTime? nextInspectionDate;
String? equipmentStatus;
double? purchasePrice; // 구매 가격
int? currentCompanyId; // 현재 회사 ID
int? warehouseLocationId; // 창고 위치 ID
int? currentBranchId; // 현재 지점 ID (Deprecated)
DateTime? lastInspectionDate; // 최근 점검일
DateTime? nextInspectionDate; // 다음 점검일
String? equipmentStatus; // 장비 상태
final TextEditingController remarkController = TextEditingController();
@@ -195,16 +198,12 @@ class EquipmentInFormController extends ChangeNotifier {
final equipment = await _equipmentService.getEquipmentDetail(actualEquipmentId!);
print('DEBUG [_loadEquipmentIn] Equipment loaded from service');
// toJson() 호출 전에 예외 처리
try {
final equipmentJson = equipment.toJson();
print('DEBUG [_loadEquipmentIn] Equipment JSON: $equipmentJson');
DebugLogger.log('장비 정보 로드 성공', tag: 'EQUIPMENT_IN', data: {
'equipment': equipmentJson,
});
} catch (jsonError) {
print('DEBUG [_loadEquipmentIn] Error converting to JSON: $jsonError');
}
print('DEBUG [_loadEquipmentIn] Equipment loaded successfully');
DebugLogger.log('장비 정보 로드 성공', tag: 'EQUIPMENT_IN', data: {
'equipmentId': equipment.id,
'manufacturer': equipment.manufacturer,
'name': equipment.name,
});
// 장비 정보 설정
print('DEBUG [_loadEquipmentIn] Setting equipment data...');
@@ -246,7 +245,15 @@ class EquipmentInFormController extends ChangeNotifier {
currentBranchId = equipment.currentBranchId;
lastInspectionDate = equipment.lastInspectionDate;
nextInspectionDate = equipment.nextInspectionDate;
equipmentStatus = equipment.equipmentStatus ?? 'available'; // 기본값: 사용 가능
// 유효한 장비 상태 목록 (클라이언트 형식으로 변환)
const validServerStatuses = ['available', 'inuse', 'maintenance', 'disposed'];
if (equipment.equipmentStatus != null && validServerStatuses.contains(equipment.equipmentStatus)) {
// 서버 상태를 클라이언트 상태로 변환하여 저장
equipmentStatus = EquipmentStatusConverter.serverToClient(equipment.equipmentStatus);
} else {
// 기본값: 입고 상태 (클라이언트 형식)
equipmentStatus = 'I'; // 입고
}
// 입고 관련 정보는 현재 API에서 제공하지 않으므로 기본값 사용
inDate = equipment.inDate ?? DateTime.now();
@@ -347,16 +354,19 @@ class EquipmentInFormController extends ChangeNotifier {
serialNumber: hasSerialNumber ? serialNumber : null,
barcode: barcode.isNotEmpty ? barcode : null,
quantity: quantity,
remark: remarkController.text.trim(),
inDate: inDate, // 구매일 매핑
remark: remarkController.text.trim().isEmpty ? null : remarkController.text.trim(),
warrantyLicense: warrantyLicense,
warrantyStartDate: warrantyStartDate,
warrantyEndDate: warrantyEndDate,
// 새로운 필드들 추가
// 백엔드 API 새로운 필드들 매핑
purchasePrice: purchasePrice,
currentCompanyId: currentCompanyId,
currentBranchId: currentBranchId,
warehouseLocationId: warehouseLocationId,
currentBranchId: currentBranchId, // Deprecated but kept for compatibility
lastInspectionDate: lastInspectionDate,
nextInspectionDate: nextInspectionDate,
equipmentStatus: equipmentStatus,
equipmentStatus: equipmentStatus, // 클라이언트 형식 ('I', 'O' 등)
warrantyStartDate: warrantyStartDate,
warrantyEndDate: warrantyEndDate,
// 워런티 코드 저장 필요시 여기에 추가
);
@@ -369,7 +379,9 @@ class EquipmentInFormController extends ChangeNotifier {
DebugLogger.log('장비 정보 업데이트 시작', tag: 'EQUIPMENT_IN', data: {
'equipmentId': actualEquipmentId,
'data': equipment.toJson(),
'manufacturer': equipment.manufacturer,
'name': equipment.name,
'serialNumber': equipment.serialNumber,
});
await _equipmentService.updateEquipment(actualEquipmentId!, equipment);

View File

@@ -222,13 +222,19 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
Future<void> deleteEquipment(int id, String status) async {
await ErrorHandler.handleApiCall<void>(
() => _equipmentService.deleteEquipment(id),
onError: (failure) {
throw failure;
},
);
removeItemLocally((e) => e.equipment.id == id && e.status == status);
// removeItemLocally((e) => e.equipment.id == id && e.status == status); // 로컬 삭제 대신 서버에서 새로고침
// 선택 목록에서도 제거
final equipmentKey = '$id:$status';
selectedEquipmentIds.remove(equipmentKey);
// 삭제 후 리스트 새로고침 (서버에서 데이터 다시 가져오기)
await refresh();
}
/// 선택된 장비 일괄 삭제

View File

@@ -229,7 +229,6 @@ class EquipmentOutFormController extends ChangeNotifier {
equipmentId: equipment.id!,
quantity: equipment.quantity,
companyId: companyId,
branchId: branchId,
notes: note ?? remarkController.text,
);
}
@@ -240,7 +239,6 @@ class EquipmentOutFormController extends ChangeNotifier {
equipmentId: selectedEquipment!.id!,
quantity: selectedEquipment!.quantity,
companyId: companyId,
branchId: branchId,
notes: note ?? remarkController.text,
);
}

View File

@@ -316,6 +316,12 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
super.dispose();
}
/// 유효한 장비 상태 값을 반환하는 메서드
String? _getValidEquipmentStatus(String? status) {
const validStatuses = ['available', 'inuse', 'maintenance', 'disposed'];
return validStatuses.contains(status) ? status : null;
}
// 포커스 변경 리스너 함수들
void _onPartnerFocusChange() {
if (!_partnerFocusNode.hasFocus) {
@@ -2534,7 +2540,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
label: '장비 상태',
required: false,
child: DropdownButtonFormField<String>(
value: _controller.equipmentStatus,
value: _getValidEquipmentStatus(_controller.equipmentStatus),
decoration: const InputDecoration(
hintText: '장비 상태를 선택하세요',
),

View File

@@ -394,33 +394,24 @@ class _EquipmentListState extends State<EquipmentList> {
TextButton(
onPressed: () async {
Navigator.pop(context);
// 로딩 다이얼로그 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
// Controller를 통한 삭제 처리
await _controller.deleteEquipment(equipment.equipment.id!, equipment.status);
// 로딩 다이얼로그 닫기
if (mounted) Navigator.pop(context);
// 삭제 후 리스트 새로고침 (서버에서 10개 다시 가져오기)
if (mounted) {
setState(() {
_controller.loadData(isRefresh: true);
});
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('장비가 삭제되었습니다.')),
);
try {
// Controller를 통한 삭제 처리 (내부에서 refresh() 호출)
await _controller.deleteEquipment(equipment.equipment.id!, equipment.status);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('장비가 삭제되었습니다.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('삭제 실패: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
@@ -762,6 +753,8 @@ class _EquipmentListState extends State<EquipmentList> {
totalWidth += 120; // 현재 위치
totalWidth += 100; // 창고 위치
totalWidth += 100; // 점검일
totalWidth += 100; // 구매일
totalWidth += 100; // 구매가격
}
// padding 추가 (좌우 각 16px)
@@ -867,6 +860,8 @@ class _EquipmentListState extends State<EquipmentList> {
_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: 100),
_buildHeaderCell('구매가격', flex: 2, useExpanded: useExpanded, minWidth: 100),
],
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90),
@@ -1016,6 +1011,30 @@ class _EquipmentListState extends State<EquipmentList> {
useExpanded: useExpanded,
minWidth: 100,
),
// 구매일
_buildDataCell(
Text(
equipment.equipment.inDate != null
? '${equipment.equipment.inDate!.year}/${equipment.equipment.inDate!.month.toString().padLeft(2, '0')}/${equipment.equipment.inDate!.day.toString().padLeft(2, '0')}'
: '-',
style: ShadcnTheme.bodySmall,
),
flex: 2,
useExpanded: useExpanded,
minWidth: 100,
),
// 구매가격
_buildDataCell(
Text(
equipment.equipment.purchasePrice != null
? '${equipment.equipment.purchasePrice!.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}'
: '-',
style: ShadcnTheme.bodySmall,
),
flex: 2,
useExpanded: useExpanded,
minWidth: 100,
),
],
// 관리
_buildDataCell(