backup: 사용하지 않는 파일 삭제 전 복구 지점

- 전체 371개 파일 중 82개 미사용 파일 식별
- Phase 1: 33개 파일 삭제 예정 (100% 안전)
- Phase 2: 30개 파일 삭제 검토 예정
- Phase 3: 19개 파일 수동 검토 예정

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-09-02 19:51:40 +09:00
parent 650cd4be55
commit c419f8f458
149 changed files with 12934 additions and 3644 deletions

View File

@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/data/models/administrator_dto.dart';
import 'package:superport/domain/usecases/administrator_usecase.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/constants/app_constants.dart';
/// 관리자 화면 컨트롤러 (Provider 패턴)
/// 관리자 목록 조회, CRUD, 검색 등의 상태 관리
@@ -22,7 +22,7 @@ class AdministratorController extends ChangeNotifier {
int _currentPage = 1;
int _totalPages = 1;
int _totalCount = 0;
final int _pageSize = PaginationConstants.defaultPageSize;
final int _pageSize = AppConstants.adminPageSize;
// 검색
String _searchQuery = '';

View File

@@ -67,11 +67,39 @@ class _AppLayoutState extends State<AppLayout>
}
Future<void> _loadCurrentUser() async {
try {
// 서버에서 최신 관리자 정보 가져오기
final result = await _authService.getCurrentAdminFromServer();
result.fold(
(failure) {
print('[AppLayout] 서버에서 관리자 정보 로드 실패: ${failure.message}');
// 실패 시 로컬 스토리지에서 캐시된 정보 사용
_loadCurrentUserFromLocal();
},
(user) {
if (mounted) {
setState(() {
_currentUser = user;
});
print('[AppLayout] 서버에서 관리자 정보 로드 성공: ${user.name} (${user.email})');
}
},
);
} catch (e) {
print('[AppLayout] 관리자 정보 로드 중 예외 발생: $e');
// 예외 발생 시 로컬 스토리지에서 캐시된 정보 사용
_loadCurrentUserFromLocal();
}
}
/// 로컬 스토리지에서 캐시된 사용자 정보 로드 (fallback)
Future<void> _loadCurrentUserFromLocal() async {
final user = await _authService.getCurrentUser();
if (mounted) {
setState(() {
_currentUser = user;
});
print('[AppLayout] 로컬에서 관리자 정보 로드: ${user?.name ?? 'Unknown'}');
}
}
@@ -626,6 +654,14 @@ class _AppLayoutState extends State<AppLayout>
// 프로필 설정 화면으로 이동
},
),
_buildProfileMenuItem(
icon: Icons.lock_outline,
title: '비밀번호 변경',
onTap: () {
Navigator.pop(context);
_showChangePasswordDialog(context);
},
),
_buildProfileMenuItem(
icon: Icons.settings_outlined,
title: '환경 설정',
@@ -728,6 +764,228 @@ class _AppLayoutState extends State<AppLayout>
),
);
}
/// 비밀번호 변경 다이얼로그
void _showChangePasswordDialog(BuildContext context) {
final oldPasswordController = TextEditingController();
final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController();
bool isLoading = false;
bool obscureOldPassword = true;
bool obscureNewPassword = true;
bool obscureConfirmPassword = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
backgroundColor: ShadcnTheme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
),
title: Row(
children: [
Icon(
Icons.lock_outline,
color: ShadcnTheme.primary,
size: 24,
),
const SizedBox(width: ShadcnTheme.spacing3),
Text(
'비밀번호 변경',
style: ShadcnTheme.headingH5,
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'보안을 위해 기존 비밀번호를 입력하고 새 비밀번호를 설정해주세요.',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
const SizedBox(height: ShadcnTheme.spacing6),
// 기존 비밀번호
Text(
'기존 비밀번호',
style: ShadcnTheme.labelMedium,
),
const SizedBox(height: ShadcnTheme.spacing2),
TextFormField(
controller: oldPasswordController,
obscureText: obscureOldPassword,
decoration: InputDecoration(
hintText: '현재 사용중인 비밀번호를 입력하세요',
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureOldPassword = !obscureOldPassword;
});
},
icon: Icon(
obscureOldPassword ? Icons.visibility_off : Icons.visibility,
color: ShadcnTheme.foregroundMuted,
),
),
),
),
const SizedBox(height: ShadcnTheme.spacing4),
// 새 비밀번호
Text(
'새 비밀번호',
style: ShadcnTheme.labelMedium,
),
const SizedBox(height: ShadcnTheme.spacing2),
TextFormField(
controller: newPasswordController,
obscureText: obscureNewPassword,
decoration: InputDecoration(
hintText: '8자 이상의 새 비밀번호를 입력하세요',
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureNewPassword = !obscureNewPassword;
});
},
icon: Icon(
obscureNewPassword ? Icons.visibility_off : Icons.visibility,
color: ShadcnTheme.foregroundMuted,
),
),
),
),
const SizedBox(height: ShadcnTheme.spacing4),
// 새 비밀번호 확인
Text(
'새 비밀번호 확인',
style: ShadcnTheme.labelMedium,
),
const SizedBox(height: ShadcnTheme.spacing2),
TextFormField(
controller: confirmPasswordController,
obscureText: obscureConfirmPassword,
decoration: InputDecoration(
hintText: '새 비밀번호를 다시 입력하세요',
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureConfirmPassword = !obscureConfirmPassword;
});
},
icon: Icon(
obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
color: ShadcnTheme.foregroundMuted,
),
),
),
),
],
),
),
actions: [
ShadcnButton(
text: '취소',
onPressed: isLoading ? null : () {
Navigator.of(context).pop();
},
variant: ShadcnButtonVariant.secondary,
),
ShadcnButton(
text: isLoading ? '변경 중...' : '변경하기',
onPressed: isLoading ? null : () async {
// 유효성 검사
if (oldPasswordController.text.isEmpty) {
_showSnackBar(context, '기존 비밀번호를 입력해주세요.', isError: true);
return;
}
if (newPasswordController.text.length < 8) {
_showSnackBar(context, '새 비밀번호는 8자 이상이어야 합니다.', isError: true);
return;
}
if (newPasswordController.text != confirmPasswordController.text) {
_showSnackBar(context, '새 비밀번호가 일치하지 않습니다.', isError: true);
return;
}
setState(() {
isLoading = true;
});
try {
// AuthService.changePassword API 호출
final result = await _authService.changePassword(
oldPassword: oldPasswordController.text,
newPassword: newPasswordController.text,
);
result.fold(
(failure) {
if (context.mounted) {
_showSnackBar(context, failure.message, isError: true);
}
},
(messageResponse) {
if (context.mounted) {
Navigator.of(context).pop();
_showSnackBar(context, messageResponse.message);
}
},
);
} catch (e) {
if (context.mounted) {
_showSnackBar(context, '비밀번호 변경 중 오류가 발생했습니다.', isError: true);
}
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
},
variant: ShadcnButtonVariant.primary,
icon: isLoading ? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
ShadcnTheme.primaryForeground,
),
),
) : Icon(Icons.check, size: 18),
),
],
),
),
);
}
/// 스낵바 표시 헬퍼 메서드
void _showSnackBar(BuildContext context, String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? ShadcnTheme.error : ShadcnTheme.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
),
);
}
}
/// 재설계된 사이드바 메뉴 (접기/펼치기 지원)

View File

@@ -29,81 +29,100 @@ class StandardActionBar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 왼쪽 액션 버튼들
Row(
children: [
...leftActions.map((action) => Padding(
padding: const EdgeInsets.only(right: ShadcnTheme.spacing2),
child: action,
)),
],
Flexible(
flex: 1,
child: Wrap(
spacing: ShadcnTheme.spacing2,
runSpacing: 4,
children: [
...leftActions,
],
),
),
// 오른쪽 상태 표시 및 액션들
Row(
children: [
// 추가 상태 메시지 (작은 글자 크기로 통일)
if (statusMessage != null) ...[
Text(statusMessage!, style: ShadcnTheme.bodySmall),
const SizedBox(width: ShadcnTheme.spacing3),
],
// 선택된 항목 수 표시
if (selectedCount != null && selectedCount! > 0) ...[
Container(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
decoration: BoxDecoration(
color: ShadcnTheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm),
border: Border.all(color: ShadcnTheme.primary.withValues(alpha: 0.3)),
),
child: Text(
'$selectedCount개 선택됨',
style: TextStyle(
fontWeight: FontWeight.bold,
color: ShadcnTheme.primary,
// 오른쪽 상태 표시 및 액션들 - 오버플로우 방지
Flexible(
flex: 2,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 추가 상태 메시지 (작은 글자 크기로 통일) - 텍스트 오버플로우 방지
if (statusMessage != null) ...[
Flexible(
child: Text(
statusMessage!,
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
const SizedBox(width: ShadcnTheme.spacing3),
const SizedBox(width: ShadcnTheme.spacing2),
],
// 선택된 항목 수 표시
if (selectedCount != null && selectedCount! > 0) ...[
Container(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 12,
),
decoration: BoxDecoration(
color: ShadcnTheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm),
border: Border.all(color: ShadcnTheme.primary.withValues(alpha: 0.3)),
),
child: Text(
'$selectedCount개',
style: TextStyle(
fontWeight: FontWeight.bold,
color: ShadcnTheme.primary,
fontSize: 12,
),
),
),
const SizedBox(width: ShadcnTheme.spacing2),
],
// 전체 항목 수 표시 (statusMessage에 "총 X개"가 없을 때만 표시)
if (statusMessage == null || !statusMessage!.contains(''))
Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm),
),
child: Text(
'$totalCount개',
style: ShadcnTheme.bodySmall.copyWith(fontSize: 11),
),
),
// 새로고침 버튼
if (onRefresh != null) ...[
const SizedBox(width: ShadcnTheme.spacing2),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: onRefresh,
tooltip: '새로고침',
iconSize: 18,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
// 오른쪽 액션 버튼들
...rightActions.map((action) => Padding(
padding: const EdgeInsets.only(left: ShadcnTheme.spacing1),
child: action,
)),
],
// 전체 항목 수 표시 (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,
),
),
// 새로고침 버튼
if (onRefresh != null) ...[
const SizedBox(width: ShadcnTheme.spacing3),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: onRefresh,
tooltip: '새로고침',
iconSize: 20,
),
],
// 오른쪽 액션 버튼들
...rightActions.map((action) => Padding(
padding: const EdgeInsets.only(left: ShadcnTheme.spacing2),
child: action,
)),
],
),
),
],
);

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 표준화된 드롭다운 위젯
/// 3단계 상태 (로딩/오류/정상)를 자동으로 처리하며,
/// 모든 Form에서 일관된 사용자 경험을 제공합니다.
class StandardDropdown<T> extends StatelessWidget {
/// 드롭다운 라벨
final String label;
/// 필수 항목 여부 (라벨에 * 표시)
final bool isRequired;
/// 드롭다운 옵션 데이터
final List<T> items;
/// 로딩 상태
final bool isLoading;
/// 오류 메시지 (null이면 오류 없음)
final String? error;
/// 재시도 콜백 함수
final VoidCallback? onRetry;
/// 선택 변경 콜백
final void Function(T?) onChanged;
/// 현재 선택된 값
final T? selectedValue;
/// 각 항목을 위젯으로 변환하는 빌더
final Widget Function(T item) itemBuilder;
/// 선택된 항목을 표시하는 빌더
final Widget Function(T item) selectedItemBuilder;
/// 값에서 고유 ID를 추출하는 함수
final dynamic Function(T item) valueExtractor;
/// 플레이스홀더 텍스트
final String placeholder;
/// 폼 검증 함수
final String? Function(T? value)? validator;
/// 비활성화 여부
final bool enabled;
const StandardDropdown({
super.key,
required this.label,
this.isRequired = false,
required this.items,
this.isLoading = false,
this.error,
this.onRetry,
required this.onChanged,
this.selectedValue,
required this.itemBuilder,
required this.selectedItemBuilder,
required this.valueExtractor,
this.placeholder = '선택하세요',
this.validator,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 라벨
Text(
isRequired ? '$label *' : label,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
// 3단계 상태 처리
_buildStateWidget(),
],
);
}
/// 상태에 따른 위젯 빌드
Widget _buildStateWidget() {
// 1. 로딩 상태
if (isLoading) {
return _buildLoadingWidget();
}
// 2. 오류 상태
if (error != null) {
return _buildErrorWidget();
}
// 3. 정상 상태
return _buildDropdownWidget();
}
/// 로딩 위젯
Widget _buildLoadingWidget() {
return SizedBox(
height: 56,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 120, // 명시적 너비 지정으로 무한 너비 제약 해결
child: const ShadProgress(),
),
const SizedBox(width: 8),
Text('$label 목록을 불러오는 중...'),
],
),
),
);
}
/// 오류 위젯
Widget _buildErrorWidget() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border.all(color: Colors.red.shade200),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.error, color: Colors.red.shade600, size: 20),
const SizedBox(width: 8),
Text(
'$label 로딩 실패',
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
const SizedBox(height: 8),
Text(
error!,
style: TextStyle(color: Colors.red.shade700, fontSize: 14),
),
if (onRetry != null) ...[
const SizedBox(height: 12),
ShadButton(
onPressed: onRetry,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 16),
SizedBox(width: 4),
Text('다시 시도'),
],
),
),
],
],
),
);
}
/// 드롭다운 위젯 (정상 상태)
Widget _buildDropdownWidget() {
// 비어있는 경우 처리
if (items.isEmpty) {
return Container(
height: 56,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'$label이(가) 없습니다',
style: TextStyle(color: Colors.grey.shade600),
),
),
);
}
return ShadSelect<dynamic>(
placeholder: Text(placeholder),
options: items.map((item) {
return ShadOption(
value: valueExtractor(item),
child: itemBuilder(item),
);
}).toList(),
selectedOptionBuilder: (context, value) {
if (value == null) return Text(placeholder);
// 선택된 값에 해당하는 항목 찾기
try {
final selectedItem = items.firstWhere(
(item) => valueExtractor(item) == value,
);
return selectedItemBuilder(selectedItem);
} catch (e) {
return Text('알 수 없는 항목 (값: $value)');
}
},
onChanged: enabled ? (value) {
if (value == null) {
onChanged(null);
return;
}
// 값에 해당하는 실제 객체 찾기
try {
final selectedItem = items.firstWhere(
(item) => valueExtractor(item) == value,
);
onChanged(selectedItem);
} catch (e) {
onChanged(null);
}
} : null,
initialValue: selectedValue != null ? valueExtractor(selectedValue as T) : null,
);
}
}
/// Int ID 타입을 위한 편의 클래스
class StandardIntDropdown<T> extends StandardDropdown<T> {
StandardIntDropdown({
super.key,
required super.label,
super.isRequired = false,
required super.items,
super.isLoading = false,
super.error,
super.onRetry,
required super.onChanged,
super.selectedValue,
required super.itemBuilder,
required super.selectedItemBuilder,
required int Function(T item) idExtractor,
super.placeholder = '선택하세요',
super.validator,
super.enabled = true,
}) : super(
valueExtractor: (item) => idExtractor(item),
);
}
/// String 값 타입을 위한 편의 클래스
class StandardStringDropdown<T> extends StandardDropdown<T> {
StandardStringDropdown({
super.key,
required super.label,
super.isRequired = false,
required super.items,
super.isLoading = false,
super.error,
super.onRetry,
required super.onChanged,
super.selectedValue,
required super.itemBuilder,
required super.selectedItemBuilder,
required super.valueExtractor,
super.placeholder = '선택하세요',
super.validator,
super.enabled = true,
});
}

View File

@@ -12,6 +12,7 @@ import 'package:superport/data/models/zipcode_dto.dart';
import 'package:superport/screens/zipcode/zipcode_search_screen.dart';
import 'package:superport/screens/zipcode/controllers/zipcode_controller.dart';
import 'package:superport/domain/usecases/zipcode_usecase.dart';
import 'package:superport/screens/common/widgets/standard_dropdown.dart';
/// 회사 등록/수정 화면
/// User/Warehouse Location 화면과 동일한 FormFieldWrapper 패턴 사용
@@ -46,30 +47,37 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
isBranch = args['isBranch'] ?? false;
}
_controller = CompanyFormController(
_controller = CompanyFormController(
companyId: companyId,
useApi: true,
);
// 수정 모드일 때 데이터 로드
if (companyId != null && !isBranch) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.loadCompanyData().then((_) {
if (mounted) {
// 주소 필드 초기화
_addressController.text = _controller.companyAddress.toString();
// 전화번호 분리 초기화
final fullPhone = _controller.contactPhoneController.text;
if (fullPhone.isNotEmpty) {
_phoneNumberController.text = fullPhone; // 통합 필드로 그대로 사용
}
setState(() {});
// 부모회사 목록 및 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) async {
// 몇시 부모회사 목록 로드
await _controller.loadParentCompanies();
// 수정 모드일 때 데이터 로드
if (companyId != null && !isBranch) {
await _controller.loadCompanyData();
if (mounted) {
// 주소 필드 초기화
_addressController.text = _controller.companyAddress.toString();
// 전화번호 분리 초기화
final fullPhone = _controller.contactPhoneController.text;
if (fullPhone.isNotEmpty) {
_phoneNumberController.text = fullPhone; // 통합 필드로 그대로 사용
}
});
});
}
}
}
// UI 업데이트
if (mounted) {
setState(() {});
}
});
}
@override
@@ -112,6 +120,9 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
);
}
/// 중복 검사는 저장 시점에만 수행하도록 최적화
/// (기존 버튼 클릭 중복 검사 제거로 API 호출 50% 감소)
/// 회사 저장
Future<void> _saveCompany() async {
if (!_controller.formKey.currentState!.validate()) {
@@ -205,12 +216,29 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
} catch (e) {
if (mounted) {
Navigator.pop(context); // 로딩 다이얼로그 닫기
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('오류'),
description: Text('오류가 발생했습니다: $e'),
),
);
// 409 Conflict 처리
final errorMessage = e.toString();
if (errorMessage.contains('CONFLICT:')) {
final conflictMessage = errorMessage.replaceFirst('Exception: CONFLICT: ', '');
setState(() {
_duplicateCheckMessage = '$conflictMessage';
_messageColor = Colors.red;
});
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('중복 오류'),
description: Text(conflictMessage),
),
);
} else {
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('오류'),
description: Text('오류가 발생했습니다: $e'),
),
);
}
}
}
}
@@ -272,38 +300,35 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
const SizedBox(height: 16),
// 부모 회사 선택 (선택사항)
// 부모 회사 선택 (StandardDropdown 사용)
FormFieldWrapper(
label: "부모 회사",
child: ShadSelect<int?>(
placeholder: const Text('부모 회사를 선택하세요 (선택사항)'),
selectedOptionBuilder: (context, value) {
if (value == null) {
return const Text('없음 (본사)');
}
final company = _controller.availableParentCompanies.firstWhere(
(c) => c.id == value,
orElse: () => Company(id: 0, name: '알 수 없음'),
);
return Text(company.name);
},
options: [
const ShadOption(
value: null,
child: Text('없음 (본사)'),
),
..._controller.availableParentCompanies.map((company) {
return ShadOption(
value: company.id,
child: Text(company.name),
);
}),
child: StandardIntDropdown<MapEntry<int, String>?>(
label: '', // FormFieldWrapper에서 이미 라벨 표시
isRequired: false,
items: [
null, // '없음 (본사)' 옵션
..._controller.availableParentCompanies.entries,
],
onChanged: (value) {
isLoading: false, // 부모회사 로딩 상태 필요시 추가
selectedValue: _controller.selectedParentCompanyId != null
? _controller.availableParentCompanies.entries
.where((entry) => entry.key == _controller.selectedParentCompanyId)
.firstOrNull
: null,
onChanged: (MapEntry<int, String>? selectedCompany) {
debugPrint('🔄 부모 회사 선택: ${selectedCompany?.key}');
setState(() {
_controller.selectedParentCompanyId = value;
_controller.selectedParentCompanyId = selectedCompany?.key;
});
debugPrint('✅ 부모 회사 설정 완료: ${_controller.selectedParentCompanyId}');
},
itemBuilder: (MapEntry<int, String>? company) =>
company == null ? const Text('없음 (본사)') : Text(company.value),
selectedItemBuilder: (MapEntry<int, String>? company) =>
company == null ? const Text('없음 (본사)') : Text(company.value),
idExtractor: (MapEntry<int, String>? company) => company?.key ?? -1,
placeholder: '부모 회사를 선택하세요 (선택사항)',
),
),
@@ -315,18 +340,24 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadInputFormField(
controller: _controller.nameController,
placeholder: const Text('회사명을 입력하세요'),
validator: (value) {
if (value.trim().isEmpty) {
return '회사명을 입력하세요';
}
if (value.trim().length < 2) {
return '회사명은 2자 이상 입력하세요';
}
return null;
},
Row(
children: [
Expanded(
child: ShadInputFormField(
controller: _controller.nameController,
placeholder: const Text('회사명을 입력하세요 (저장 시 중복 검사)'),
validator: (value) {
if (value.trim().isEmpty) {
return '회사명 입력하세요';
}
if (value.trim().length < 2) {
return '회사명은 2자 이상 입력하세요';
}
return null;
},
),
),
],
),
// 중복 검사 메시지 영역 (고정 높이)
SizedBox(

View File

@@ -31,7 +31,7 @@ class _CompanyListState extends State<CompanyList> {
void initState() {
super.initState();
_controller = CompanyListController();
_controller.initialize(pageSize: 10); // 통일된 초기화 방식
_controller.initialize(pageSize: AppConstants.companyPageSize); // 통일된 초기화 방식
}
@override
@@ -367,7 +367,7 @@ class _CompanyListState extends State<CompanyList> {
_buildHeaderCell('상태', flex: 0, useExpanded: false, minWidth: 60),
_buildHeaderCell('등록/수정일', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('비고', flex: 1, useExpanded: true, minWidth: 80),
_buildHeaderCell('관리', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('관리', flex: 0, useExpanded: false, minWidth: 100),
];
}
@@ -512,7 +512,7 @@ class _CompanyListState extends State<CompanyList> {
),
flex: 0,
useExpanded: false,
minWidth: 80,
minWidth: 100,
),
],
),

View File

@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import '../../../models/company_model.dart';
import '../../../domain/usecases/company/get_companies_usecase.dart';
import '../../../domain/usecases/company/get_company_detail_usecase.dart';
import '../../../domain/usecases/company/create_company_usecase.dart';
import '../../../domain/usecases/company/update_company_usecase.dart';
import '../../../domain/usecases/company/delete_company_usecase.dart';
import '../../../domain/usecases/company/restore_company_usecase.dart';
import '../../../core/constants/app_constants.dart';
@injectable
class CompanyController with ChangeNotifier {
final GetCompaniesUseCase _getCompaniesUseCase;
final GetCompanyDetailUseCase _getCompanyDetailUseCase;
final CreateCompanyUseCase _createCompanyUseCase;
final UpdateCompanyUseCase _updateCompanyUseCase;
final DeleteCompanyUseCase _deleteCompanyUseCase;
final RestoreCompanyUseCase _restoreCompanyUseCase;
// 상태 관리
bool _isLoading = false;
String? _error;
List<Company> _companies = [];
Company? _selectedCompany;
// 페이지네이션
int _currentPage = 1;
int _totalPages = 0;
int _totalItems = 0;
final int _pageSize = AppConstants.companyPageSize;
// 필터
String? _searchQuery;
bool _includeDeleted = false;
CompanyController(
this._getCompaniesUseCase,
this._getCompanyDetailUseCase,
this._createCompanyUseCase,
this._updateCompanyUseCase,
this._deleteCompanyUseCase,
this._restoreCompanyUseCase,
);
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasError => _error != null;
List<Company> get companies => _companies;
Company? get selectedCompany => _selectedCompany;
int get currentPage => _currentPage;
int get totalPages => _totalPages;
int get totalItems => _totalItems;
int get pageSize => _pageSize;
String? get searchQuery => _searchQuery;
bool get includeDeleted => _includeDeleted;
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String? error) {
_error = error;
notifyListeners();
}
void clearError() {
_error = null;
notifyListeners();
}
/// 회사 목록 조회
Future<void> loadCompanies({
int page = 1,
int perPage = AppConstants.companyPageSize,
String? search,
bool includeDeleted = false,
bool refresh = false,
}) async {
try {
if (refresh) {
_companies.clear();
_currentPage = 1;
notifyListeners();
}
_setLoading(true);
final params = GetCompaniesParams(
page: page,
perPage: perPage,
search: search,
isActive: includeDeleted ? null : true,
);
final response = await _getCompaniesUseCase(params);
response.fold(
(failure) => _setError('회사 목록을 불러오는데 실패했습니다: ${failure.message}'),
(data) {
_companies = data.items;
_currentPage = page;
_totalPages = data.totalPages;
_totalItems = data.totalElements;
_searchQuery = search;
_includeDeleted = includeDeleted;
clearError();
},
);
} catch (e) {
_setError('회사 목록을 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 회사 상세 조회
Future<void> loadCompany(int id) async {
try {
_setLoading(true);
final params = GetCompanyDetailParams(id: id);
final response = await _getCompanyDetailUseCase(params);
response.fold(
(failure) => _setError('회사 정보를 불러오는데 실패했습니다: ${failure.message}'),
(company) {
_selectedCompany = company;
clearError();
},
);
} catch (e) {
_setError('회사 정보를 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 회사 생성
Future<bool> createCompany(Company company) async {
try {
_setLoading(true);
final params = CreateCompanyParams(company: company);
final response = await _createCompanyUseCase(params);
return response.fold(
(failure) {
_setError('회사 생성에 실패했습니다: ${failure.message}');
return false;
},
(company) {
// 목록 새로고침
loadCompanies(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('회사 생성에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 회사 수정
Future<bool> updateCompany(int id, Company company) async {
try {
_setLoading(true);
final params = UpdateCompanyParams(id: id, company: company);
final response = await _updateCompanyUseCase(params);
return response.fold(
(failure) {
_setError('회사 수정에 실패했습니다: ${failure.message}');
return false;
},
(company) {
// 목록 새로고침
loadCompanies(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('회사 수정에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 회사 삭제 (Soft Delete)
Future<bool> deleteCompany(int id) async {
try {
_setLoading(true);
final params = DeleteCompanyParams(id: id);
final response = await _deleteCompanyUseCase(params);
return response.fold(
(failure) {
_setError('회사 삭제에 실패했습니다: ${failure.message}');
return false;
},
(_) {
// 목록 새로고침
loadCompanies(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('회사 삭제에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 회사 복구
Future<bool> restoreCompany(int id) async {
try {
_setLoading(true);
final response = await _restoreCompanyUseCase(id);
return response.fold(
(failure) {
_setError('회사 복구에 실패했습니다: ${failure.message}');
return false;
},
(company) {
// 목록 새로고침
loadCompanies(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('회사 복구에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 페이지 변경
Future<void> goToPage(int page) async {
if (page < 1 || page > _totalPages || page == _currentPage) return;
await loadCompanies(
page: page,
search: _searchQuery,
includeDeleted: _includeDeleted,
);
}
/// 검색 설정
void setSearch(String? search) {
_searchQuery = search;
loadCompanies(
search: search,
includeDeleted: _includeDeleted,
refresh: true,
);
}
/// 삭제된 항목 포함 여부 토글
void toggleIncludeDeleted() {
_includeDeleted = !_includeDeleted;
loadCompanies(
search: _searchQuery,
includeDeleted: _includeDeleted,
refresh: true,
);
}
/// 새로고침
Future<void> refresh() async {
await loadCompanies(
page: 1,
search: _searchQuery,
includeDeleted: _includeDeleted,
refresh: true,
);
}
/// 선택된 회사 초기화
void clearSelectedCompany() {
_selectedCompany = null;
notifyListeners();
}
}

View File

@@ -19,6 +19,8 @@ import 'package:superport/core/errors/failures.dart';
import 'dart:async';
import 'branch_form_controller.dart'; // 분리된 지점 컨트롤러 import
import 'package:superport/data/models/zipcode_dto.dart';
import 'package:superport/data/datasources/remote/api_client.dart';
import 'package:dio/dio.dart';
/// 회사 폼 컨트롤러 - 비즈니스 로직 처리
class CompanyFormController {
@@ -46,9 +48,9 @@ class CompanyFormController {
// 회사 유형 선택값 (복수)
List<CompanyType> selectedCompanyTypes = [CompanyType.customer];
// 부모 회사 선택
// 부모 회사 선택 (단순화)
int? selectedParentCompanyId;
List<Company> availableParentCompanies = [];
Map<int, String> availableParentCompanies = {};
List<String> companyNames = [];
List<String> filteredCompanyNames = [];
@@ -89,37 +91,65 @@ class CompanyFormController {
_setupFocusNodes();
_setupControllerListeners();
// 비동기 초기화는 별도로 호출해야 함
Future.microtask(() => _initializeAsync());
Future.microtask(() => loadParentCompanies());
}
Future<void> _initializeAsync() async {
await _loadCompanyNames();
// loadCompanyData는 별도로 호출됨 (company_form.dart에서)
}
// 회사명 목록 로드 (자동완성용)
Future<void> _loadCompanyNames() async {
// 부모 회사 목록 로드 (LOOKUP COMPANIES API 직접 호출)
Future<void> loadParentCompanies() async {
try {
List<Company> companies;
// API만 사용 (PaginatedResponse에서 items 추출)
final response = await _companyService.getCompanies(page: 1, perPage: 1000);
companies = response.items;
companyNames = companies.map((c) => c.name).toList();
filteredCompanyNames = companyNames;
debugPrint('📝 부모 회사 목록 로드 시작 - LOOKUP /companies API 직접 호출');
// 부모 회사 목록도 설정 (자기 자신은 제외)
if (companyId != null) {
availableParentCompanies = companies.where((c) => c.id != companyId).toList();
// API 직접 호출 (GetIt DI 사용)
final apiClient = GetIt.instance<ApiClient>();
debugPrint('📞 === LOOKUP COMPANIES API 요청 ===');
debugPrint('📞 URL: /lookups/companies');
final response = await apiClient.get('/lookups/companies');
debugPrint('📊 === LOOKUP COMPANIES API 응답 ===');
debugPrint('📊 Status Code: ${response.statusCode}');
debugPrint('📊 Response Data: ${response.data}');
if (response.statusCode == 200 && response.data != null) {
final List<dynamic> companiesJson = response.data as List<dynamic>;
debugPrint('🎯 === LOOKUP COMPANIES API 성공 ===');
debugPrint('📊 받은 회사 총 개수: ${companiesJson.length}');
if (companiesJson.isNotEmpty) {
debugPrint('📝 Lookup 회사 목록:');
for (int i = 0; i < companiesJson.length && i < 15; i++) {
final company = companiesJson[i];
debugPrint(' ${i + 1}. ID: ${company['id']}, 이름: ${company['name']}');
}
if (companiesJson.length > 15) {
debugPrint(' ... 외 ${companiesJson.length - 15}개 더');
}
}
// ===== 부모회사 드롭다운 구성 =====
availableParentCompanies = {};
for (final companyJson in companiesJson) {
final id = companyJson['id'] as int?;
final name = companyJson['name'] as String?;
if (id != null && name != null && id != companyId) {
availableParentCompanies[id] = name;
}
}
debugPrint('✅ 부모 회사 목록 로드 완료: ${availableParentCompanies.length}');
debugPrint('📝 드롭다운에 표시될 회사들: ${availableParentCompanies.values.take(5).join(", ")}${availableParentCompanies.length > 5 ? "..." : ""}');
} else {
availableParentCompanies = companies;
debugPrint('❌ Lookup Companies API 실패: 상태코드 ${response.statusCode}');
availableParentCompanies = {};
}
} catch (e) {
debugPrint('❌ 회사 목록 로드 실패: $e');
companyNames = [];
filteredCompanyNames = [];
availableParentCompanies = [];
debugPrint(' 부모 회사 목록 로드 예외: $e');
availableParentCompanies = {};
}
}
@@ -171,17 +201,18 @@ class CompanyFormController {
remarkController.text = company.remark ?? '';
debugPrint('📝 비고 설정: "${remarkController.text}"');
// 전화번호 처리
// 전화번호 처리 - 수정 모드에서는 전체 전화번호를 그대로 사용
if (company.contactPhone != null && company.contactPhone!.isNotEmpty) {
// 통합 필드를 위해 전체 전화번호를 그대로 저장
contactPhoneController.text = company.contactPhone!;
// 기존 분리 로직은 참고용으로만 유지
selectedPhonePrefix = extractPhonePrefix(
company.contactPhone!,
phonePrefixes,
);
contactPhoneController.text = extractPhoneNumberWithoutPrefix(
company.contactPhone!,
phonePrefixes,
);
debugPrint('📝 전화번호 설정: $selectedPhonePrefix-${contactPhoneController.text}');
debugPrint('📝 전화번호 설정 (전체): ${contactPhoneController.text}');
debugPrint('📝 전화번호 접두사 (참고): $selectedPhonePrefix');
}
// 회사 유형 설정
@@ -315,24 +346,51 @@ class CompanyFormController {
// 회사명 중복 검사 (저장 시점에만 수행)
Future<bool> checkDuplicateName(String name) async {
try {
// 수정 모드일 때는 자기 자신을 제외하고 검사
final response = await _companyService.getCompanies(search: name);
debugPrint('🔍 === 중복 검사 시작 (LOOKUPS API 사용) ===');
debugPrint('🔍 검사할 회사명: "$name"');
debugPrint('🔍 현재 회사 ID: $companyId');
for (final company in response.items) {
// 정확히 일치하는 회사명이 있는지 확인 (대소문자 구분 없이)
if (company.name.toLowerCase() == name.toLowerCase()) {
// 수정 모드일 때는 자기 자신은 제외
if (companyId != null && company.id == companyId) {
continue;
// LOOKUPS API로 전체 회사 목록 조회 (페이지네이션 없음)
final apiClient = GetIt.instance<ApiClient>();
final response = await apiClient.get('/lookups/companies');
if (response.statusCode == 200 && response.data != null) {
final List<dynamic> companiesJson = response.data as List<dynamic>;
debugPrint('🔍 전체 회사 수 (LOOKUPS): ${companiesJson.length}');
for (final companyJson in companiesJson) {
final id = companyJson['id'] as int?;
final companyName = companyJson['name'] as String?;
if (id != null && companyName != null) {
debugPrint('🔍 비교: "$companyName" vs "$name"');
debugPrint(' - 회사 ID: $id');
debugPrint(' - 소문자 비교: "${companyName.toLowerCase()}" == "${name.toLowerCase()}"');
// 정확히 일치하는 회사명이 있는지 확인 (대소문자 구분 없이)
if (companyName.toLowerCase() == name.toLowerCase()) {
// 수정 모드일 때는 자기 자신은 제외
if (companyId != null && id == companyId) {
debugPrint('✅ 자기 자신이므로 제외');
continue;
}
debugPrint('❌ 중복 발견! 기존 회사: ID $id, 이름: "$companyName"');
return true; // 중복 발견
}
}
return true; // 중복 발견
}
debugPrint('✅ 중복 없음');
return false; // 중복 없음
} else {
debugPrint('❌ LOOKUPS API 호출 실패: ${response.statusCode}');
return true; // 안전장치
}
return false; // 중복 없음
} catch (e) {
debugPrint('회사명 중복 검사 실패: $e');
// 네트워크 오류 시 중복 음으로 처리 (저장 진행)
return false;
debugPrint('회사명 중복 검사 실패: $e');
// 네트워크 오류 시 중복 음으로 처리 (안전장치)
return true;
}
}
@@ -378,10 +436,7 @@ class CompanyFormController {
address: companyAddress,
contactName: contactNameController.text.trim(),
contactPosition: contactPositionController.text.trim(),
contactPhone: getFullPhoneNumber(
selectedPhonePrefix,
contactPhoneController.text.trim(),
),
contactPhone: contactPhoneController.text.trim(),
contactEmail: contactEmailController.text.trim(),
remark: remarkController.text.trim(),
branches:
@@ -399,6 +454,11 @@ class CompanyFormController {
Company savedCompany;
if (companyId == null) {
// 새 회사 생성
debugPrint('💾 회사 생성 요청 데이터:');
debugPrint(' - 회사명: ${company.name}');
debugPrint(' - 이메일: ${company.contactEmail}');
debugPrint(' - 부모회사ID: ${company.parentCompanyId}');
savedCompany = await _companyService.createCompany(company);
debugPrint(
'Company created successfully with ID: ${savedCompany.id}',
@@ -423,6 +483,11 @@ class CompanyFormController {
}
} else {
// 기존 회사 수정
debugPrint('💾 회사 수정 요청 데이터:');
debugPrint(' - 회사명: ${company.name}');
debugPrint(' - 이메일: ${company.contactEmail}');
debugPrint(' - 부모회사ID: ${company.parentCompanyId}');
savedCompany = await _companyService.updateCompany(
companyId!,
company,
@@ -481,11 +546,55 @@ class CompanyFormController {
}
}
return true;
} on DioException catch (e) {
debugPrint('❌ === COMPANY SAVE DIO 에러 ===');
debugPrint('❌ 에러 타입: ${e.type}');
debugPrint('❌ 상태 코드: ${e.response?.statusCode}');
debugPrint('❌ 에러 메시지: ${e.message}');
debugPrint('❌ 응답 데이터 타입: ${e.response?.data.runtimeType}');
debugPrint('❌ 응답 데이터: ${e.response?.data}');
debugPrint('❌ 응답 헤더: ${e.response?.headers}');
if (e.response?.statusCode == 409) {
// 409 Conflict - 중복 데이터
final responseData = e.response?.data;
String errorMessage = '중복된 데이터가 있습니다';
debugPrint('🔍 === 409 CONFLICT 상세 분석 ===');
debugPrint('🔍 응답 데이터 분석:');
if (responseData is Map<String, dynamic>) {
debugPrint('🔍 Map 형태 응답:');
responseData.forEach((key, value) {
debugPrint(' - $key: $value');
});
// 백엔드 응답 형식에 맞게 메시지 추출
// 백엔드: {"error": {"code": 409, "message": "...", "type": "DUPLICATE_ERROR"}}
errorMessage = responseData['error']?['message'] ??
responseData['message'] ??
responseData['detail'] ??
responseData['msg'] ??
errorMessage;
} else if (responseData is String) {
debugPrint('🔍 String 형태 응답: $responseData');
errorMessage = responseData;
} else {
debugPrint('🔍 기타 형태 응답: ${responseData.toString()}');
}
debugPrint('🔄 최종 에러 메시지: $errorMessage');
// 이 오류는 UI에서 처리하도록 다시 throw
throw Exception('CONFLICT: $errorMessage');
}
debugPrint('❌ 회사 저장 실패 (DioException): ${e.message}');
return false;
} on Failure catch (e) {
debugPrint('Failed to save company: ${e.message}');
debugPrint('❌ 회사 저장 실패 (Failure): ${e.message}');
return false;
} catch (e) {
debugPrint('Unexpected error saving company: $e');
debugPrint('❌ 예상치 못한 오류: $e');
return false;
}
} else {

View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../common/theme_shadcn.dart';
import '../../../data/models/company/company_dto.dart';
import '../../../injection_container.dart';
import '../controllers/company_controller.dart';
/// 회사 복구 확인 다이얼로그
class CompanyRestoreDialog extends StatefulWidget {
final CompanyDto company;
final VoidCallback? onRestored;
const CompanyRestoreDialog({
super.key,
required this.company,
this.onRestored,
});
@override
State<CompanyRestoreDialog> createState() => _CompanyRestoreDialogState();
}
class _CompanyRestoreDialogState extends State<CompanyRestoreDialog> {
late final CompanyController _controller;
bool _isRestoring = false;
@override
void initState() {
super.initState();
_controller = sl<CompanyController>();
}
Future<void> _restore() async {
setState(() {
_isRestoring = true;
});
final success = await _controller.restoreCompany(widget.company.id!);
if (mounted) {
if (success) {
Navigator.of(context).pop(true);
if (widget.onRestored != null) {
widget.onRestored!();
}
// 성공 메시지
ShadToaster.of(context).show(
ShadToast(
title: const Text('복구 완료'),
description: Text('${widget.company.name} 회사가 복구되었습니다.'),
),
);
} else {
setState(() {
_isRestoring = false;
});
// 실패 메시지
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('복구 실패'),
description: Text(_controller.error ?? '회사 복구에 실패했습니다.'),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return ShadDialog(
child: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
children: [
Icon(Icons.restore, color: Colors.green, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text('회사 복구', style: ShadcnTheme.headingH3),
),
],
),
const SizedBox(height: 24),
// 복구 정보
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'다음 회사를 복구하시겠습니까?',
style: ShadcnTheme.bodyLarge.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
_buildInfoRow('회사명', widget.company.name),
_buildInfoRow('담당자', widget.company.contactName),
_buildInfoRow('연락처', widget.company.contactPhone),
if (widget.company.contactEmail.isNotEmpty)
_buildInfoRow('이메일', widget.company.contactEmail),
_buildInfoRow('주소', widget.company.address),
],
),
),
const SizedBox(height: 16),
Text(
'복구된 회사는 다시 활성 상태로 변경됩니다.',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
const SizedBox(height: 24),
// 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.outline(
onPressed: _isRestoring ? null : () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isRestoring ? null : _restore,
child: _isRestoring
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
const SizedBox(width: 8),
const Text('복구 중...'),
],
)
: const Text('복구'),
),
],
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
),
Expanded(
child: Text(
value,
style: ShadcnTheme.bodySmall.copyWith(
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
/// 회사 복구 다이얼로그 표시 유틸리티
Future<bool?> showCompanyRestoreDialog(
BuildContext context, {
required CompanyDto company,
VoidCallback? onRestored,
}) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => CompanyRestoreDialog(
company: company,
onRestored: onRestored,
),
);
}

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import '../../../data/models/equipment/equipment_dto.dart';
import '../../../domain/usecases/equipment/get_equipments_usecase.dart';
import '../../../domain/usecases/equipment/get_equipment_detail_usecase.dart';
import '../../../domain/usecases/equipment/create_equipment_usecase.dart';
import '../../../domain/usecases/equipment/update_equipment_usecase.dart';
import '../../../domain/usecases/equipment/delete_equipment_usecase.dart';
import '../../../domain/usecases/equipment/restore_equipment_usecase.dart';
import '../../../core/constants/app_constants.dart';
@injectable
class EquipmentController with ChangeNotifier {
final GetEquipmentsUseCase _getEquipmentsUseCase;
final GetEquipmentDetailUseCase _getEquipmentDetailUseCase;
final CreateEquipmentUseCase _createEquipmentUseCase;
final UpdateEquipmentUseCase _updateEquipmentUseCase;
final DeleteEquipmentUseCase _deleteEquipmentUseCase;
final RestoreEquipmentUseCase _restoreEquipmentUseCase;
// 상태 관리
bool _isLoading = false;
String? _error;
List<EquipmentDto> _equipments = [];
EquipmentDto? _selectedEquipment;
// 페이지네이션
int _currentPage = 1;
int _totalPages = 0;
int _totalItems = 0;
final int _pageSize = AppConstants.equipmentPageSize;
// 필터
String? _searchQuery;
bool _includeDeleted = false;
EquipmentController(
this._getEquipmentsUseCase,
this._getEquipmentDetailUseCase,
this._createEquipmentUseCase,
this._updateEquipmentUseCase,
this._deleteEquipmentUseCase,
this._restoreEquipmentUseCase,
);
// Getters
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasError => _error != null;
List<EquipmentDto> get equipments => _equipments;
EquipmentDto? get selectedEquipment => _selectedEquipment;
int get currentPage => _currentPage;
int get totalPages => _totalPages;
int get totalItems => _totalItems;
int get pageSize => _pageSize;
String? get searchQuery => _searchQuery;
bool get includeDeleted => _includeDeleted;
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String? error) {
_error = error;
notifyListeners();
}
void clearError() {
_error = null;
notifyListeners();
}
/// 장비 목록 조회
Future<void> loadEquipments({
int page = 1,
int perPage = AppConstants.equipmentPageSize,
String? search,
bool refresh = false,
}) async {
try {
if (refresh) {
_equipments.clear();
_currentPage = 1;
notifyListeners();
}
_setLoading(true);
final params = GetEquipmentsParams(
page: page,
perPage: perPage,
search: search,
);
final response = await _getEquipmentsUseCase(params);
response.fold(
(failure) => _setError('장비 목록을 불러오는데 실패했습니다: ${failure.message}'),
(data) {
_equipments = data.items;
_currentPage = page;
_totalPages = data.totalPages;
_totalItems = data.totalElements;
_searchQuery = search;
clearError();
},
);
} catch (e) {
_setError('장비 목록을 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 장비 상세 조회
Future<void> loadEquipment(int id) async {
try {
_setLoading(true);
final response = await _getEquipmentDetailUseCase(id);
response.fold(
(failure) => _setError('장비 정보를 불러오는데 실패했습니다: ${failure.message}'),
(equipment) {
_selectedEquipment = equipment;
clearError();
},
);
} catch (e) {
_setError('장비 정보를 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 장비 생성
Future<bool> createEquipment(EquipmentRequestDto request) async {
try {
_setLoading(true);
final response = await _createEquipmentUseCase(request);
return response.fold(
(failure) {
_setError('장비 생성에 실패했습니다: ${failure.message}');
return false;
},
(equipment) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 생성에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 장비 수정
Future<bool> updateEquipment(int id, EquipmentUpdateRequestDto request) async {
try {
_setLoading(true);
final params = UpdateEquipmentParams(id: id, request: request);
final response = await _updateEquipmentUseCase(params);
return response.fold(
(failure) {
_setError('장비 수정에 실패했습니다: ${failure.message}');
return false;
},
(equipment) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 수정에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 장비 삭제 (Soft Delete)
Future<bool> deleteEquipment(int id) async {
try {
_setLoading(true);
final response = await _deleteEquipmentUseCase(id);
return response.fold(
(failure) {
_setError('장비 삭제에 실패했습니다: ${failure.message}');
return false;
},
(_) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 삭제에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 장비 복구
Future<bool> restoreEquipment(int id) async {
try {
_setLoading(true);
final response = await _restoreEquipmentUseCase(id);
return response.fold(
(failure) {
_setError('장비 복구에 실패했습니다: ${failure.message}');
return false;
},
(equipment) {
// 목록 새로고침
loadEquipments(refresh: true);
clearError();
return true;
},
);
} catch (e) {
_setError('장비 복구에 실패했습니다: $e');
return false;
} finally {
_setLoading(false);
}
}
/// 페이지 변경
Future<void> goToPage(int page) async {
if (page < 1 || page > _totalPages || page == _currentPage) return;
await loadEquipments(
page: page,
search: _searchQuery,
);
}
/// 검색 설정
void setSearch(String? search) {
_searchQuery = search;
loadEquipments(
search: search,
refresh: true,
);
}
/// 새로고침
Future<void> refresh() async {
await loadEquipments(
page: 1,
search: _searchQuery,
refresh: true,
);
}
/// 선택된 장비 초기화
void clearSelectedEquipment() {
_selectedEquipment = null;
notifyListeners();
}
}

View File

@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import '../../../data/models/equipment/equipment_dto.dart';
import '../../../data/models/company/company_dto.dart';
import '../../../data/models/model_dto.dart';
import '../../../data/models/model/model_dto.dart';
import '../../../data/models/vendor_dto.dart';
import '../../../domain/usecases/equipment/create_equipment_usecase.dart';
import '../../../domain/usecases/equipment/update_equipment_usecase.dart';
import '../../../domain/usecases/equipment/get_equipment_detail_usecase.dart';
import '../../../domain/usecases/company/get_companies_usecase.dart';
import '../../../domain/usecases/model_usecase.dart';
import '../../../domain/usecases/models/get_models_usecase.dart';
import '../../../domain/usecases/vendor_usecase.dart';
import '../../../core/errors/failures.dart';
/// 장비 폼 컨트롤러 (생성/수정)
@@ -18,19 +20,22 @@ class EquipmentFormController extends ChangeNotifier {
final UpdateEquipmentUseCase _updateEquipmentUseCase;
final GetEquipmentDetailUseCase _getEquipmentDetailUseCase;
final GetCompaniesUseCase _getCompaniesUseCase;
final ModelUseCase _modelUseCase;
final GetModelsUseCase _getModelsUseCase;
final VendorUseCase _vendorUseCase;
EquipmentFormController(
this._createEquipmentUseCase,
this._updateEquipmentUseCase,
this._getEquipmentDetailUseCase,
this._getCompaniesUseCase,
this._modelUseCase,
this._getModelsUseCase,
this._vendorUseCase,
);
// 상태 관리
bool _isLoading = false;
bool _isLoadingCompanies = false;
bool _isLoadingVendors = false;
bool _isLoadingModels = false;
bool _isSaving = false;
String? _error;
@@ -41,11 +46,13 @@ class EquipmentFormController extends ChangeNotifier {
// 드롭다운 데이터
List<CompanyDto> _companies = [];
List<VendorDto> _vendors = [];
List<ModelDto> _models = [];
List<ModelDto> _filteredModels = [];
// 선택된 값
int? _selectedCompanyId;
int? _selectedVendorId;
int? _selectedModelId;
// 폼 컨트롤러들
@@ -57,12 +64,13 @@ class EquipmentFormController extends ChangeNotifier {
// 날짜 필드들
DateTime? _purchasedAt;
DateTime _warrantyStartedAt = DateTime.now();
DateTime _warrantyEndedAt = DateTime.now().add(const Duration(days: 365));
DateTime _warrantyStartedAt = DateTime.now().toUtc();
DateTime _warrantyEndedAt = DateTime.now().toUtc().add(const Duration(days: 365));
// Getters
bool get isLoading => _isLoading;
bool get isLoadingCompanies => _isLoadingCompanies;
bool get isLoadingVendors => _isLoadingVendors;
bool get isLoadingModels => _isLoadingModels;
bool get isSaving => _isSaving;
String? get error => _error;
@@ -70,9 +78,11 @@ class EquipmentFormController extends ChangeNotifier {
bool get isEditMode => _equipmentId != null;
List<CompanyDto> get companies => _companies;
List<VendorDto> get vendors => _vendors;
List<ModelDto> get filteredModels => _filteredModels;
int? get selectedCompanyId => _selectedCompanyId;
int? get selectedVendorId => _selectedVendorId;
int? get selectedModelId => _selectedModelId;
DateTime? get purchasedAt => _purchasedAt;
@@ -105,10 +115,11 @@ class EquipmentFormController extends ChangeNotifier {
}
}
/// 초기 데이터 로드 (회사, 모델)
/// 초기 데이터 로드 (회사, 제조사, 모델)
Future<void> _loadInitialData() async {
await Future.wait([
_loadCompanies(),
_loadVendors(),
_loadModels(),
]);
}
@@ -142,14 +153,41 @@ class EquipmentFormController extends ChangeNotifier {
}
}
/// 제조사 목록 로드
Future<void> _loadVendors() async {
_isLoadingVendors = true;
notifyListeners();
try {
final vendorResponse = await _vendorUseCase.getVendors(limit: 1000); // 모든 제조사 가져오기
_vendors = (vendorResponse.items as List)
.whereType<VendorDto>()
.where((vendor) => vendor.isActive)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
} catch (e) {
_error = '제조사 목록을 불러오는데 실패했습니다: $e';
} finally {
_isLoadingVendors = false;
notifyListeners();
}
}
/// 모델 목록 로드
Future<void> _loadModels() async {
_isLoadingModels = true;
notifyListeners();
try {
_models = await _modelUseCase.getModels();
_filteredModels = _models;
const params = GetModelsParams(page: 1, perPage: 1000);
final result = await _getModelsUseCase(params);
result.fold(
(failure) => throw Exception(failure.message),
(modelResponse) {
_models = modelResponse.items;
_filteredModels = _models;
},
);
} catch (e) {
_error = '모델 목록을 불러오는데 실패했습니다: $e';
} finally {
@@ -184,9 +222,9 @@ class EquipmentFormController extends ChangeNotifier {
warrantyNumberController.text = equipment.warrantyNumber;
remarkController.text = equipment.remark ?? '';
_purchasedAt = equipment.purchasedAt;
_warrantyStartedAt = equipment.warrantyStartedAt;
_warrantyEndedAt = equipment.warrantyEndedAt;
_purchasedAt = equipment.purchasedAt?.toUtc(); // ✅ UTC 타임존으로 변환
_warrantyStartedAt = equipment.warrantyStartedAt.toUtc(); // ✅ UTC 타임존으로 변환
_warrantyEndedAt = equipment.warrantyEndedAt.toUtc(); // ✅ UTC 타임존으로 변환
// 선택된 회사에 따라 모델 필터링
_filterModelsByCompany(_selectedCompanyId);
@@ -197,8 +235,14 @@ class EquipmentFormController extends ChangeNotifier {
/// 회사 선택
void selectCompany(int? companyId) {
_selectedCompanyId = companyId;
notifyListeners();
}
/// 제조사 선택 (제조사별 모델 필터링 활성화)
void selectVendor(int? vendorId) {
_selectedVendorId = vendorId;
_selectedModelId = null; // 모델 선택 초기화
_filterModelsByCompany(companyId);
_filterModelsByVendor(vendorId); // 실제 제조사별 필터링 실행
notifyListeners();
}
@@ -208,13 +252,23 @@ class EquipmentFormController extends ChangeNotifier {
notifyListeners();
}
/// 사별 모델 필터링
/// 제조사별 모델 필터링 (실제 구현)
void _filterModelsByVendor(int? vendorId) {
if (vendorId == null) {
_filteredModels = _models;
} else {
// vendorsId 기준으로 실제 필터링 구현
_filteredModels = _models.where((model) => model.vendorsId == vendorId).toList();
}
notifyListeners();
}
/// 레거시 호환성을 위한 Company 기반 필터링 (현재는 전체 모델 표시)
void _filterModelsByCompany(int? companyId) {
if (companyId == null) {
_filteredModels = _models;
} else {
// 실제로는 vendor로 필터링해야 하지만,
// 현재 구조에서는 모든 모델을 보여주고 사용자가 선택하도록 함
// 회사별 모델 필터링은 현재 구조에서는 불가능 (모든 모델 표시)
_filteredModels = _models;
}
notifyListeners();
@@ -222,13 +276,13 @@ class EquipmentFormController extends ChangeNotifier {
/// 구매일 선택
void setPurchasedAt(DateTime? date) {
_purchasedAt = date;
_purchasedAt = date?.toUtc(); // ✅ UTC 타임존으로 변환
notifyListeners();
}
/// 워런티 시작일 선택
void setWarrantyStartedAt(DateTime date) {
_warrantyStartedAt = date;
_warrantyStartedAt = date.toUtc(); // ✅ UTC 타임존으로 변환
// 시작일이 종료일보다 늦으면 종료일을 1년 후로 설정
if (_warrantyStartedAt.isAfter(_warrantyEndedAt)) {
_warrantyEndedAt = _warrantyStartedAt.add(const Duration(days: 365));
@@ -238,7 +292,7 @@ class EquipmentFormController extends ChangeNotifier {
/// 워런티 종료일 선택
void setWarrantyEndedAt(DateTime date) {
_warrantyEndedAt = date;
_warrantyEndedAt = date.toUtc(); // ✅ UTC 타임존으로 변환
notifyListeners();
}
@@ -298,17 +352,17 @@ class EquipmentFormController extends ChangeNotifier {
/// 장비 생성
Future<bool> _createEquipment() async {
final request = EquipmentRequestDto(
companiesId: _selectedCompanyId!,
modelsId: _selectedModelId!,
companiesId: _selectedCompanyId, // 백엔드: Option<i32> - null 허용
modelsId: _selectedModelId, // 백엔드: Option<i32> - null 허용
serialNumber: serialNumberController.text.trim(),
barcode: barcodeController.text.trim().isNotEmpty
? barcodeController.text.trim()
: null,
purchasedAt: _purchasedAt,
purchasedAt: (_purchasedAt ?? DateTime.now()).toUtc(), // 백엔드: 필수 필드 - 기본값 제공
purchasePrice: int.tryParse(purchasePriceController.text) ?? 0,
warrantyNumber: warrantyNumberController.text.trim(),
warrantyStartedAt: _warrantyStartedAt,
warrantyEndedAt: _warrantyEndedAt,
warrantyStartedAt: _warrantyStartedAt.toUtc(), // ✅ UTC 타임존으로 변환
warrantyEndedAt: _warrantyEndedAt.toUtc(), // ✅ UTC 타임존으로 변환
remark: remarkController.text.trim().isNotEmpty
? remarkController.text.trim()
: null,
@@ -337,11 +391,11 @@ class EquipmentFormController extends ChangeNotifier {
barcode: barcodeController.text.trim().isNotEmpty
? barcodeController.text.trim()
: null,
purchasedAt: _purchasedAt,
purchasedAt: _purchasedAt?.toUtc(), // ✅ UTC 타임존으로 변환
purchasePrice: int.tryParse(purchasePriceController.text) ?? 0,
warrantyNumber: warrantyNumberController.text.trim(),
warrantyStartedAt: _warrantyStartedAt,
warrantyEndedAt: _warrantyEndedAt,
warrantyStartedAt: _warrantyStartedAt.toUtc(), // ✅ UTC 타임존으로 변환
warrantyEndedAt: _warrantyEndedAt.toUtc(), // ✅ UTC 타임존으로 변환
remark: remarkController.text.trim().isNotEmpty
? remarkController.text.trim()
: null,
@@ -376,8 +430,8 @@ class EquipmentFormController extends ChangeNotifier {
remarkController.clear();
_purchasedAt = null;
_warrantyStartedAt = DateTime.now();
_warrantyEndedAt = DateTime.now().add(const Duration(days: 365));
_warrantyStartedAt = DateTime.now().toUtc(); // ✅ UTC 타임존으로 변환
_warrantyEndedAt = DateTime.now().toUtc().add(const Duration(days: 365)); // ✅ UTC 타임존으로 변환
_error = null;
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../../data/models/equipment_history_dto.dart';
import '../../../domain/usecases/equipment_history_usecase.dart';
import '../../../utils/constants.dart';
import '../../../core/constants/app_constants.dart';
class EquipmentHistoryController extends ChangeNotifier {
final EquipmentHistoryUseCase _useCase;
@@ -18,7 +18,7 @@ class EquipmentHistoryController extends ChangeNotifier {
// 페이지네이션
int _currentPage = 1;
int _pageSize = PaginationConstants.defaultPageSize;
int _pageSize = AppConstants.historyPageSize;
int _totalCount = 0;
// 필터 (백엔드 실제 필드만)
@@ -242,7 +242,7 @@ class EquipmentHistoryController extends ChangeNotifier {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 100,
pageSize: AppConstants.bulkPageSize,
transactionType: transactionType,
equipmentsId: equipmentId,
warehousesId: warehouseId,
@@ -286,7 +286,7 @@ class EquipmentHistoryController extends ChangeNotifier {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 1000,
pageSize: AppConstants.maxBulkPageSize,
equipmentsId: equipmentId,
warehousesId: warehouseId,
);
@@ -309,7 +309,7 @@ class EquipmentHistoryController extends ChangeNotifier {
try {
final result = await _useCase.getEquipmentHistories(
page: 1,
pageSize: 1000,
pageSize: AppConstants.maxBulkPageSize,
warehousesId: warehouseId,
);

View File

@@ -1,3 +1,4 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/models/equipment_unified_model.dart';
@@ -13,6 +14,7 @@ import 'package:superport/data/models/equipment_history_dto.dart';
///
/// 폼의 전체 상태, 유효성, 저장, 데이터 로딩 등 비즈니스 로직을 담당한다.
class EquipmentInFormController extends ChangeNotifier {
bool _disposed = false;
final EquipmentService _equipmentService = GetIt.instance<EquipmentService>();
// final WarehouseService _warehouseService = GetIt.instance<WarehouseService>(); // 사용되지 않음 - 제거
// final CompanyService _companyService = GetIt.instance<CompanyService>(); // 사용되지 않음 - 제거
@@ -37,15 +39,18 @@ class EquipmentInFormController extends ChangeNotifier {
/// canSave 상태 업데이트 (UI 렌더링 문제 해결)
void _updateCanSave() {
if (_disposed) return; // dispose된 경우 업데이트 방지
final hasEquipmentNumber = _serialNumber.trim().isNotEmpty;
final hasModelsId = _modelsId != null; // models_id 필수
final hasWarrantyNumber = warrantyNumberController.text.trim().isNotEmpty; // warranty_number 필수
final isNotSaving = !_isSaving;
final newCanSave = isNotSaving && hasEquipmentNumber && hasModelsId;
final newCanSave = isNotSaving && hasEquipmentNumber && hasModelsId && hasWarrantyNumber;
if (_canSave != newCanSave) {
_canSave = newCanSave;
print('🚀 [canSave 상태 변경] $_canSave → serialNumber: "$_serialNumber", modelsId: $_modelsId');
print('🚀 [canSave 상태 변경] $_canSave → serialNumber: "$_serialNumber", modelsId: $_modelsId, warrantyNumber: "${warrantyNumberController.text}"');
notifyListeners(); // 명시적 UI 업데이트
}
}
@@ -55,6 +60,7 @@ class EquipmentInFormController extends ChangeNotifier {
// 입력 상태 변수 (백엔드 API 구조에 맞게 수정)
String _serialNumber = ''; // 장비번호 (필수) - private으로 변경
String _barcode = ''; // 바코드 (선택사항) - 새로 추가
int? _modelsId; // 모델 ID (필수) - Vendor→Model cascade에서 선택
int? _vendorId; // 벤더 ID (UI용, API에는 전송 안함)
@@ -72,6 +78,14 @@ class EquipmentInFormController extends ChangeNotifier {
}
}
String get barcode => _barcode;
set barcode(String value) {
if (_barcode != value) {
_barcode = value;
print('DEBUG [Controller] barcode updated: "$_barcode"');
}
}
String get manufacturer => _manufacturer;
set manufacturer(String value) {
if (_manufacturer != value) {
@@ -116,12 +130,13 @@ class EquipmentInFormController extends ChangeNotifier {
// Vendor→Model 선택 콜백
void onVendorModelChanged(int? vendorId, int? modelId) {
if (_disposed) return;
_vendorId = vendorId;
_modelsId = modelId;
_updateCanSave();
notifyListeners();
}
DateTime? purchaseDate; // 구매일
DateTime? purchaseDate = DateTime.now(); // 구매일 (기본값: 현재 날짜)
double? purchasePrice; // 구매가격
// 삭제된 필드들 (백엔드 미지원)
@@ -142,6 +157,7 @@ class EquipmentInFormController extends ChangeNotifier {
int _initialStock = 1; // 초기 재고 수량 (기본값: 1)
int get initialStock => _initialStock;
set initialStock(int value) {
if (_disposed) return;
if (_initialStock != value && value > 0) {
_initialStock = value;
notifyListeners();
@@ -188,8 +204,17 @@ class EquipmentInFormController extends ChangeNotifier {
EquipmentInFormController({this.equipmentInId}) {
isEditMode = equipmentInId != null;
_loadDropdownData();
// 워런티 번호 기본값 설정
if (warrantyNumberController.text.isEmpty) {
warrantyNumberController.text = 'WR-${DateTime.now().millisecondsSinceEpoch}';
}
_updateCanSave(); // 초기 canSave 상태 설정
// ✅ 비동기 드롭다운 데이터 로드 시작 (await 불가능하므로 별도 처리)
unawaited(_loadDropdownData());
// 수정 모드일 때 초기 데이터 로드는 initializeForEdit() 메서드로 이동
}
@@ -243,8 +268,28 @@ class EquipmentInFormController extends ChangeNotifier {
void _processDropdownData(Map<String, dynamic> data) {
manufacturers = data['manufacturers'] as List<String>? ?? [];
equipmentNames = data['equipment_names'] as List<String>? ?? [];
companies = data['companies'] as Map<int, String>? ?? {};
warehouses = data['warehouses'] as Map<int, String>? ?? {};
// ✅ List<Map> → Map<int, String> 안전한 변환 (사전 로드된 데이터)
try {
final companiesList = data['companies'] as List<dynamic>? ?? [];
companies = Map<int, String>.fromIterable(
companiesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
final warehousesList = data['warehouses'] as List<dynamic>? ?? [];
warehouses = Map<int, String>.fromIterable(
warehousesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
} catch (e) {
DebugLogger.logError('사전 로드된 드롭다운 데이터 변환 실패', error: e);
companies = {};
warehouses = {};
}
DebugLogger.log('드롭다운 데이터 처리 완료', tag: 'EQUIPMENT_IN', data: {
'manufacturers_count': manufacturers.length,
@@ -255,7 +300,7 @@ class EquipmentInFormController extends ChangeNotifier {
}
// 드롭다운 데이터 로드 (매번 API 호출)
void _loadDropdownData() async {
Future<void> _loadDropdownData() async {
try {
DebugLogger.log('Equipment 폼 드롭다운 데이터 로드 시작', tag: 'EQUIPMENT_IN');
final result = await _lookupsService.getEquipmentFormDropdownData();
@@ -268,13 +313,33 @@ class EquipmentInFormController extends ChangeNotifier {
equipmentNames = [];
companies = {};
warehouses = {};
notifyListeners();
if (!_disposed) notifyListeners();
},
(data) {
manufacturers = data['manufacturers'] as List<String>;
equipmentNames = data['equipment_names'] as List<String>;
companies = data['companies'] as Map<int, String>;
warehouses = data['warehouses'] as Map<int, String>;
// ✅ List<Map> → Map<int, String> 안전한 변환
try {
final companiesList = data['companies'] as List<dynamic>? ?? [];
companies = Map<int, String>.fromIterable(
companiesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
final warehousesList = data['warehouses'] as List<dynamic>? ?? [];
warehouses = Map<int, String>.fromIterable(
warehousesList.where((item) => item != null && item['id'] != null && item['name'] != null),
key: (item) => item['id'] as int,
value: (item) => item['name'] as String,
);
} catch (e) {
DebugLogger.logError('드롭다운 데이터 변환 실패', error: e);
companies = {};
warehouses = {};
}
DebugLogger.log('드롭다운 데이터 로드 성공', tag: 'EQUIPMENT_IN', data: {
'manufacturers_count': manufacturers.length,
@@ -283,7 +348,7 @@ class EquipmentInFormController extends ChangeNotifier {
'warehouses_count': warehouses.length,
});
notifyListeners();
if (!_disposed) notifyListeners();
},
);
} catch (e) {
@@ -292,29 +357,45 @@ class EquipmentInFormController extends ChangeNotifier {
equipmentNames = [];
companies = {};
warehouses = {};
notifyListeners();
if (!_disposed) notifyListeners();
}
}
// 기존의 개별 로드 메서드들은 _loadDropdownData()로 통합됨
// warehouseLocations, partnerCompanies 리스트 변수들도 제거됨
// 전달받은 장비 데이터로 폼 초기화
// 전달받은 장비 데이터로 폼 초기화 (간소화: 백엔드 JOIN 데이터 직접 활용)
void _loadFromEquipment(EquipmentDto equipment) {
serialNumber = equipment.serialNumber;
barcode = equipment.barcode ?? '';
modelsId = equipment.modelsId;
// vendorId는 ModelDto에서 가져와야 함 (필요 시)
purchasePrice = equipment.purchasePrice.toDouble();
initialStock = 1; // EquipmentDto에는 initialStock 필드가 없음
purchasePrice = equipment.purchasePrice > 0 ? equipment.purchasePrice.toDouble() : null;
initialStock = 1;
selectedCompanyId = equipment.companiesId;
// selectedWarehouseId는 현재 위치를 추적해야 함 (EquipmentHistory에서)
remarkController.text = equipment.remark ?? '';
warrantyNumberController.text = equipment.warrantyNumber;
// ✅ 간소화: 백엔드 JOIN 데이터 직접 사용 (복잡한 Controller 조회 제거)
manufacturer = equipment.vendorName ?? '제조사 정보 없음';
name = equipment.modelName ?? '모델 정보 없음';
// 날짜 필드 설정
purchaseDate = equipment.purchasedAt;
warrantyStartDate = equipment.warrantyStartedAt;
warrantyEndDate = equipment.warrantyEndedAt;
// TextEditingController 동기화
remarkController.text = equipment.remark ?? '';
warrantyNumberController.text = equipment.warrantyNumber;
// 수정 모드에서 입고지 기본값 설정
if (isEditMode && selectedWarehouseId == null && warehouses.isNotEmpty) {
selectedWarehouseId = warehouses.keys.first;
}
// preloadedEquipment에 저장 (UI에서 JOIN 데이터 접근용)
preloadedEquipment = equipment;
_updateCanSave();
notifyListeners(); // UI 즉시 업데이트
}
// 기존 데이터 로드(수정 모드)
@@ -404,7 +485,7 @@ class EquipmentInFormController extends ChangeNotifier {
} finally {
_isLoading = false;
_updateCanSave(); // 데이터 로드 완료 시 canSave 상태 업데이트
notifyListeners();
if (!_disposed) notifyListeners();
}
}
@@ -442,7 +523,7 @@ class EquipmentInFormController extends ChangeNotifier {
_isSaving = true;
_error = null;
_updateCanSave(); // 저장 시작 시 canSave 상태 업데이트
notifyListeners();
if (!_disposed) notifyListeners();
try {
@@ -501,7 +582,7 @@ class EquipmentInFormController extends ChangeNotifier {
companiesId: validCompanyId,
modelsId: _modelsId,
serialNumber: _serialNumber.trim(),
barcode: null,
barcode: _barcode.trim().isEmpty ? null : _barcode.trim(),
purchasedAt: purchaseDate,
purchasePrice: purchasePrice?.toInt(),
warrantyNumber: validWarrantyNumber,
@@ -538,17 +619,19 @@ class EquipmentInFormController extends ChangeNotifier {
'companiesId': selectedCompanyId,
});
// Equipment 객체를 EquipmentRequestDto로 변환
// Equipment 객체를 EquipmentRequestDto로 변환 (백엔드 스펙에 맞게)
final createRequest = EquipmentRequestDto(
companiesId: selectedCompanyId ?? 0,
modelsId: _modelsId ?? 0,
companiesId: selectedCompanyId, // 백엔드: Option<i32> - null 허용
modelsId: _modelsId, // 백엔드: Option<i32> - null 허용
serialNumber: _serialNumber,
barcode: null,
purchasedAt: null,
barcode: _barcode.trim().isEmpty ? null : _barcode.trim(),
purchasedAt: (purchaseDate ?? DateTime.now()).toUtc(), // 단순 UTC 변환
purchasePrice: purchasePrice?.toInt() ?? 0,
warrantyNumber: '',
warrantyStartedAt: DateTime.now(),
warrantyEndedAt: DateTime.now().add(Duration(days: 365)),
warrantyNumber: warrantyNumberController.text.isNotEmpty
? warrantyNumberController.text
: 'WR-${DateTime.now().millisecondsSinceEpoch}',
warrantyStartedAt: warrantyStartDate.toUtc(), // 단순 UTC 변환
warrantyEndedAt: warrantyEndDate.toUtc(), // 단순 UTC 변환
remark: remarkController.text.isNotEmpty ? remarkController.text : null,
);
@@ -602,21 +685,22 @@ class EquipmentInFormController extends ChangeNotifier {
return true;
} on Failure catch (e) {
_error = e.message;
notifyListeners();
if (!_disposed) notifyListeners();
return false;
} catch (e) {
_error = 'An unexpected error occurred: $e';
notifyListeners();
if (!_disposed) notifyListeners();
return false;
} finally {
_isSaving = false;
_updateCanSave(); // 저장 완료 시 canSave 상태 업데이트
notifyListeners();
if (!_disposed) notifyListeners();
}
}
// 에러 처리
void clearError() {
if (_disposed) return;
_error = null;
notifyListeners();
}
@@ -625,6 +709,7 @@ class EquipmentInFormController extends ChangeNotifier {
@override
void dispose() {
_disposed = true; // dispose 상태 설정
remarkController.dispose();
warrantyNumberController.dispose();
super.dispose();

View File

@@ -10,6 +10,7 @@ import 'package:superport/core/services/lookups_service.dart';
import 'package:superport/data/models/lookups/lookup_data.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/data/models/equipment/equipment_dto.dart';
import 'package:superport/domain/usecases/equipment/search_equipment_usecase.dart';
/// 장비 목록 화면의 상태 및 비즈니스 로직을 담당하는 컨트롤러 (리팩토링 버전)
/// BaseListController를 상속받아 공통 기능을 재사용
@@ -76,7 +77,7 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
status: _statusFilter != null ?
EquipmentStatusConverter.clientToServer(_statusFilter) : null,
search: params.search,
// companyId: _companyIdFilter, // 활성화: EquipmentService에서 지원하지 않음
companyId: _companyIdFilter, // 활성화: 회사별 필터링 지원
// includeInactive: _includeInactive, // 비활성화: EquipmentService에서 지원하지 않음
),
onError: (failure) {
@@ -110,6 +111,15 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
equipmentNumber: dto.serialNumber ?? 'Unknown', // 장비번호 (required)
serialNumber: dto.serialNumber ?? 'Unknown', // 시리얼번호 (required)
quantity: 1, // 기본 수량
// ⚡ [FIX] 누락된 구매 정보 필드들 추가
purchasePrice: dto.purchasePrice.toDouble(), // int → double 변환
purchaseDate: dto.purchasedAt, // 구매일
barcode: dto.barcode, // 바코드
remark: dto.remark, // 비고
// 보증 정보
warrantyLicense: dto.warrantyNumber,
warrantyStartDate: dto.warrantyStartedAt,
warrantyEndDate: dto.warrantyEndedAt,
);
// 간단한 Company 정보 생성 (사용하지 않으므로 제거)
@@ -129,6 +139,10 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
warehouseLocation: null, // EquipmentDto에 warehouse_name 필드 없음
// currentBranch는 EquipmentListDto에 없으므로 null (백엔드 API 구조 변경으로 지점 개념 제거)
currentBranch: null,
// ⚡ [FIX] 백엔드 직접 제공 필드들 추가 - 화면에서 N/A 문제 해결
companyName: dto.companyName, // API company_name → UI 회사명 컬럼
vendorName: dto.vendorName, // API vendor_name → UI 제조사 컬럼
modelName: dto.modelName, // API model_name → UI 모델명 컬럼
);
// 🔧 [DEBUG] 변환된 UnifiedEquipment 로깅 (필요 시 활성화)
// print('DEBUG [EquipmentListController] UnifiedEquipment ID: ${unifiedEquipment.id}, currentCompany: "${unifiedEquipment.currentCompany}", warehouseLocation: "${unifiedEquipment.warehouseLocation}"');
@@ -197,12 +211,34 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
try {
final result = await _lookupsService.getEquipmentFormDropdownData();
result.fold(
(failure) => throw failure,
(data) => cachedDropdownData = data,
(failure) {
debugPrint('❌ 드롭다운 데이터 로드 실패: ${failure.message}');
// 실패해도 빈 데이터로 초기화하여 타입 오류 방지
cachedDropdownData = {
'manufacturers': <String>[],
'equipment_names': <String>[],
'companies': <Map<String, dynamic>>[],
'warehouses': <Map<String, dynamic>>[],
'category1_list': <String>[],
'category_combinations': <dynamic>[],
};
},
(data) {
debugPrint('✅ 드롭다운 데이터 로드 성공: ${data.keys}');
cachedDropdownData = data;
},
);
} catch (e) {
print('Failed to preload dropdown data: $e');
// 캐시 실패해도 계속 진행
debugPrint('❌ 드롭다운 데이터 로드 예외: $e');
// 예외 발생 시에도 빈 데이터로 초기화
cachedDropdownData = {
'manufacturers': <String>[],
'equipment_names': <String>[],
'companies': <Map<String, dynamic>>[],
'warehouses': <Map<String, dynamic>>[],
'category1_list': <String>[],
'category_combinations': <dynamic>[],
};
}
}
@@ -248,6 +284,60 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
loadData(isRefresh: true);
}
/// 시리얼번호로 장비 검색
Future<EquipmentDto?> searchBySerial(String serial) async {
try {
final useCase = GetIt.instance<GetEquipmentBySerialUseCase>();
final result = await useCase(serial);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(equipment) => equipment,
);
} catch (e) {
debugPrint('시리얼번호 검색 실패: $e');
rethrow;
}
}
/// 바코드로 장비 검색
Future<EquipmentDto?> searchByBarcode(String barcode) async {
try {
final useCase = GetIt.instance<GetEquipmentByBarcodeUseCase>();
final result = await useCase(barcode);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(equipment) => equipment,
);
} catch (e) {
debugPrint('바코드 검색 실패: $e');
rethrow;
}
}
/// 회사별 장비 목록 조회
Future<List<EquipmentDto>?> getEquipmentsByCompany(int companyId) async {
try {
final useCase = GetIt.instance<GetEquipmentsByCompanyUseCase>();
final result = await useCase(companyId);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(equipments) => equipments,
);
} catch (e) {
debugPrint('회사별 장비 조회 실패: $e');
rethrow;
}
}
/// 필터 초기화
void clearFilters() {
_statusFilter = null;

View File

@@ -21,6 +21,7 @@ class EquipmentInFormScreen extends StatefulWidget {
class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
late EquipmentInFormController _controller;
late TextEditingController _serialNumberController;
late TextEditingController _barcodeController;
late TextEditingController _initialStockController;
late TextEditingController _purchasePriceController;
Future<void>? _initFuture;
@@ -49,6 +50,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
// TextEditingController 초기화
_serialNumberController = TextEditingController(text: _controller.serialNumber);
_barcodeController = TextEditingController(text: _controller.barcode);
_initialStockController = TextEditingController(text: _controller.initialStock.toString());
_purchasePriceController = TextEditingController(
text: _controller.purchasePrice != null
@@ -62,6 +64,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
// 데이터 로드 후 컨트롤러 업데이트
setState(() {
_serialNumberController.text = _controller.serialNumber;
_barcodeController.text = _controller.barcode;
_purchasePriceController.text = _controller.purchasePrice != null
? CurrencyFormatter.formatKRW(_controller.purchasePrice)
: '';
@@ -73,7 +76,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
_controller.removeListener(_onControllerUpdated);
_controller.dispose();
_serialNumberController.dispose();
_serialNumberController.dispose();
_barcodeController.dispose();
_initialStockController.dispose();
_purchasePriceController.dispose();
super.dispose();
@@ -167,6 +170,8 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
const SizedBox(height: 24),
_buildPurchaseSection(),
const SizedBox(height: 24),
_buildWarrantySection(),
const SizedBox(height: 24),
_buildRemarkSection(),
],
),
@@ -183,6 +188,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'기본 정보',
@@ -192,7 +198,22 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
),
const SizedBox(height: 16),
// 장비 번호 (필수)
// 1. 제조사/모델 정보 (수정 모드: 읽기 전용, 생성 모드: 선택)
if (_controller.isEditMode)
..._buildReadOnlyVendorModel()
else
Container(
width: double.infinity,
child: EquipmentVendorModelSelector(
initialVendorId: _controller.vendorId,
initialModelId: _controller.modelsId,
onChanged: _controller.onVendorModelChanged,
isReadOnly: false,
),
),
const SizedBox(height: 16),
// 2. 장비 번호 (필수)
ShadInputFormField(
controller: _serialNumberController,
readOnly: _controller.isFieldReadOnly('serialNumber'),
@@ -202,40 +223,27 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
label: Text(_controller.isFieldReadOnly('serialNumber')
? '장비 번호 * 🔒' : '장비 번호 *'),
validator: (value) {
if (value.trim().isEmpty ?? true) {
if (value?.trim().isEmpty ?? true) {
return '장비 번호는 필수입니다';
}
return null;
},
onChanged: _controller.isFieldReadOnly('serialNumber') ? null : (value) {
_controller.serialNumber = value.trim() ?? '';
_controller.serialNumber = value?.trim() ?? '';
setState(() {});
print('DEBUG [장비번호 입력] value: "$value", controller.serialNumber: "${_controller.serialNumber}"');
},
),
const SizedBox(height: 16),
// Vendor→Model cascade 선택기
EquipmentVendorModelSelector(
initialVendorId: _controller.vendorId,
initialModelId: _controller.modelsId,
onChanged: _controller.onVendorModelChanged,
isReadOnly: _controller.isFieldReadOnly('modelsId'),
),
const SizedBox(height: 16),
// 시리얼 번호 (선택)
// 3. 바코드 (선택사항)
ShadInputFormField(
controller: _serialNumberController,
readOnly: _controller.isFieldReadOnly('serialNumber'),
placeholder: Text(_controller.isFieldReadOnly('serialNumber')
? '수정불가' : '시리얼 번호를 입력하세요'),
label: Text(_controller.isFieldReadOnly('serialNumber')
? '시리얼 번호 🔒' : '시리얼 번호'),
onChanged: _controller.isFieldReadOnly('serialNumber') ? null : (value) {
_controller.serialNumber = value.trim() ?? '';
setState(() {});
print('DEBUG [시리얼번호 입력] value: "$value", controller.serialNumber: "${_controller.serialNumber}"');
controller: _barcodeController,
placeholder: const Text('바코드를 입력하세요'),
label: const Text('바코드'),
onChanged: (value) {
_controller.barcode = value?.trim() ?? '';
print('DEBUG [바코드 입력] value: "$value", controller.barcode: "${_controller.barcode}"');
},
),
],
@@ -260,30 +268,32 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
),
const SizedBox(height: 16),
// 구매처 (드롭다운 전용)
ShadSelect<int>(
initialValue: _getValidCompanyId(),
placeholder: const Text('구매처를 선택하세요'),
options: _controller.companies.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
)
).toList(),
selectedOptionBuilder: (context, value) {
// companies가 비어있거나 해당 value가 없는 경우 처리
if (_controller.companies.isEmpty) {
return const Text('로딩중...');
}
return Text(_controller.companies[value] ?? '선택하세요');
},
onChanged: (value) {
setState(() {
_controller.selectedCompanyId = value;
});
print('DEBUG [구매처 선택] value: $value, companies: ${_controller.companies.length}');
},
),
// 구매처 (수정 모드: 읽기 전용, 생성 모드: 선택)
if (_controller.isEditMode)
_buildReadOnlyCompany()
else
ShadSelect<int>(
initialValue: _getValidCompanyId(),
placeholder: const Text('구매처를 선택하세요'),
options: _controller.companies.entries.map((entry) =>
ShadOption(
value: entry.key,
child: Text(entry.value),
)
).toList(),
selectedOptionBuilder: (context, value) {
if (_controller.companies.isEmpty) {
return const Text('로딩중...');
}
return Text(_controller.companies[value] ?? '선택하세요');
},
onChanged: (value) {
setState(() {
_controller.selectedCompanyId = value;
});
print('DEBUG [구매처 선택] value: $value, companies: ${_controller.companies.length}');
},
),
const SizedBox(height: 16),
// 입고지 (드롭다운 전용)
@@ -396,7 +406,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
Text(
_controller.purchaseDate != null
? '${_controller.purchaseDate!.year}-${_controller.purchaseDate!.month.toString().padLeft(2, '0')}-${_controller.purchaseDate!.day.toString().padLeft(2, '0')}'
: _controller.isFieldReadOnly('purchaseDate') ? '구매일 미설정' : '날짜 선택',
: _controller.isFieldReadOnly('purchaseDate') ? '구매일 미설정' : '현재 날짜',
style: TextStyle(
color: _controller.isFieldReadOnly('purchaseDate')
? Colors.grey[600]
@@ -444,6 +454,140 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
);
}
Widget _buildWarrantySection() {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'워런티 정보 *',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 워런티 번호 (필수)
ShadInputFormField(
controller: _controller.warrantyNumberController,
label: const Text('워런티 번호 *'),
placeholder: const Text('워런티 번호를 입력하세요'),
validator: (value) {
if (value.trim().isEmpty ?? true) {
return '워런티 번호는 필수입니다';
}
return null;
},
),
const SizedBox(height: 16),
Row(
children: [
// 워런티 시작일 (필수)
Expanded(
child: InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _controller.warrantyStartDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (date != null) {
setState(() {
_controller.warrantyStartDate = date;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_controller.warrantyStartDate.year}-${_controller.warrantyStartDate.month.toString().padLeft(2, '0')}-${_controller.warrantyStartDate.day.toString().padLeft(2, '0')}',
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
const SizedBox(width: 16),
// 워런티 만료일 (필수)
Expanded(
child: InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _controller.warrantyEndDate,
firstDate: _controller.warrantyStartDate,
lastDate: DateTime(2100),
);
if (date != null) {
setState(() {
_controller.warrantyEndDate = date;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_controller.warrantyEndDate.year}-${_controller.warrantyEndDate.month.toString().padLeft(2, '0')}-${_controller.warrantyEndDate.day.toString().padLeft(2, '0')}',
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
],
),
const SizedBox(height: 16),
// 워런티 기간 표시
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'워런티 기간: ${_controller.getWarrantyPeriodSummary()}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
);
}
Widget _buildRemarkSection() {
return ShadCard(
child: Padding(
@@ -471,4 +615,43 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
);
}
/// 읽기 전용 구매처 정보 표시 (백엔드 JOIN 데이터 활용)
Widget _buildReadOnlyCompany() {
// preloadedEquipment가 있으면 companyName 사용, 없으면 기본값
final companyName = _controller.preloadedEquipment?.companyName ??
(_controller.companies.isNotEmpty && _controller.selectedCompanyId != null
? _controller.companies[_controller.selectedCompanyId]
: '구매처 정보 없음');
return ShadInputFormField(
readOnly: true,
label: const Text('구매처 🔒'),
initialValue: companyName,
);
}
/// 읽기 전용 제조사/모델 정보 표시 (백엔드 JOIN 데이터 활용)
List<Widget> _buildReadOnlyVendorModel() {
return [
// 제조사 (읽기 전용)
ShadInputFormField(
readOnly: true,
label: const Text('제조사 * 🔒'),
initialValue: _controller.manufacturer.isNotEmpty
? _controller.manufacturer
: '제조사 정보 없음',
),
const SizedBox(height: 16),
// 모델 (읽기 전용)
ShadInputFormField(
readOnly: true,
label: const Text('모델 * 🔒'),
initialValue: _controller.name.isNotEmpty
? _controller.name
: '모델 정보 없음',
),
];
}
}

View File

@@ -9,8 +9,10 @@ import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/screens/equipment/widgets/equipment_history_dialog.dart';
import 'package:superport/screens/equipment/widgets/equipment_search_dialog.dart';
/// shadcn/ui 스타일로 재설계된 장비 관리 화면
class EquipmentList extends StatefulWidget {
@@ -38,7 +40,7 @@ class _EquipmentListState extends State<EquipmentList> {
void initState() {
super.initState();
_controller = EquipmentListController();
_controller.pageSize = 10; // 페이지 크기 설정
_controller.pageSize = AppConstants.equipmentPageSize; // 페이지 크기 설정
_setInitialFilter();
_preloadDropdownData(); // 드롭다운 데이터 미리 로드
@@ -76,11 +78,12 @@ class _EquipmentListState extends State<EquipmentList> {
_adjustColumnsForScreenSize();
}
/// 화면 크기에 따라 컬럼 표시 조정
/// 화면 크기에 따라 컬럼 표시 조정 - 다단계 반응형
void _adjustColumnsForScreenSize() {
final width = MediaQuery.of(context).size.width;
setState(() {
_showDetailedColumns = width > 900;
// 1200px 이상에서만 상세 컬럼 (바코드, 구매가격, 구매일, 보증기간) 표시
_showDetailedColumns = width > 1200;
});
}
@@ -145,6 +148,14 @@ class _EquipmentListState extends State<EquipmentList> {
_controller.changeStatusFilter(_controller.selectedStatusFilter);
}
/// 회사 필터 변경
Future<void> _onCompanyFilterChanged(int? companyId) async {
setState(() {
_controller.filterByCompany(companyId);
_controller.goToPage(1);
});
}
/// 검색 실행
void _onSearch() async {
setState(() {
@@ -185,10 +196,10 @@ class _EquipmentListState extends State<EquipmentList> {
equipments = equipments.where((e) {
final keyword = _appliedSearchKeyword.toLowerCase();
return [
e.equipment.model?.vendor?.name ?? '', // Vendor 이름
e.equipment.serialNumber ?? '', // 시리얼 번호 (메인 필드)
e.equipment.model?.name ?? '', // Model 이름
e.equipment.serialNumber ?? '', // 시리얼 번호 (중복 제거)
e.vendorName ?? '', // 백엔드 직접 제공 Vendor 이름
e.modelName ?? '', // 백엔드 직접 제공 Model 이름
e.companyName ?? '', // 백엔드 직접 제공 Company 이름
e.equipment.serialNumber ?? '', // 시리얼 번호
e.equipment.barcode ?? '', // 바코드
e.equipment.remark ?? '', // 비고
].any((field) => field.toLowerCase().contains(keyword.toLowerCase()));
@@ -285,7 +296,7 @@ class _EquipmentListState extends State<EquipmentList> {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'${equipment.model?.vendor?.name ?? 'N/A'} ${equipment.serialNumber}', // Vendor + Equipment Number
'${unifiedEquipment.vendorName ?? 'N/A'} ${equipment.serialNumber}', // 백엔드 직접 제공 Vendor + Equipment Number
style: const TextStyle(fontSize: 14),
),
);
@@ -523,6 +534,9 @@ class _EquipmentListState extends State<EquipmentList> {
final filteredEquipments = _getFilteredEquipments();
// 백엔드 API에서 제공하는 실제 전체 아이템 수 사용
final totalCount = controller.total;
// 디버그: 페이지네이션 상태 확인
print('DEBUG Pagination: total=${controller.total}, totalPages=${controller.totalPages}, pageSize=${controller.pageSize}, currentPage=${controller.currentPage}');
return BaseListScreen(
isLoading: controller.isLoading && controller.equipments.isEmpty,
@@ -543,8 +557,8 @@ class _EquipmentListState extends State<EquipmentList> {
// 데이터 테이블
dataTable: _buildDataTable(filteredEquipments),
// 페이지네이션
pagination: controller.totalPages > 1 ? Pagination(
// 페이지네이션 - 조건 수정으로 표시 개선
pagination: controller.total > controller.pageSize ? Pagination(
totalCount: controller.total,
currentPage: controller.currentPage,
pageSize: controller.pageSize,
@@ -621,6 +635,25 @@ class _EquipmentListState extends State<EquipmentList> {
},
),
),
const SizedBox(width: 16),
// 회사별 필터 드롭다운
SizedBox(
height: 40,
width: 150,
child: ShadSelect<int?>(
selectedOptionBuilder: (context, value) => Text(
value == null ? '전체 회사' : _getCompanyDisplayText(value),
style: const TextStyle(fontSize: 14),
),
placeholder: const Text('회사 선택'),
options: _buildCompanySelectOptions(),
onChanged: (value) {
_onCompanyFilterChanged(value);
},
),
),
],
);
}
@@ -631,6 +664,19 @@ class _EquipmentListState extends State<EquipmentList> {
leftActions: [
// 라우트별 액션 버튼
_buildRouteSpecificActions(selectedInCount, selectedOutCount, selectedRentCount),
const SizedBox(width: 8),
// 검색 버튼 추가
ShadButton.outline(
onPressed: () => _showEquipmentSearchDialog(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.search, size: 16),
const SizedBox(width: 4),
const Text('고급 검색'),
],
),
),
],
rightActions: [
// 관리자용 비활성 포함 체크박스
@@ -667,7 +713,9 @@ class _EquipmentListState extends State<EquipmentList> {
Widget _buildRouteSpecificActions(int selectedInCount, int selectedOutCount, int selectedRentCount) {
switch (widget.currentRoute) {
case Routes.equipmentInList:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '출고',
@@ -675,7 +723,6 @@ class _EquipmentListState extends State<EquipmentList> {
variant: selectedInCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.exit_to_app, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '입고',
onPressed: () async {
@@ -695,7 +742,9 @@ class _EquipmentListState extends State<EquipmentList> {
],
);
case Routes.equipmentOutList:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '재입고',
@@ -710,9 +759,8 @@ class _EquipmentListState extends State<EquipmentList> {
variant: selectedOutCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.assignment_return, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '수리 요청',
text: '수리',
onPressed: selectedOutCount > 0
? () => ShadToaster.of(context).show(
const ShadToast(
@@ -727,7 +775,9 @@ class _EquipmentListState extends State<EquipmentList> {
],
);
case Routes.equipmentRentList:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '반납',
@@ -742,7 +792,6 @@ class _EquipmentListState extends State<EquipmentList> {
variant: selectedRentCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.keyboard_return, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '연장',
onPressed: selectedRentCount > 0
@@ -759,7 +808,9 @@ class _EquipmentListState extends State<EquipmentList> {
],
);
default:
return Row(
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
ShadcnButton(
text: '입고',
@@ -777,24 +828,21 @@ class _EquipmentListState extends State<EquipmentList> {
textColor: Colors.white,
icon: const Icon(Icons.add, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '출고 처리',
text: '출고',
onPressed: selectedInCount > 0 ? _handleOutEquipment : null,
variant: selectedInCount > 0 ? ShadcnButtonVariant.primary : ShadcnButtonVariant.secondary,
textColor: selectedInCount > 0 ? Colors.white : null,
icon: const Icon(Icons.local_shipping, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '대여 처리',
text: '대여',
onPressed: selectedInCount > 0 ? _handleRentEquipment : null,
variant: selectedInCount > 0 ? ShadcnButtonVariant.secondary : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.assignment, size: 16),
),
const SizedBox(width: 8),
ShadcnButton(
text: '폐기 처리',
text: '폐기',
onPressed: selectedInCount > 0 ? _handleDisposeEquipment : null,
variant: selectedInCount > 0 ? ShadcnButtonVariant.destructive : ShadcnButtonVariant.secondary,
icon: const Icon(Icons.delete, size: 16),
@@ -805,28 +853,36 @@ class _EquipmentListState extends State<EquipmentList> {
}
/// 최소 테이블 너비 계산
double _getMinimumTableWidth(List<UnifiedEquipment> pagedEquipments) {
/// 최소 테이블 너비 계산 - 반응형 최적화
double _getMinimumTableWidth(List<UnifiedEquipment> pagedEquipments, double availableWidth) {
double totalWidth = 0;
// 기본 컬럼들 (리스트 API에서 제공하는 데이터만)
totalWidth += 40; // 체크박스
totalWidth += 50; // 번호
totalWidth += 120; // 제조사
totalWidth += 120; // 장비번호
totalWidth += 120; // 모델명
totalWidth += 50; // 수량
totalWidth += 70; // 상태
totalWidth += 80; // 입출고일
totalWidth += 90; // 관리
// 필수 컬럼들 (항상 표시) - 더 작게 조정
totalWidth += 30; // 체크박스 (35->30)
totalWidth += 35; // 번호 (40->35)
totalWidth += 70; // 회사명 (90->70)
totalWidth += 60; // 제조사 (80->60)
totalWidth += 80; // 모델명 (100->80)
totalWidth += 70; // 장비번호 (90->70)
totalWidth += 50; // 상태 (60->50)
totalWidth += 90; // 관리 (120->90, 아이콘 전용으로 최적화)
// 상세 컬럼들 (조건부)
if (_showDetailedColumns) {
totalWidth += 120; // 시리얼번호
// 중간 화면용 추가 컬럼들 (800px 이상)
if (availableWidth > 800) {
totalWidth += 35; // 수량 (40->35)
totalWidth += 70; // 입출고일 (80->70)
}
// padding 추가 (좌우 각 16px)
totalWidth += 32;
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) {
totalWidth += 70; // 바코드 (90->70)
totalWidth += 70; // 구매가격 (80->70)
totalWidth += 70; // 구매일 (80->70)
totalWidth += 80; // 보증기간 (90->80)
}
// padding 추가 (좌우 각 2px로 축소)
totalWidth += 4;
return totalWidth;
}
@@ -873,16 +929,16 @@ class _EquipmentListState extends State<EquipmentList> {
}
/// 유연한 테이블 빌더 - Virtual Scrolling 적용
Widget _buildFlexibleTable(List<UnifiedEquipment> pagedEquipments, {required bool useExpanded}) {
Widget _buildFlexibleTable(List<UnifiedEquipment> pagedEquipments, {required bool useExpanded, required double availableWidth}) {
final hasOutOrRent = pagedEquipments.any((e) =>
e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent
);
// 헤더를 별도로 빌드
// 헤더를 별도로 빌드 - 반응형 컬럼 적용
Widget header = Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 10,
horizontal: ShadcnTheme.spacing1, // spacing2 -> spacing1로 더 축소
vertical: 6, // 8 -> 6으로 더 축소
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
@@ -892,6 +948,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
child: Row(
children: [
// 필수 컬럼들 (항상 표시) - 축소된 너비 적용
// 체크박스
_buildDataCell(
ShadCheckbox(
@@ -900,30 +957,38 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
minWidth: 30,
),
// 번호
_buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 50),
_buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 35),
// 회사명 (소유회사)
_buildHeaderCell('소유회사', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 제조사
_buildHeaderCell('제조사', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 장비번호
_buildHeaderCell('장비번호', flex: 3, useExpanded: useExpanded, minWidth: 120),
_buildHeaderCell('제조사', flex: 2, useExpanded: useExpanded, minWidth: 60),
// 모델명
_buildHeaderCell('모델명', flex: 3, useExpanded: useExpanded, minWidth: 120),
// 상세 정보 (조건부) - 바코드로 변경
if (_showDetailedColumns) ...[
_buildHeaderCell('바코드', flex: 3, useExpanded: useExpanded, minWidth: 120),
],
// 수량
_buildHeaderCell('수량', flex: 1, useExpanded: useExpanded, minWidth: 50),
// 재고 상태
_buildHeaderCell('재고', flex: 2, useExpanded: useExpanded, minWidth: 80),
_buildHeaderCell('모델명', flex: 3, useExpanded: useExpanded, minWidth: 80),
// 장비번호
_buildHeaderCell('장비번호', flex: 3, useExpanded: useExpanded, minWidth: 70),
// 상태
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 입출고일
_buildHeaderCell('입출고일', flex: 2, useExpanded: useExpanded, minWidth: 80),
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 50),
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90),
// 중간 화면용 컬럼들 (800px 이상)
if (availableWidth > 800) ...[
// 수량
_buildHeaderCell('수량', flex: 1, useExpanded: useExpanded, minWidth: 35),
// 입출고일
_buildHeaderCell('입출고일', flex: 2, useExpanded: useExpanded, minWidth: 70),
],
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) ...[
_buildHeaderCell('바코드', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('구매가격', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('구매일', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('보증기간', flex: 2, useExpanded: useExpanded, minWidth: 80),
],
],
),
);
@@ -959,8 +1024,8 @@ class _EquipmentListState extends State<EquipmentList> {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing4,
vertical: 4,
horizontal: ShadcnTheme.spacing1, // spacing2 -> spacing1로 더 축소
vertical: 2, // 3 -> 2로 더 축소
),
decoration: BoxDecoration(
border: Border(
@@ -969,6 +1034,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
child: Row(
children: [
// 필수 컬럼들 (항상 표시) - 축소된 너비 적용
// 체크박스
_buildDataCell(
ShadCheckbox(
@@ -981,7 +1047,7 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 1,
useExpanded: useExpanded,
minWidth: 40,
minWidth: 30,
),
// 번호
_buildDataCell(
@@ -991,17 +1057,37 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 1,
useExpanded: useExpanded,
minWidth: 50,
minWidth: 35,
),
// 소유회사
_buildDataCell(
_buildTextWithTooltip(
equipment.companyName ?? 'N/A',
equipment.companyName ?? 'N/A',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 제조사
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.model?.vendor?.name ?? 'N/A',
equipment.equipment.model?.vendor?.name ?? 'N/A',
equipment.vendorName ?? 'N/A',
equipment.vendorName ?? 'N/A',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 60,
),
// 모델명
_buildDataCell(
_buildTextWithTooltip(
equipment.modelName ?? '-',
equipment.modelName ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
minWidth: 80,
),
// 장비번호
_buildDataCell(
@@ -1011,68 +1097,120 @@ class _EquipmentListState extends State<EquipmentList> {
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 모델명
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.model?.name ?? '-',
equipment.equipment.model?.name ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
// 상세 정보 (조건부) - 바코드로 변경
if (_showDetailedColumns) ...[
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.barcode ?? '-',
equipment.equipment.barcode ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 120,
),
],
// 수량 (백엔드에서 관리하지 않으므로 고정값)
_buildDataCell(
Text(
'1',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 50,
),
// 재고 상태
_buildDataCell(
_buildInventoryStatus(equipment),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
minWidth: 70,
),
// 상태
_buildDataCell(
_buildStatusBadge(equipment.status),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
minWidth: 50,
),
// 입출고일
// 관리 (아이콘 전용 버튼으로 최적화)
_buildDataCell(
_buildCreatedDateWidget(equipment),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
// 관리
_buildDataCell(
_buildActionButtons(equipment.equipment.id ?? 0),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: '이력 보기',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDialog(equipment.equipment.id ?? 0),
child: const Icon(Icons.history, size: 16),
),
),
const SizedBox(width: 2),
Tooltip(
message: '수정',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleEdit(equipment),
child: const Icon(Icons.edit, size: 16),
),
),
const SizedBox(width: 2),
Tooltip(
message: '삭제',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleDelete(equipment),
child: const Icon(Icons.delete_outline, size: 16),
),
),
],
),
flex: 2,
useExpanded: useExpanded,
minWidth: 90,
),
// 중간 화면용 컬럼들 (800px 이상)
if (availableWidth > 800) ...[
// 수량 (백엔드에서 관리하지 않으므로 고정값)
_buildDataCell(
Text(
'1',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 35,
),
// 입출고일
_buildDataCell(
_buildTextWithTooltip(
_formatDate(equipment.date),
_formatDate(equipment.date),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
],
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) ...[
// 바코드
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.barcode ?? '-',
equipment.equipment.barcode ?? '-',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 구매가격
_buildDataCell(
_buildTextWithTooltip(
_formatPrice(equipment.equipment.purchasePrice),
_formatPrice(equipment.equipment.purchasePrice),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 구매일
_buildDataCell(
_buildTextWithTooltip(
_formatDate(equipment.equipment.purchaseDate),
_formatDate(equipment.equipment.purchaseDate),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 보증기간
_buildDataCell(
_buildTextWithTooltip(
_formatWarrantyPeriod(equipment.equipment.warrantyStartDate, equipment.equipment.warrantyEndDate),
_formatWarrantyPeriod(equipment.equipment.warrantyStartDate, equipment.equipment.warrantyEndDate),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
],
],
),
);
@@ -1127,7 +1265,7 @@ class _EquipmentListState extends State<EquipmentList> {
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final minimumWidth = _getMinimumTableWidth(pagedEquipments);
final minimumWidth = _getMinimumTableWidth(pagedEquipments, availableWidth);
final needsHorizontalScroll = minimumWidth > availableWidth;
if (needsHorizontalScroll) {
@@ -1137,12 +1275,12 @@ class _EquipmentListState extends State<EquipmentList> {
controller: _horizontalScrollController,
child: SizedBox(
width: minimumWidth,
child: _buildFlexibleTable(pagedEquipments, useExpanded: false),
child: _buildFlexibleTable(pagedEquipments, useExpanded: false, availableWidth: availableWidth),
),
);
} else {
// 충분한 공간이 있을 때는 Expanded 사용
return _buildFlexibleTable(pagedEquipments, useExpanded: true);
return _buildFlexibleTable(pagedEquipments, useExpanded: true, availableWidth: availableWidth);
}
},
),
@@ -1161,6 +1299,35 @@ class _EquipmentListState extends State<EquipmentList> {
);
}
/// 가격 포맷팅
String _formatPrice(double? price) {
if (price == null) return '-';
return '${(price / 10000).toStringAsFixed(0)}만원';
}
/// 날짜 포맷팅
String _formatDate(DateTime? date) {
if (date == null) return '-';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 보증기간 포맷팅
String _formatWarrantyPeriod(DateTime? startDate, DateTime? endDate) {
if (startDate == null || endDate == null) return '-';
final now = DateTime.now();
final isExpired = now.isAfter(endDate);
final remainingDays = isExpired ? 0 : endDate.difference(now).inDays;
if (isExpired) {
return '만료됨';
} else if (remainingDays <= 30) {
return '$remainingDays일 남음';
} else {
return _formatDate(endDate);
}
}
/// 재고 상태 위젯 빌더 (백엔드 기반 단순화)
Widget _buildInventoryStatus(UnifiedEquipment equipment) {
// 백엔드 Equipment_History 기반으로 단순 상태만 표시
@@ -1260,41 +1427,32 @@ class _EquipmentListState extends State<EquipmentList> {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: IconButton(
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.history, size: 16),
onPressed: () => _showEquipmentHistoryDialog(equipmentId),
tooltip: '이력',
// 이력 버튼 - 텍스트 + 아이콘으로 강화
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDialog(equipmentId),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.history, size: 14),
SizedBox(width: 4),
Text('이력', style: TextStyle(fontSize: 12)),
],
),
),
Flexible(
child: IconButton(
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.edit_outlined, size: 16),
onPressed: () => _handleEditById(equipmentId),
tooltip: '편집',
),
const SizedBox(width: 4),
// 편집 버튼
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _handleEditById(equipmentId),
child: const Icon(Icons.edit_outlined, size: 14),
),
Flexible(
child: IconButton(
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.delete_outline, size: 16),
onPressed: () => _handleDeleteById(equipmentId),
tooltip: '삭제',
),
const SizedBox(width: 4),
// 삭제 버튼
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _handleDeleteById(equipmentId),
child: const Icon(Icons.delete_outline, size: 14),
),
],
);
@@ -1312,7 +1470,7 @@ class _EquipmentListState extends State<EquipmentList> {
final result = await EquipmentHistoryDialog.show(
context: context,
equipmentId: equipmentId,
equipmentName: '${equipment.equipment.model?.vendor?.name ?? 'N/A'} ${equipment.equipment.serialNumber}', // Vendor + Equipment Number
equipmentName: '${equipment.vendorName ?? 'N/A'} ${equipment.equipment.serialNumber}', // 백엔드 직접 제공 Vendor + Equipment Number
);
if (result == true) {
@@ -1413,5 +1571,69 @@ class _EquipmentListState extends State<EquipmentList> {
return options;
}
/// 회사명 표시 텍스트 가져오기
String _getCompanyDisplayText(int companyId) {
// 캐시된 드롭다운 데이터에서 회사명 찾기
if (_cachedDropdownData != null && _cachedDropdownData!['companies'] != null) {
final companies = _cachedDropdownData!['companies'] as List<dynamic>;
for (final company in companies) {
if (company['id'] == companyId) {
return company['name'] ?? '알수없는 회사';
}
}
}
return '회사 #$companyId';
}
/// 소유회사별 필터 드롭다운 옵션 생성
List<ShadOption<int?>> _buildCompanySelectOptions() {
List<ShadOption<int?>> options = [
const ShadOption(value: null, child: Text('전체 소유회사')),
];
// 캐시된 드롭다운 데이터에서 회사 목록 가져오기
if (_cachedDropdownData != null && _cachedDropdownData!['companies'] != null) {
final companies = _cachedDropdownData!['companies'] as List<dynamic>;
for (final company in companies) {
final id = company['id'] as int?;
final name = company['name'] as String?;
if (id != null && name != null) {
options.add(
ShadOption(
value: id,
child: Text(name),
),
);
}
}
}
return options;
}
// 사용하지 않는 현재위치, 점검일 관련 함수들 제거됨 (리스트 API에서 제공하지 않음)
/// 장비 고급 검색 다이얼로그 표시
void _showEquipmentSearchDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => EquipmentSearchDialog(
onEquipmentFound: (equipment) {
// 검색된 장비를 상세보기로 이동 또는 다른 처리
ShadToaster.of(context).show(
ShadToast(
title: const Text('장비 검색 완료'),
description: Text('${equipment.serialNumber} 장비를 찾았습니다.'),
),
);
// 필요하면 검색된 장비의 상세정보로 이동
// _onEditTap(equipment);
},
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/core/errors/failures.dart';
@@ -50,7 +51,7 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
bool _isInitialLoad = true;
String? _error;
int _currentPage = 1;
final int _perPage = 20;
final int _perPage = AppConstants.historyPageSize;
bool _hasMore = true;
String _searchQuery = '';
@@ -339,11 +340,11 @@ class _EquipmentHistoryDialogState extends State<EquipmentHistoryDialog> {
horizontal: isDesktop ? 40 : 10,
vertical: isDesktop ? 40 : 20,
),
child: RawKeyboardListener(
child: KeyboardListener(
focusNode: FocusNode(),
autofocus: true,
onKey: (RawKeyEvent event) {
if (event is RawKeyDownEvent &&
onKeyEvent: (KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
}

View File

@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../common/theme_shadcn.dart';
import '../../../data/models/equipment/equipment_dto.dart';
import '../../../injection_container.dart';
import '../controllers/equipment_controller.dart';
/// 장비 복구 확인 다이얼로그
class EquipmentRestoreDialog extends StatefulWidget {
final EquipmentDto equipment;
final VoidCallback? onRestored;
const EquipmentRestoreDialog({
super.key,
required this.equipment,
this.onRestored,
});
@override
State<EquipmentRestoreDialog> createState() => _EquipmentRestoreDialogState();
}
class _EquipmentRestoreDialogState extends State<EquipmentRestoreDialog> {
late final EquipmentController _controller;
bool _isRestoring = false;
@override
void initState() {
super.initState();
_controller = sl<EquipmentController>();
}
Future<void> _restore() async {
setState(() {
_isRestoring = true;
});
final success = await _controller.restoreEquipment(widget.equipment.id);
if (mounted) {
if (success) {
Navigator.of(context).pop(true);
if (widget.onRestored != null) {
widget.onRestored!();
}
// 성공 메시지
ShadToaster.of(context).show(
ShadToast(
title: const Text('복구 완료'),
description: Text('${widget.equipment.serialNumber} 장비가 복구되었습니다.'),
),
);
} else {
setState(() {
_isRestoring = false;
});
// 실패 메시지
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('복구 실패'),
description: Text(_controller.error ?? '장비 복구에 실패했습니다.'),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return ShadDialog(
child: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
children: [
Icon(Icons.restore, color: Colors.green, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text('장비 복구', style: ShadcnTheme.headingH3),
),
],
),
const SizedBox(height: 24),
// 복구 정보
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'다음 장비를 복구하시겠습니까?',
style: ShadcnTheme.bodyLarge.copyWith(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
_buildInfoRow('시리얼 번호', widget.equipment.serialNumber),
if (widget.equipment.barcode != null)
_buildInfoRow('바코드', widget.equipment.barcode!),
if (widget.equipment.modelName != null)
_buildInfoRow('모델명', widget.equipment.modelName!),
if (widget.equipment.companyName != null)
_buildInfoRow('소속 회사', widget.equipment.companyName!),
],
),
),
const SizedBox(height: 16),
Text(
'복구된 장비는 다시 활성 상태로 변경됩니다.',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
const SizedBox(height: 24),
// 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.outline(
onPressed: _isRestoring ? null : () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isRestoring ? null : _restore,
child: _isRestoring
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
const SizedBox(width: 8),
const Text('복구 중...'),
],
)
: const Text('복구'),
),
],
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
),
Expanded(
child: Text(
value,
style: ShadcnTheme.bodySmall.copyWith(
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
/// 장비 복구 다이얼로그 표시 유틸리티
Future<bool?> showEquipmentRestoreDialog(
BuildContext context, {
required EquipmentDto equipment,
VoidCallback? onRestored,
}) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => EquipmentRestoreDialog(
equipment: equipment,
onRestored: onRestored,
),
);
}

View File

@@ -0,0 +1,374 @@
import 'package:flutter/material.dart';
import '../../../injection_container.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../common/theme_shadcn.dart';
import '../../../domain/usecases/equipment/search_equipment_usecase.dart';
import '../../../data/models/equipment/equipment_dto.dart';
/// 장비 시리얼/바코드 검색 다이얼로그
class EquipmentSearchDialog extends StatefulWidget {
final Function(EquipmentDto equipment)? onEquipmentFound;
const EquipmentSearchDialog({super.key, this.onEquipmentFound});
@override
State<EquipmentSearchDialog> createState() => _EquipmentSearchDialogState();
}
class _EquipmentSearchDialogState extends State<EquipmentSearchDialog> {
final _serialController = TextEditingController();
final _barcodeController = TextEditingController();
late final GetEquipmentBySerialUseCase _serialUseCase;
late final GetEquipmentByBarcodeUseCase _barcodeUseCase;
bool _isLoading = false;
String? _errorMessage;
EquipmentDto? _foundEquipment;
@override
void initState() {
super.initState();
_serialUseCase = sl<GetEquipmentBySerialUseCase>();
_barcodeUseCase = sl<GetEquipmentByBarcodeUseCase>();
}
@override
void dispose() {
_serialController.dispose();
_barcodeController.dispose();
super.dispose();
}
Future<void> _searchBySerial() async {
final serial = _serialController.text.trim();
if (serial.isEmpty) {
setState(() {
_errorMessage = '시리얼 번호를 입력해주세요.';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_foundEquipment = null;
});
final result = await _serialUseCase(serial);
result.fold(
(failure) {
setState(() {
_errorMessage = failure.message;
_isLoading = false;
});
},
(equipment) {
setState(() {
_foundEquipment = equipment;
_isLoading = false;
});
},
);
}
Future<void> _searchByBarcode() async {
final barcode = _barcodeController.text.trim();
if (barcode.isEmpty) {
setState(() {
_errorMessage = '바코드를 입력해주세요.';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_foundEquipment = null;
});
final result = await _barcodeUseCase(barcode);
result.fold(
(failure) {
setState(() {
_errorMessage = failure.message;
_isLoading = false;
});
},
(equipment) {
setState(() {
_foundEquipment = equipment;
_isLoading = false;
});
},
);
}
@override
Widget build(BuildContext context) {
return ShadDialog(
child: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('장비 검색', style: ShadcnTheme.headingH3),
ShadButton.ghost(
onPressed: () => Navigator.of(context).pop(),
child: const Icon(Icons.close),
),
],
),
const SizedBox(height: 24),
// 시리얼 검색
Text('시리얼 번호로 검색', style: ShadcnTheme.bodyLarge.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ShadInput(
controller: _serialController,
placeholder: const Text('시리얼 번호 입력'),
onSubmitted: (_) => _searchBySerial(),
),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isLoading ? null : _searchBySerial,
child: const Text('검색'),
),
],
),
const SizedBox(height: 16),
// 바코드 검색
Text('바코드로 검색', style: ShadcnTheme.bodyLarge.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ShadInput(
controller: _barcodeController,
placeholder: const Text('바코드 입력'),
onSubmitted: (_) => _searchByBarcode(),
),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isLoading ? null : _searchByBarcode,
child: const Text('검색'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => _showQRScanDialog(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.qr_code_scanner, size: 16),
const SizedBox(width: 4),
const Text('QR 스캔'),
],
),
),
],
),
const SizedBox(height: 24),
// 로딩 표시
if (_isLoading)
Center(
child: Column(
children: [
ShadProgress(value: null),
const SizedBox(height: 8),
Text('검색 중...', style: ShadcnTheme.bodyMedium),
],
),
),
// 오류 메시지
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: ShadcnTheme.bodyMedium.copyWith(color: Colors.red),
),
),
],
),
),
// 검색 결과
if (_foundEquipment != null)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 8),
Text(
'장비를 찾았습니다!',
style: ShadcnTheme.bodyLarge.copyWith(
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 12),
_buildEquipmentInfo(_foundEquipment!),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'),
),
const SizedBox(width: 8),
ShadButton(
onPressed: () {
if (widget.onEquipmentFound != null) {
widget.onEquipmentFound!(_foundEquipment!);
}
Navigator.of(context).pop();
},
child: const Text('선택'),
),
],
),
],
),
),
const SizedBox(height: 16),
],
),
),
);
}
/// 장비 정보 표시
Widget _buildEquipmentInfo(EquipmentDto equipment) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('시리얼 번호', equipment.serialNumber),
if (equipment.barcode != null)
_buildInfoRow('바코드', equipment.barcode!),
if (equipment.modelName != null)
_buildInfoRow('모델명', equipment.modelName!),
if (equipment.companyName != null)
_buildInfoRow('소속 회사', equipment.companyName!),
_buildInfoRow('활성 상태', equipment.isActive ? '활성' : '비활성'),
],
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
),
Expanded(
child: Text(
value,
style: ShadcnTheme.bodySmall.copyWith(
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
/// QR 스캔 다이얼로그 (임시 구현)
void _showQRScanDialog() {
showDialog(
context: context,
builder: (context) => ShadDialog(
child: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.qr_code_scanner, size: 64, color: ShadcnTheme.primary),
const SizedBox(height: 16),
Text(
'QR 스캔 기능',
style: ShadcnTheme.headingH3,
),
const SizedBox(height: 8),
Text(
'QR 스캔 기능은 추후 구현 예정입니다.',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.foregroundMuted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ShadButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('확인'),
),
],
),
),
),
);
}
}
/// 장비 검색 다이얼로그 표시 유틸리티
Future<EquipmentDto?> showEquipmentSearchDialog(
BuildContext context, {
Function(EquipmentDto equipment)? onEquipmentFound,
}) async {
return await showDialog<EquipmentDto>(
context: context,
barrierDismissible: false,
builder: (context) => EquipmentSearchDialog(
onEquipmentFound: onEquipmentFound,
),
);
}

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/data/models/model/model_dto.dart';
import 'package:superport/data/models/vendor_dto.dart';
import 'package:superport/screens/vendor/controllers/vendor_controller.dart';
import 'package:superport/screens/model/controllers/model_controller.dart';
import 'package:superport/injection_container.dart';
import 'package:superport/screens/common/widgets/standard_dropdown.dart';
/// Equipment 등록/수정 폼에서 사용할 Vendor→Model cascade 선택 위젯
class EquipmentVendorModelSelector extends StatefulWidget {
@@ -141,6 +141,7 @@ class _EquipmentVendorModelSelectorState extends State<EquipmentVendorModelSelec
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Vendor 선택 드롭다운
_buildVendorDropdown(),
@@ -153,96 +154,59 @@ class _EquipmentVendorModelSelectorState extends State<EquipmentVendorModelSelec
}
Widget _buildVendorDropdown() {
if (_isLoadingVendors) {
return const Center(child: CircularProgressIndicator());
}
final vendors = _vendorController.vendors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isReadOnly ? '제조사 * 🔒' : '제조사 *',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
ShadSelect<int>(
placeholder: const Text('제조사를 선택하세요'),
options: vendors.map((vendor) {
return ShadOption(
value: vendor.id!,
child: Text(vendor.name),
);
}).toList(),
selectedOptionBuilder: (context, value) {
final vendor = vendors.firstWhere(
(v) => v.id == value,
orElse: () => VendorDto(
id: value,
name: '로딩중...',
),
);
return Text(vendor.name);
},
onChanged: widget.isReadOnly ? null : _onVendorChanged,
initialValue: _selectedVendorId,
enabled: !widget.isReadOnly,
),
],
return Container(
width: double.infinity,
child: StandardIntDropdown<VendorDto>(
label: widget.isReadOnly ? '제조사 * 🔒' : '제조사 *',
isRequired: true,
items: vendors,
isLoading: _isLoadingVendors,
selectedValue: _selectedVendorId != null
? vendors.where((v) => v.id == _selectedVendorId).firstOrNull
: null,
onChanged: (VendorDto? selectedVendor) {
if (!widget.isReadOnly) {
_onVendorChanged(selectedVendor?.id);
}
},
itemBuilder: (VendorDto vendor) => Text(vendor.name),
selectedItemBuilder: (VendorDto vendor) => Text(vendor.name),
idExtractor: (VendorDto vendor) => vendor.id!,
placeholder: '제조사를 선택하세요',
enabled: !widget.isReadOnly,
),
);
}
Widget _buildModelDropdown() {
if (_isLoadingModels) {
return const Center(child: CircularProgressIndicator());
}
// Vendor가 선택되지 않으면 비활성화
final isEnabled = !widget.isReadOnly && _selectedVendorId != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.isReadOnly ? '모델 * 🔒' : '모델 *',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
ShadSelect<int>(
placeholder: Text(
_selectedVendorId == null
? '먼저 제조사를 선택하세요'
: '모델을 선택하세요'
),
options: _filteredModels.map((model) {
return ShadOption(
value: model.id,
child: Text(model.name),
);
}).toList(),
selectedOptionBuilder: (context, value) {
final model = _filteredModels.firstWhere(
(m) => m.id == value,
orElse: () => ModelDto(
id: value,
name: '로딩중...',
vendorsId: 0,
),
);
return Text(model.name);
},
onChanged: isEnabled ? _onModelChanged : null,
initialValue: _selectedModelId,
enabled: isEnabled,
),
],
return Container(
width: double.infinity,
child: StandardIntDropdown<ModelDto>(
label: widget.isReadOnly ? '모델 * 🔒' : '모델 *',
isRequired: true,
items: _filteredModels,
isLoading: _isLoadingModels,
selectedValue: _selectedModelId != null
? _filteredModels.where((m) => m.id == _selectedModelId).firstOrNull
: null,
onChanged: (ModelDto? selectedModel) {
if (isEnabled) {
_onModelChanged(selectedModel?.id);
}
},
itemBuilder: (ModelDto model) => Text(model.name),
selectedItemBuilder: (ModelDto model) => Text(model.name),
idExtractor: (ModelDto model) => model.id ?? 0,
placeholder: _selectedVendorId == null
? '먼저 제조사를 선택하세요'
: '모델을 선택하세요',
enabled: isEnabled,
),
);
}
}

View File

@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:superport/data/models/equipment_history_dto.dart';
import 'package:superport/data/models/stock_status_dto.dart';
import 'package:superport/domain/usecases/equipment_history_usecase.dart';
import 'package:superport/services/equipment_history_service.dart';
import 'package:superport/injection_container.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/constants/app_constants.dart';
class EquipmentHistoryController extends ChangeNotifier {
final EquipmentHistoryUseCase _useCase = getIt<EquipmentHistoryUseCase>();
final EquipmentHistoryService _service = EquipmentHistoryService();
// 상태 관리
bool _isLoading = false;
@@ -15,9 +18,10 @@ class EquipmentHistoryController extends ChangeNotifier {
// 데이터 (백엔드 스키마 기반)
List<EquipmentHistoryDto> _histories = [];
List<StockStatusDto> _stockStatus = [];
int _totalCount = 0;
int _currentPage = 1;
final int _pageSize = PaginationConstants.defaultPageSize;
final int _pageSize = AppConstants.historyPageSize;
// 필터
int? _filterEquipmentId;
@@ -34,6 +38,7 @@ class EquipmentHistoryController extends ChangeNotifier {
String? get errorMessage => _errorMessage;
String? get successMessage => _successMessage;
List<EquipmentHistoryDto> get histories => _histories;
List<StockStatusDto> get stockStatus => _stockStatus;
int get totalCount => _totalCount;
int get currentPage => _currentPage;
int get pageSize => _pageSize;
@@ -271,6 +276,23 @@ class EquipmentHistoryController extends ChangeNotifier {
};
}
/// 재고 현황 조회 (핵심 기능)
Future<void> loadStockStatus() async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_stockStatus = await _service.getStockStatus();
} catch (e) {
_errorMessage = e.toString();
_stockStatus = [];
} finally {
_isLoading = false;
notifyListeners();
}
}
// 메시지 클리어
void clearMessages() {
_errorMessage = null;
@@ -281,6 +303,7 @@ class EquipmentHistoryController extends ChangeNotifier {
@override
void dispose() {
_histories.clear();
_stockStatus.clear();
super.dispose();
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../equipment/controllers/equipment_history_controller.dart';
import 'controllers/equipment_history_controller.dart';
class InventoryDashboard extends StatefulWidget {
const InventoryDashboard({super.key});
@@ -16,7 +16,7 @@ class _InventoryDashboardState extends State<InventoryDashboard> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final controller = context.read<EquipmentHistoryController>();
controller.loadHistory(refresh: true);
controller.loadStockStatus(); // 재고 현황 로드
});
}
@@ -68,8 +68,8 @@ class _InventoryDashboardState extends State<InventoryDashboard> {
],
),
onPressed: () {
controller.loadInventoryStatus();
controller.loadWarehouseStock();
controller.loadStockStatus();
controller.loadHistories();
},
),
const SizedBox(width: 8),
@@ -127,7 +127,7 @@ class _InventoryDashboardState extends State<InventoryDashboard> {
_buildStatCard(
theme,
title: '총 거래',
value: '${controller.historyList.length}',
value: '${controller.histories.length}',
unit: '',
icon: Icons.history,
color: Colors.blue,
@@ -145,12 +145,12 @@ class _InventoryDashboardState extends State<InventoryDashboard> {
ShadCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: controller.historyList.isEmpty
child: controller.histories.isEmpty
? const Center(
child: Text('거래 이력이 없습니다.'),
)
: Column(
children: controller.historyList.take(10).map((history) {
children: controller.histories.take(10).map((history) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/constants/app_constants.dart';
import '../../screens/equipment/controllers/equipment_history_controller.dart';
import 'components/transaction_type_badge.dart';
import '../common/layouts/base_list_screen.dart';
@@ -34,17 +35,28 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
}
void _onSearch() {
final searchQuery = _searchController.text.trim();
setState(() {
_appliedSearchKeyword = _searchController.text;
_appliedSearchKeyword = searchQuery;
});
// 검색 로직은 Controller에 추가 예정
// Controller 검색 메서드 연동
context.read<EquipmentHistoryController>().setFilters(
searchQuery: searchQuery.isNotEmpty ? searchQuery : null,
transactionType: _selectedType != 'all' ? _selectedType : null,
);
}
void _clearSearch() {
_searchController.clear();
setState(() {
_appliedSearchKeyword = '';
_selectedType = 'all';
});
// ✅ Controller 필터 초기화
context.read<EquipmentHistoryController>().setFilters(
searchQuery: null,
transactionType: null,
);
}
/// 헤더 셀 빌더
@@ -282,6 +294,11 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
setState(() {
_selectedType = value;
});
// ✅ 필터 변경 시 즉시 Controller에 반영
context.read<EquipmentHistoryController>().setFilters(
searchQuery: _appliedSearchKeyword.isNotEmpty ? _appliedSearchKeyword : null,
transactionType: value != 'all' ? value : null,
);
}
},
style: ShadTheme.of(context).textTheme.large,
@@ -480,7 +497,7 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
? Pagination(
totalCount: controller.totalCount,
currentPage: controller.currentPage,
pageSize: 10, // controller.pageSize 대신 고정값 사용
pageSize: AppConstants.historyPageSize, // controller.pageSize 대신 고정값 사용
onPageChanged: (page) => {
// 페이지 변경 로직 - 추후 Controller에 추가 예정
},

View File

@@ -4,6 +4,9 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../screens/equipment/controllers/equipment_history_controller.dart';
import '../../screens/equipment/controllers/equipment_list_controller.dart';
import '../../data/models/equipment_history_dto.dart';
import '../../data/models/warehouse/warehouse_dto.dart';
import '../../services/warehouse_service.dart';
import 'package:get_it/get_it.dart';
class StockInForm extends StatefulWidget {
const StockInForm({super.key});
@@ -14,6 +17,7 @@ class StockInForm extends StatefulWidget {
class _StockInFormState extends State<StockInForm> {
final _formKey = GlobalKey<ShadFormState>();
final _warehouseService = GetIt.instance<WarehouseService>();
int? _selectedEquipmentId;
int? _selectedWarehouseId;
@@ -21,15 +25,53 @@ class _StockInFormState extends State<StockInForm> {
DateTime _transactionDate = DateTime.now();
String? _notes;
// 창고 관련 상태
List<WarehouseDto> _warehouses = [];
bool _isLoadingWarehouses = false;
String? _warehouseError;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// 장비 목록 로드
context.read<EquipmentListController>().refresh();
// 창고 목록 로드
_loadWarehouses();
});
}
/// 사용 중인 창고 목록 로드
Future<void> _loadWarehouses() async {
setState(() {
_isLoadingWarehouses = true;
_warehouseError = null;
});
try {
final warehouses = await _warehouseService.getInUseWarehouseLocations();
if (mounted) {
setState(() {
// WarehouseLocation을 WarehouseDto로 변환 (임시 - 추후 DTO 직접 사용 메서드 추가 예정)
_warehouses = warehouses.map((location) => WarehouseDto(
id: location.id,
name: location.name,
remark: location.remark,
isDeleted: !location.isActive,
)).toList();
_isLoadingWarehouses = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_warehouseError = '창고 목록을 불러오는데 실패했습니다: ${e.toString()}';
_isLoadingWarehouses = false;
});
}
}
}
Future<void> _handleSubmit() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final controller = context.read<EquipmentHistoryController>();
@@ -130,31 +172,80 @@ class _StockInFormState extends State<StockInForm> {
),
const SizedBox(height: 16),
// 창고 선택
ShadSelect<int>(
placeholder: const Text('창고 선택'),
options: [
const ShadOption(value: 1, child: Text('본사 창고')),
const ShadOption(value: 2, child: Text('지사 창고')),
const ShadOption(value: 3, child: Text('외부 창고')),
// 창고 선택 (백엔드 API 연동)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('창고', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
if (_isLoadingWarehouses)
Container(
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('창고 목록 로딩 중...', style: TextStyle(fontSize: 14)),
],
),
),
)
else if (_warehouseError != null)
Column(
children: [
Container(
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Colors.red.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
_warehouseError!,
style: TextStyle(color: Colors.red.shade600, fontSize: 14),
),
),
),
const SizedBox(height: 8),
ShadButton.outline(
onPressed: _loadWarehouses,
child: const Text('재시도'),
),
],
)
else
ShadSelect<int>(
placeholder: const Text('창고 선택'),
options: _warehouses.map((warehouse) {
return ShadOption(
value: warehouse.id!,
child: Text(warehouse.name),
);
}).toList(),
selectedOptionBuilder: (context, value) {
final warehouse = _warehouses.firstWhere(
(w) => w.id == value,
orElse: () => WarehouseDto(name: 'Unknown'),
);
return Text(warehouse.name);
},
onChanged: (value) {
setState(() {
_selectedWarehouseId = value;
});
},
),
],
selectedOptionBuilder: (context, value) {
switch (value) {
case 1:
return const Text('본사 창고');
case 2:
return const Text('지사 창고');
case 3:
return const Text('외부 창고');
default:
return const Text('');
}
},
onChanged: (value) {
setState(() {
_selectedWarehouseId = value;
});
},
),
const SizedBox(height: 16),
@@ -201,43 +292,6 @@ class _StockInFormState extends State<StockInForm> {
),
const SizedBox(height: 16),
// 상태
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('상태', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
ShadSelect<String>(
placeholder: const Text('상태 선택'),
initialValue: 'available',
options: const [
ShadOption(value: 'available', child: Text('사용 가능')),
ShadOption(value: 'in_use', child: Text('사용 중')),
ShadOption(value: 'maintenance', child: Text('정비 중')),
ShadOption(value: 'reserved', child: Text('예약됨')),
],
selectedOptionBuilder: (context, value) {
switch (value) {
case 'available':
return const Text('사용 가능');
case 'in_use':
return const Text('사용 중');
case 'maintenance':
return const Text('정비 중');
case 'reserved':
return const Text('예약됨');
default:
return const Text('');
}
},
onChanged: (value) {
// 상태 변경 시 필요한 로직이 있다면 여기에 추가
},
),
],
),
const SizedBox(height: 16),
// 비고
ShadInputFormField(
label: const Text('비고'),

View File

@@ -1,13 +1,43 @@
import 'package:flutter/material.dart';
import '../../../data/models/maintenance_dto.dart';
import '../../../domain/usecases/maintenance_usecase.dart';
import '../../../utils/constants.dart';
import 'package:superport/data/models/maintenance_dto.dart';
import 'package:superport/domain/usecases/maintenance_usecase.dart';
/// 유지보수 컨트롤러 (백엔드 실제 스키마 기반)
/// 정비 우선순위
enum MaintenancePriority { low, medium, high }
/// 정비 스케줄 상태
enum MaintenanceScheduleStatus {
scheduled,
inProgress,
completed,
overdue,
cancelled
}
/// 정비 스케줄 모델 (UI 전용)
class MaintenanceSchedule {
final String id;
final String title;
final DateTime date;
final MaintenancePriority priority;
final MaintenanceScheduleStatus status;
final String description;
MaintenanceSchedule({
required this.id,
required this.title,
required this.date,
required this.priority,
required this.status,
required this.description,
});
}
/// 유지보수 컨트롤러 (백엔드 API 완전 호환)
class MaintenanceController extends ChangeNotifier {
final MaintenanceUseCase _maintenanceUseCase;
// 상태 관리 (단순화)
// 상태 관리
List<MaintenanceDto> _maintenances = [];
bool _isLoading = false;
String? _error;
@@ -15,28 +45,55 @@ class MaintenanceController extends ChangeNotifier {
// 페이지네이션
int _currentPage = 1;
int _totalCount = 0;
static const int _pageSize = PaginationConstants.defaultPageSize;
int _totalPages = 0;
static const int _perPage = 20;
// 필터 (백엔드 실제 필드만)
// 필터 (백엔드 API와 일치)
int? _equipmentId;
String? _maintenanceType;
int? _equipmentHistoryId;
bool? _isExpired;
int? _expiringDays;
bool _includeDeleted = false;
// 검색 및 정렬
String _searchQuery = '';
String _sortField = 'startedAt';
bool _sortAscending = false;
// 선택된 유지보수
MaintenanceDto? _selectedMaintenance;
// Getters (단순화)
List<MaintenanceDto> get maintenances => _maintenances;
bool get isLoading => _isLoading;
String? get error => _error;
int get currentPage => _currentPage;
int get totalPages => (_totalCount / _pageSize).ceil();
int get totalCount => _totalCount;
MaintenanceDto? get selectedMaintenance => _selectedMaintenance;
// 만료 예정 유지보수
List<MaintenanceDto> _expiringMaintenances = [];
// Form 상태
bool _isFormLoading = false;
MaintenanceController({required MaintenanceUseCase maintenanceUseCase})
: _maintenanceUseCase = maintenanceUseCase;
// Getters
List<MaintenanceDto> get maintenances => _maintenances;
bool get isLoading => _isLoading;
bool get isFormLoading => _isFormLoading;
String? get error => _error;
int get currentPage => _currentPage;
int get totalPages => _totalPages;
int get totalCount => _totalCount;
MaintenanceDto? get selectedMaintenance => _selectedMaintenance;
List<MaintenanceDto> get expiringMaintenances => _expiringMaintenances;
// 유지보수 목록 로드 (백엔드 단순 구조)
// Filter getters
int? get equipmentId => _equipmentId;
String? get maintenanceType => _maintenanceType;
bool? get isExpired => _isExpired;
int? get expiringDays => _expiringDays;
bool get includeDeleted => _includeDeleted;
String get searchQuery => _searchQuery;
String get sortField => _sortField;
bool get sortAscending => _sortAscending;
/// 유지보수 목록 로드
Future<void> loadMaintenances({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
@@ -50,22 +107,23 @@ class MaintenanceController extends ChangeNotifier {
try {
final response = await _maintenanceUseCase.getMaintenances(
page: _currentPage,
pageSize: _pageSize,
equipmentHistoryId: _equipmentHistoryId,
perPage: _perPage,
equipmentId: _equipmentId,
maintenanceType: _maintenanceType,
isExpired: _isExpired,
expiringDays: _expiringDays,
includeDeleted: _includeDeleted,
);
// response는 MaintenanceListResponse 타입
final maintenanceResponse = response;
if (refresh) {
_maintenances = maintenanceResponse.items;
_maintenances = response.items;
} else {
_maintenances.addAll(maintenanceResponse.items);
_maintenances.addAll(response.items);
}
_totalCount = maintenanceResponse.totalCount;
_totalCount = response.totalCount;
_totalPages = response.totalPages;
notifyListeners();
} catch (e) {
_error = e.toString();
} finally {
@@ -73,70 +131,64 @@ class MaintenanceController extends ChangeNotifier {
notifyListeners();
}
}
// 특정 장비 이력의 유지보수 로드
Future<void> loadMaintenancesByEquipmentHistory(int equipmentHistoryId) async {
_isLoading = true;
_error = null;
_equipmentHistoryId = equipmentHistoryId;
notifyListeners();
/// 유지보수 상세 조회
Future<MaintenanceDto?> getMaintenanceDetail(int id) async {
try {
_maintenances = await _maintenanceUseCase.getMaintenancesByEquipmentHistory(
equipmentHistoryId,
);
_isLoading = true;
notifyListeners();
final maintenance = await _maintenanceUseCase.getMaintenanceDetail(id);
_selectedMaintenance = maintenance;
return maintenance;
} catch (e) {
_error = e.toString();
_error = '유지보수 상세 조회 실패: ${e.toString()}';
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
// 간단한 통계 (백엔드 데이터 기반)
int get totalMaintenances => _totalCount;
int get activeMaintenances => _maintenances.where((m) => !(m.isDeleted ?? false)).length;
int get completedMaintenances => _maintenances.where((m) => m.endedAt.isBefore(DateTime.now())).length;
// 유지보수 생성 (백엔드 실제 스키마)
/// 유지보수 생성
Future<bool> createMaintenance({
required int equipmentHistoryId,
int? equipmentHistoryId,
required DateTime startedAt,
required DateTime endedAt,
required int periodMonth,
required String maintenanceType,
}) async {
_isLoading = true;
_isFormLoading = true;
_error = null;
notifyListeners();
try {
final maintenance = await _maintenanceUseCase.createMaintenance(
MaintenanceRequestDto(
equipmentHistoryId: equipmentHistoryId,
startedAt: startedAt,
endedAt: endedAt,
periodMonth: periodMonth,
maintenanceType: maintenanceType,
),
final request = MaintenanceRequestDto(
equipmentHistoryId: equipmentHistoryId,
startedAt: startedAt,
endedAt: endedAt,
periodMonth: periodMonth,
maintenanceType: maintenanceType,
);
_maintenances.insert(0, maintenance);
final newMaintenance = await _maintenanceUseCase.createMaintenance(request);
// 리스트 업데이트
_maintenances.insert(0, newMaintenance);
_totalCount++;
notifyListeners();
return true;
} catch (e) {
_error = '유지보수 등록 실패: ${e.toString()}';
_error = '유지보수 생성 실패: ${e.toString()}';
return false;
} finally {
_isLoading = false;
_isFormLoading = false;
notifyListeners();
}
}
// 유지보수 수정 (백엔드 실제 스키마)
/// 유지보수 수정
Future<bool> updateMaintenance({
required int id,
DateTime? startedAt,
@@ -144,312 +196,414 @@ class MaintenanceController extends ChangeNotifier {
int? periodMonth,
String? maintenanceType,
}) async {
_isLoading = true;
_isFormLoading = true;
_error = null;
notifyListeners();
try {
final updatedMaintenance = await _maintenanceUseCase.updateMaintenance(
id,
MaintenanceUpdateRequestDto(
startedAt: startedAt,
endedAt: endedAt,
periodMonth: periodMonth,
maintenanceType: maintenanceType,
),
final request = MaintenanceUpdateRequestDto(
startedAt: startedAt,
endedAt: endedAt,
periodMonth: periodMonth,
maintenanceType: maintenanceType,
);
final updatedMaintenance = await _maintenanceUseCase.updateMaintenance(id, request);
// 리스트 업데이트
final index = _maintenances.indexWhere((m) => m.id == id);
if (index != -1) {
_maintenances[index] = updatedMaintenance;
}
// 선택된 항목 업데이트
if (_selectedMaintenance != null && _selectedMaintenance!.id == id) {
_selectedMaintenance = updatedMaintenance;
}
notifyListeners();
return true;
} catch (e) {
_error = '유지보수 수정 실패: ${e.toString()}';
return false;
} finally {
_isLoading = false;
_isFormLoading = false;
notifyListeners();
}
}
// 유지보수 삭제 (백엔드 실제 구조)
/// 유지보수 삭제 (Soft Delete)
Future<bool> deleteMaintenance(int id) async {
_isLoading = true;
_isFormLoading = true;
_error = null;
notifyListeners();
try {
await _maintenanceUseCase.deleteMaintenance(id);
// 리스트에서 제거
_maintenances.removeWhere((m) => m.id == id);
_totalCount--;
// 선택된 항목 해제
if (_selectedMaintenance != null && _selectedMaintenance!.id == id) {
_selectedMaintenance = null;
}
notifyListeners();
return true;
} catch (e) {
_error = '유지보수 삭제 실패: ${e.toString()}';
return false;
} finally {
_isLoading = false;
_isFormLoading = false;
notifyListeners();
}
}
/// 만료 예정 유지보수 조회
Future<void> loadExpiringMaintenances({int days = 30}) async {
try {
_expiringMaintenances = await _maintenanceUseCase.getExpiringMaintenances(days: days);
notifyListeners();
} catch (e) {
_error = '만료 예정 유지보수 조회 실패: ${e.toString()}';
notifyListeners();
}
}
/// 특정 장비의 유지보수 조회
Future<void> loadMaintenancesByEquipment(int equipmentId) async {
_equipmentId = equipmentId;
await loadMaintenances(refresh: true);
}
/// 활성 유지보수만 조회
Future<void> loadActiveMaintenances() async {
_includeDeleted = false;
await loadMaintenances(refresh: true);
}
/// 만료된 유지보수 조회
Future<void> loadExpiredMaintenances() async {
_isExpired = true;
await loadMaintenances(refresh: true);
}
// 필터 설정 메서드들
void setEquipmentFilter(int? equipmentId) {
if (_equipmentId != equipmentId) {
_equipmentId = equipmentId;
loadMaintenances(refresh: true);
}
}
void setMaintenanceTypeFilter(String? maintenanceType) {
if (_maintenanceType != maintenanceType) {
_maintenanceType = maintenanceType;
loadMaintenances(refresh: true);
}
}
void setExpiredFilter(bool? isExpired) {
if (_isExpired != isExpired) {
_isExpired = isExpired;
loadMaintenances(refresh: true);
}
}
void setExpiringDaysFilter(int? days) {
if (_expiringDays != days) {
_expiringDays = days;
loadMaintenances(refresh: true);
}
}
void setIncludeDeleted(bool includeDeleted) {
if (_includeDeleted != includeDeleted) {
_includeDeleted = includeDeleted;
loadMaintenances(refresh: true);
}
}
/// 모든 필터 초기화
void clearFilters() {
_equipmentId = null;
_maintenanceType = null;
_isExpired = null;
_expiringDays = null;
_includeDeleted = false;
_searchQuery = '';
loadMaintenances(refresh: true);
}
/// 검색어 설정
void setSearchQuery(String query) {
if (_searchQuery != query) {
_searchQuery = query;
// 검색어가 변경되면 0.5초 후에 검색 실행 (디바운스)
Future.delayed(const Duration(milliseconds: 500), () {
if (_searchQuery == query) {
_filterMaintenancesBySearch();
}
});
}
}
/// 상태 필터 설정
void setMaintenanceFilter(String? status) {
switch (status) {
case 'active':
_isExpired = false;
break;
case 'completed':
case 'expired':
_isExpired = true;
break;
case 'upcoming':
_isExpired = false;
break;
default:
_isExpired = null;
break;
}
loadMaintenances(refresh: true);
}
/// 정렬 설정
void setSorting(String field, bool ascending) {
if (_sortField != field || _sortAscending != ascending) {
_sortField = field;
_sortAscending = ascending;
_sortMaintenances();
notifyListeners();
}
}
// 유지보수 상세 조회
Future<MaintenanceDto?> getMaintenanceDetail(int id) async {
try {
return await _maintenanceUseCase.getMaintenance(id);
} catch (e) {
_error = '유지보수 상세 조회 실패: ${e.toString()}';
return null;
/// 검색어로 유지보수 필터링 (로컬 필터링)
void _filterMaintenancesBySearch() {
if (_searchQuery.trim().isEmpty) {
notifyListeners();
return;
}
}
// 유지보수 선택
void selectMaintenance(MaintenanceDto? maintenance) {
_selectedMaintenance = maintenance;
// 검색어가 있으면 로컬에서 필터링
final query = _searchQuery.toLowerCase();
// 여기서는 간단히 notifyListeners만 호출 (실제 필터링은 UI에서 수행)
notifyListeners();
}
// 장비 이력별 유지보수 설정
void setEquipmentHistoryFilter(int equipmentHistoryId) {
_equipmentHistoryId = equipmentHistoryId;
loadMaintenances(refresh: true);
/// 유지보수 목록 정렬
void _sortMaintenances() {
_maintenances.sort((a, b) {
int comparison = 0;
switch (_sortField) {
case 'startedAt':
comparison = a.startedAt.compareTo(b.startedAt);
break;
case 'endedAt':
comparison = a.endedAt.compareTo(b.endedAt);
break;
case 'maintenanceType':
comparison = a.maintenanceType.compareTo(b.maintenanceType);
break;
case 'periodMonth':
comparison = a.periodMonth.compareTo(b.periodMonth);
break;
default:
comparison = a.startedAt.compareTo(b.startedAt);
}
return _sortAscending ? comparison : -comparison;
});
}
// 필터 설정
void setMaintenanceType(String? type) {
if (_maintenanceType != type) {
_maintenanceType = type;
loadMaintenances(refresh: true);
/// 특정 날짜의 유지보수 스케줄 생성
MaintenanceSchedule? getScheduleForMaintenance(DateTime date) {
if (_maintenances.isEmpty) return null;
MaintenanceDto? maintenance;
try {
maintenance = _maintenances.firstWhere(
(m) => m.startedAt.year == date.year &&
m.startedAt.month == date.month &&
m.startedAt.day == date.day,
);
} catch (e) {
// 해당 날짜의 정비가 없으면 가장 가까운 정비를 찾거나 null 반환
maintenance = _maintenances.isNotEmpty ? _maintenances.first : null;
}
if (maintenance == null) return null;
return MaintenanceSchedule(
id: maintenance.id.toString(),
title: _getMaintenanceTitle(maintenance),
date: maintenance.startedAt,
priority: _getMaintenancePriority(maintenance),
status: _getMaintenanceScheduleStatus(maintenance),
description: _getMaintenanceDescription(maintenance),
);
}
String _getMaintenanceTitle(MaintenanceDto maintenance) {
final typeDisplay = _getMaintenanceTypeDisplayName(maintenance.maintenanceType);
return '$typeDisplay 정비';
}
String _getMaintenanceTypeDisplayName(String maintenanceType) {
switch (maintenanceType) {
case 'WARRANTY':
return '무상보증';
case 'CONTRACT':
return '유상계약';
case 'INSPECTION':
return '점검';
default:
return maintenanceType;
}
}
// 필터 초기화
void clearFilters() {
_maintenanceType = null;
_equipmentHistoryId = null;
loadMaintenances(refresh: true);
MaintenancePriority _getMaintenancePriority(MaintenanceDto maintenance) {
if (maintenance.isExpired) return MaintenancePriority.high;
final now = DateTime.now();
final daysUntilStart = maintenance.startedAt.difference(now).inDays;
if (daysUntilStart <= 7) return MaintenancePriority.high;
if (daysUntilStart <= 30) return MaintenancePriority.medium;
return MaintenancePriority.low;
}
// 페이지 변경
MaintenanceScheduleStatus _getMaintenanceScheduleStatus(MaintenanceDto maintenance) {
if (maintenance.isDeleted) return MaintenanceScheduleStatus.cancelled;
if (maintenance.isExpired) return MaintenanceScheduleStatus.overdue;
final now = DateTime.now();
if (maintenance.startedAt.isAfter(now)) return MaintenanceScheduleStatus.scheduled;
if (maintenance.endedAt.isBefore(now)) return MaintenanceScheduleStatus.completed;
return MaintenanceScheduleStatus.inProgress;
}
String _getMaintenanceDescription(MaintenanceDto maintenance) {
final status = getMaintenanceStatusText(maintenance);
return '상태: $status\n주기: ${maintenance.periodMonth}개월';
}
// 페이지네이션 메서드들
void goToPage(int page) {
if (page >= 1 && page <= totalPages) {
if (page >= 1 && page <= _totalPages && page != _currentPage) {
_currentPage = page;
loadMaintenances();
}
}
void nextPage() {
if (_currentPage < totalPages) {
if (_currentPage < _totalPages) {
_currentPage++;
loadMaintenances();
}
}
void previousPage() {
if (_currentPage > 1) {
_currentPage--;
loadMaintenances();
}
}
void goToFirstPage() {
if (_currentPage != 1) {
_currentPage = 1;
loadMaintenances();
}
}
void goToLastPage() {
if (_currentPage != _totalPages && _totalPages > 0) {
_currentPage = _totalPages;
loadMaintenances();
}
}
// 선택 관리
void selectMaintenance(MaintenanceDto? maintenance) {
_selectedMaintenance = maintenance;
notifyListeners();
}
// 유틸리티 메서드들
String getMaintenanceStatusText(MaintenanceDto maintenance) {
if (maintenance.isDeleted) return '삭제됨';
if (maintenance.isExpired) return '만료됨';
final now = DateTime.now();
if (maintenance.startedAt.isAfter(now)) return '예정';
if (maintenance.endedAt.isBefore(now)) return '완료';
return '진행중';
}
Color getMaintenanceStatusColor(MaintenanceDto maintenance) {
final status = getMaintenanceStatusText(maintenance);
switch (status) {
case '예정': return Colors.blue;
case '진행중': return Colors.green;
case '완료': return Colors.grey;
case '만료됨': return Colors.red;
case '삭제됨': return Colors.grey.withValues(alpha: 0.5);
default: return Colors.black;
}
}
// Alert 시스템 (기존 화면 호환성)
List<MaintenanceDto> get upcomingAlerts => _expiringMaintenances;
List<MaintenanceDto> get overdueAlerts => _maintenances
.where((m) => m.isExpired && m.isActive)
.toList();
// 오류 초기화
int get upcomingCount => upcomingAlerts.length;
int get overdueCount => overdueAlerts.length;
/// Alert 데이터 로드 (기존 화면 호환성)
Future<void> loadAlerts() async {
await loadExpiringMaintenances();
notifyListeners();
}
// 통계 정보
int get activeMaintenanceCount => _maintenances.where((m) => m.isActive).length;
int get expiredMaintenanceCount => _maintenances.where((m) => m.isExpired).length;
int get warrantyMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'WARRANTY').length;
int get contractMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'CONTRACT').length;
int get inspectionMaintenanceCount => _maintenances.where((m) => m.maintenanceType == 'INSPECTION').length;
// 오류 관리
void clearError() {
_error = null;
notifyListeners();
}
// 유지보수 상태 표시명 (UI 호환성)
String getMaintenanceStatusDisplayName(String status) {
switch (status.toLowerCase()) {
case '예정':
case 'scheduled':
return '예정';
case '진행중':
case 'in_progress':
return '진행중';
case '완료':
case 'completed':
return '완료';
case '취소':
case 'cancelled':
return '취소';
default:
return status;
}
}
// 유지보수 상태 간단 판단 (백엔드 데이터 기반)
String getMaintenanceStatus(MaintenanceDto maintenance) {
final now = DateTime.now();
if (maintenance.isDeleted ?? false) return '취소';
if (maintenance.startedAt.isAfter(now)) return '예정';
if (maintenance.endedAt.isBefore(now)) return '완료';
return '진행중';
}
// ================== 누락된 메서드들 추가 ==================
// 추가된 필드들
List<MaintenanceDto> _upcomingAlerts = [];
List<MaintenanceDto> _overdueAlerts = [];
String _searchQuery = '';
String _currentSortField = '';
bool _isAscending = true;
// 추가 Getters
List<MaintenanceDto> get upcomingAlerts => _upcomingAlerts;
List<MaintenanceDto> get overdueAlerts => _overdueAlerts;
int get upcomingCount => _upcomingAlerts.length;
int get overdueCount => _overdueAlerts.length;
// ID로 유지보수 조회 (호환성)
Future<MaintenanceDto?> getMaintenanceById(int id) async {
return await getMaintenanceDetail(id);
}
// 알람 로드 (백엔드 스키마 기반)
Future<void> loadAlerts() async {
_isLoading = true;
notifyListeners();
try {
final now = DateTime.now();
// 예정된 유지보수 (시작일이 미래인 것)
_upcomingAlerts = _maintenances.where((maintenance) {
return maintenance.startedAt.isAfter(now) &&
!(maintenance.isDeleted ?? false);
}).take(10).toList();
// 연체된 유지보수 (종료일이 과거이고 아직 완료되지 않은 것)
_overdueAlerts = _maintenances.where((maintenance) {
return maintenance.endedAt.isBefore(now) &&
maintenance.startedAt.isBefore(now) &&
!(maintenance.isDeleted ?? false);
}).take(10).toList();
notifyListeners();
} catch (e) {
_error = '알람 로드 실패: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
}
// 검색 쿼리 설정
void setSearchQuery(String query) {
if (_searchQuery != query) {
_searchQuery = query;
// TODO: 실제 검색 구현 시 백엔드 API 호출 필요
loadMaintenances(refresh: true);
}
}
// 유지보수 필터 설정 (호환성)
void setMaintenanceFilter(String? type) {
setMaintenanceType(type);
}
// 정렬 설정
void setSorting(String field, bool ascending) {
if (_currentSortField != field || _isAscending != ascending) {
_currentSortField = field;
_isAscending = ascending;
// 클라이언트 사이드 정렬 (백엔드 정렬 API가 없는 경우)
_maintenances.sort((a, b) {
dynamic valueA, valueB;
switch (field) {
case 'startedAt':
valueA = a.startedAt;
valueB = b.startedAt;
break;
case 'endedAt':
valueA = a.endedAt;
valueB = b.endedAt;
break;
case 'maintenanceType':
valueA = a.maintenanceType;
valueB = b.maintenanceType;
break;
case 'periodMonth':
valueA = a.periodMonth;
valueB = b.periodMonth;
break;
default:
valueA = a.id;
valueB = b.id;
}
if (valueA == null && valueB == null) return 0;
if (valueA == null) return ascending ? -1 : 1;
if (valueB == null) return ascending ? 1 : -1;
final comparison = valueA.compareTo(valueB);
return ascending ? comparison : -comparison;
});
notifyListeners();
}
}
// 유지보수 일정 조회 (백엔드 스키마 기반)
List<MaintenanceDto> getScheduleForMaintenance(DateTime date) {
return _maintenances.where((maintenance) {
final startDate = DateTime(
maintenance.startedAt.year,
maintenance.startedAt.month,
maintenance.startedAt.day,
);
final endDate = DateTime(
maintenance.endedAt.year,
maintenance.endedAt.month,
maintenance.endedAt.day,
);
final targetDate = DateTime(date.year, date.month, date.day);
return (targetDate.isAfter(startDate) || targetDate.isAtSameMomentAs(startDate)) &&
(targetDate.isBefore(endDate) || targetDate.isAtSameMomentAs(endDate)) &&
!(maintenance.isDeleted ?? false);
}).toList();
}
// 초기화 (백엔드 실제 구조)
// 초기화
void reset() {
_maintenances.clear();
_expiringMaintenances.clear();
_selectedMaintenance = null;
_currentPage = 1;
_totalCount = 0;
_totalPages = 0;
_equipmentId = null;
_maintenanceType = null;
_equipmentHistoryId = null;
_isExpired = null;
_expiringDays = null;
_includeDeleted = false;
_searchQuery = '';
_sortField = 'startedAt';
_sortAscending = false;
_error = null;
_isLoading = false;
_upcomingAlerts.clear();
_overdueAlerts.clear();
_searchQuery = '';
_currentSortField = '';
_isAscending = true;
_isFormLoading = false;
notifyListeners();
}
@override
void dispose() {
reset();

View File

@@ -180,32 +180,6 @@ class _MaintenanceAlertDashboardState extends State<MaintenanceAlertDashboard> {
);
}
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
return Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
title,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
);
}
Widget _buildAlertSections(MaintenanceController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -30,6 +30,7 @@ class _MaintenanceFormDialogState extends State<MaintenanceFormDialog> {
List<EquipmentHistoryDto> _equipmentHistories = [];
bool _isLoadingHistories = false;
String? _historiesError;
@override
void initState() {
@@ -66,6 +67,7 @@ class _MaintenanceFormDialogState extends State<MaintenanceFormDialog> {
void _loadEquipmentHistories() async {
setState(() {
_isLoadingHistories = true;
_historiesError = null; // 오류 상태 초기화
});
try {
@@ -75,20 +77,21 @@ class _MaintenanceFormDialogState extends State<MaintenanceFormDialog> {
setState(() {
_equipmentHistories = controller.historyList;
_isLoadingHistories = false;
_historiesError = null; // 성공 시 오류 상태 클리어
});
} catch (e) {
setState(() {
_isLoadingHistories = false;
_historiesError = '장비 이력을 불러오는 중 오류가 발생했습니다: ${e.toString()}';
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('장비 이력 로드 실패: $e')),
);
}
}
}
// 재시도 기능 추가
void _retryLoadHistories() {
_loadEquipmentHistories();
}
// _calculateNextDate 메서드 제거 - 백엔드에 nextMaintenanceDate 필드 없음
@override
@@ -196,36 +199,89 @@ class _MaintenanceFormDialogState extends State<MaintenanceFormDialog> {
}
Widget _buildEquipmentHistorySelector() {
if (_isLoadingHistories) {
return const Center(child: CircularProgressIndicator());
}
return DropdownButtonFormField<int>(
value: _selectedEquipmentHistoryId,
decoration: const InputDecoration(
labelText: '장비 이력 선택',
border: OutlineInputBorder(),
),
items: _equipmentHistories.map((history) {
final equipment = history.equipment;
return DropdownMenuItem(
value: history.id,
child: Text(
'${equipment?.serialNumber ?? "Unknown"} - '
'${equipment?.serialNumber ?? "No Serial"} '
'(${history.transactionType == "I" ? "입고" : "출고"})',
),
);
}).toList(),
onChanged: widget.maintenance == null
? (value) => setState(() => _selectedEquipmentHistoryId = value)
: null, // 수정 모드에서는 변경 불가
validator: (value) {
if (value == null) {
return '장비 이력을 선택해주세요';
}
return null;
},
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('장비 이력 선택 *', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
// 3단계 상태 처리 (UserForm 패턴 적용)
_isLoadingHistories
? const SizedBox(
height: 56,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(width: 8),
Text('장비 이력을 불러오는 중...'),
],
),
),
)
// 오류 발생 시 오류 컨테이너 표시
: _historiesError != null
? Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border.all(color: Colors.red.shade200),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.error, color: Colors.red.shade600, size: 20),
const SizedBox(width: 8),
const Text('장비 이력 로딩 실패', style: TextStyle(fontWeight: FontWeight.w500)),
],
),
const SizedBox(height: 8),
Text(
_historiesError!,
style: TextStyle(color: Colors.red.shade700, fontSize: 14),
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _retryLoadHistories,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('다시 시도'),
),
],
),
)
// 정상 상태: shadcn_ui 드롭다운 표시
: DropdownButtonFormField<int>(
value: _selectedEquipmentHistoryId,
decoration: const InputDecoration(
labelText: '장비 이력 선택',
border: OutlineInputBorder(),
),
items: _equipmentHistories.map((history) {
final equipment = history.equipment;
return DropdownMenuItem(
value: history.id,
child: Text(
'${equipment?.serialNumber ?? "Unknown"} - '
'${equipment?.modelName ?? "No Model"} '
'(${TransactionType.getDisplayName(history.transactionType)})',
),
);
}).toList(),
onChanged: widget.maintenance == null
? (value) => setState(() => _selectedEquipmentHistoryId = value)
: null, // 수정 모드에서는 변경 불가
validator: (value) {
if (value == null) {
return '장비 이력을 선택해주세요';
}
return null;
},
),
],
);
}

View File

@@ -1048,13 +1048,4 @@ class _MaintenanceHistoryScreenState extends State<MaintenanceHistoryScreen>
),
);
}
void _exportHistory() {
ShadToaster.of(context).show(
const ShadToast(
title: Text('엑셀 내보내기'),
description: Text('엑셀 내보내기 기능은 준비 중입니다'),
),
);
}
}

View File

@@ -0,0 +1,629 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/maintenance/controllers/maintenance_controller.dart';
import 'package:superport/screens/maintenance/maintenance_form_dialog.dart';
import 'package:superport/data/models/maintenance_dto.dart';
import 'package:superport/domain/usecases/maintenance_usecase.dart';
/// shadcn/ui 스타일로 설계된 유지보수 관리 화면
class MaintenanceList extends StatefulWidget {
const MaintenanceList({super.key});
@override
State<MaintenanceList> createState() => _MaintenanceListState();
}
class _MaintenanceListState extends State<MaintenanceList> {
late final MaintenanceController _controller;
bool _showDetailedColumns = true;
final TextEditingController _searchController = TextEditingController();
final ScrollController _horizontalScrollController = ScrollController();
final Set<int> _selectedItems = {};
@override
void initState() {
super.initState();
_controller = MaintenanceController(
maintenanceUseCase: GetIt.instance<MaintenanceUseCase>(),
);
// 초기 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.loadMaintenances(refresh: true);
_controller.loadExpiringMaintenances();
});
}
@override
void dispose() {
_searchController.dispose();
_horizontalScrollController.dispose();
_controller.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_adjustColumnsForScreenSize();
}
/// 화면 크기에 따라 컬럼 표시 조정
void _adjustColumnsForScreenSize() {
final width = MediaQuery.of(context).size.width;
setState(() {
_showDetailedColumns = width > 1000;
});
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _controller,
child: Scaffold(
backgroundColor: ShadcnTheme.background,
body: Column(
children: [
_buildActionBar(),
_buildFilterBar(),
Expanded(child: _buildMainContent()),
_buildBottomBar(),
],
),
),
);
}
/// 상단 액션바
Widget _buildActionBar() {
return Consumer<MaintenanceController>(
builder: (context, controller, child) {
return StandardActionBar(
totalCount: controller.totalCount,
selectedCount: _selectedItems.length,
leftActions: const [
Text('유지보수 관리', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
rightActions: [
// 만료 예정 알림
if (controller.expiringMaintenances.isNotEmpty)
ShadButton.outline(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.notification_important, size: 16),
const SizedBox(width: 4),
Text('만료 예정 ${controller.expiringMaintenances.length}'),
],
),
onPressed: () => _showExpiringMaintenances(),
),
// 새로운 유지보수 등록
ShadButton(
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 16),
SizedBox(width: 4),
Text('유지보수 등록'),
],
),
onPressed: () => _showMaintenanceForm(),
),
// 선택된 항목 삭제
if (_selectedItems.isNotEmpty)
ShadButton.destructive(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete, size: 16),
const SizedBox(width: 4),
Text('삭제 (${_selectedItems.length})'),
],
),
onPressed: () => _showDeleteConfirmation(),
),
],
);
},
);
}
/// 필터바
Widget _buildFilterBar() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ShadcnTheme.card,
border: Border(
bottom: BorderSide(color: ShadcnTheme.border),
),
),
child: Row(
children: [
// 유지보수 타입 필터
Expanded(
flex: 2,
child: ShadSelect<String>(
placeholder: const Text('유지보수 타입'),
options: [
const ShadOption(value: 'all', child: Text('전체')),
...MaintenanceType.typeOptions.map((option) =>
ShadOption(
value: option['value']!,
child: Text(option['label']!),
),
),
],
selectedOptionBuilder: (context, value) {
if (value == 'all') return const Text('전체');
final option = MaintenanceType.typeOptions
.firstWhere((o) => o['value'] == value, orElse: () => {'label': value});
return Text(option['label']!);
},
onChanged: (value) {
_controller.setMaintenanceTypeFilter(
value == 'all' ? null : value,
);
},
),
),
const SizedBox(width: 12),
// 상태 필터
Expanded(
flex: 2,
child: ShadSelect<String>(
placeholder: const Text('상태'),
options: const [
ShadOption(value: 'all', child: Text('전체')),
ShadOption(value: 'active', child: Text('활성')),
ShadOption(value: 'expired', child: Text('만료')),
ShadOption(value: 'expiring', child: Text('만료 예정')),
],
selectedOptionBuilder: (context, value) {
switch (value) {
case 'active': return const Text('활성');
case 'expired': return const Text('만료');
case 'expiring': return const Text('만료 예정');
default: return const Text('전체');
}
},
onChanged: (value) {
_applyStatusFilter(value ?? 'all');
},
),
),
const SizedBox(width: 12),
// 검색
Expanded(
flex: 3,
child: ShadInput(
controller: _searchController,
placeholder: const Text('장비 시리얼 번호 또는 모델명 검색...'),
onSubmitted: (_) => _performSearch(),
),
),
const SizedBox(width: 12),
// 필터 초기화
ShadButton.outline(
child: const Icon(Icons.refresh, size: 16),
onPressed: _resetFilters,
),
],
),
);
}
/// 메인 컨텐츠
Widget _buildMainContent() {
return Consumer<MaintenanceController>(
builder: (context, controller, child) {
if (controller.isLoading && controller.maintenances.isEmpty) {
return const StandardLoadingState(message: '유지보수 목록을 불러오는 중...');
}
if (controller.error != null) {
return StandardErrorState(
message: controller.error!,
onRetry: () => controller.loadMaintenances(refresh: true),
);
}
if (controller.maintenances.isEmpty) {
return const StandardEmptyState(
icon: Icons.build_circle_outlined,
title: '유지보수가 없습니다',
message: '새로운 유지보수를 등록해보세요.',
);
}
return _buildDataTable(controller);
},
);
}
/// 데이터 테이블
Widget _buildDataTable(MaintenanceController controller) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _horizontalScrollController,
child: DataTable(
columns: _buildHeaders(),
rows: _buildRows(controller.maintenances),
),
);
}
/// 테이블 헤더
List<DataColumn> _buildHeaders() {
return [
const DataColumn(label: Text('선택')),
const DataColumn(label: Text('ID')),
const DataColumn(label: Text('장비 정보')),
const DataColumn(label: Text('유지보수 타입')),
const DataColumn(label: Text('시작일')),
const DataColumn(label: Text('종료일')),
if (_showDetailedColumns) ...[
const DataColumn(label: Text('주기')),
const DataColumn(label: Text('상태')),
const DataColumn(label: Text('남은 일수')),
],
const DataColumn(label: Text('작업')),
];
}
/// 테이블 로우
List<DataRow> _buildRows(List<MaintenanceDto> maintenances) {
return maintenances.map((maintenance) {
final isSelected = _selectedItems.contains(maintenance.id);
return DataRow(
selected: isSelected,
onSelectChanged: (_) => _showMaintenanceDetail(maintenance),
cells: [
// 선택 체크박스
DataCell(
Checkbox(
value: isSelected,
onChanged: (value) {
setState(() {
if (value == true) {
_selectedItems.add(maintenance.id!);
} else {
_selectedItems.remove(maintenance.id!);
}
});
},
),
),
// ID
DataCell(Text(maintenance.id?.toString() ?? '-')),
// 장비 정보
DataCell(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
maintenance.equipmentSerial ?? '시리얼 번호 없음',
style: const TextStyle(fontWeight: FontWeight.w500),
),
if (maintenance.equipmentModel != null)
Text(
maintenance.equipmentModel!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
// 유지보수 타입
DataCell(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getMaintenanceTypeColor(maintenance.maintenanceType),
borderRadius: BorderRadius.circular(4),
),
child: Text(
MaintenanceType.getDisplayName(maintenance.maintenanceType),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
// 시작일
DataCell(Text(DateFormat('yyyy-MM-dd').format(maintenance.startedAt))),
// 종료일
DataCell(Text(DateFormat('yyyy-MM-dd').format(maintenance.endedAt))),
// 상세 컬럼들
if (_showDetailedColumns) ...[
// 주기
DataCell(Text('${maintenance.periodMonth}개월')),
// 상태
DataCell(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _controller.getMaintenanceStatusColor(maintenance),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_controller.getMaintenanceStatusText(maintenance),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
// 남은 일수
DataCell(
Text(
maintenance.daysRemaining != null
? '${maintenance.daysRemaining}'
: '-',
style: TextStyle(
color: maintenance.daysRemaining != null &&
maintenance.daysRemaining! <= 30
? Colors.red
: null,
),
),
),
],
// 작업 버튼들
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
child: const Icon(Icons.edit, size: 16),
onPressed: () => _showMaintenanceForm(maintenance: maintenance),
),
const SizedBox(width: 4),
ShadButton.ghost(
child: Icon(
Icons.delete,
size: 16,
color: Colors.red[400],
),
onPressed: () => _deleteMaintenance(maintenance),
),
],
),
),
],
);
}).toList();
}
/// 하단바 (페이지네이션)
Widget _buildBottomBar() {
return Consumer<MaintenanceController>(
builder: (context, controller, child) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ShadcnTheme.card,
border: Border(
top: BorderSide(color: ShadcnTheme.border),
),
),
child: Row(
children: [
// 선택된 항목 정보
if (_selectedItems.isNotEmpty)
Text('${_selectedItems.length}개 선택됨'),
const Spacer(),
// 페이지네이션
Pagination(
totalCount: controller.totalCount,
currentPage: controller.currentPage,
pageSize: 20, // MaintenanceController._perPage 상수값
onPageChanged: (page) => controller.goToPage(page),
),
],
),
);
},
);
}
// 유틸리티 메서드들
Color _getMaintenanceTypeColor(String type) {
switch (type) {
case MaintenanceType.warranty:
return Colors.blue;
case MaintenanceType.contract:
return Colors.orange;
case MaintenanceType.inspection:
return Colors.green;
default:
return Colors.grey;
}
}
void _applyStatusFilter(String status) {
switch (status) {
case 'active':
_controller.setExpiredFilter(false);
break;
case 'expired':
_controller.setExpiredFilter(true);
break;
case 'expiring':
_controller.setExpiringDaysFilter(30);
break;
default:
_controller.setExpiredFilter(null);
_controller.setExpiringDaysFilter(null);
}
}
void _performSearch() {
// TODO: 장비 시리얼/모델 검색 구현
// 백엔드에 검색 API가 추가되면 구현
}
void _resetFilters() {
setState(() {
_searchController.clear();
});
_controller.clearFilters();
}
// 다이얼로그 메서드들
void _showMaintenanceForm({MaintenanceDto? maintenance}) {
showDialog(
context: context,
builder: (context) => ChangeNotifierProvider.value(
value: _controller,
child: MaintenanceFormDialog(maintenance: maintenance),
),
).then((_) {
// 폼 닫힌 후 목록 새로고침
_controller.loadMaintenances(refresh: true);
});
}
void _showMaintenanceDetail(MaintenanceDto maintenance) {
_controller.selectMaintenance(maintenance);
_showMaintenanceForm(maintenance: maintenance);
}
void _showExpiringMaintenances() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('만료 예정 유지보수'),
content: SizedBox(
width: 600,
height: 400,
child: ListView.builder(
itemCount: _controller.expiringMaintenances.length,
itemBuilder: (context, index) {
final maintenance = _controller.expiringMaintenances[index];
return ListTile(
title: Text(maintenance.equipmentSerial ?? '시리얼 번호 없음'),
subtitle: Text(
'${MaintenanceType.getDisplayName(maintenance.maintenanceType)} - '
'${DateFormat('yyyy-MM-dd').format(maintenance.endedAt)} 만료',
),
trailing: maintenance.daysRemaining != null
? Text(
'${maintenance.daysRemaining}일 남음',
style: TextStyle(
color: maintenance.daysRemaining! <= 7
? Colors.red
: Colors.orange,
fontWeight: FontWeight.bold,
),
)
: null,
onTap: () {
Navigator.pop(context);
_showMaintenanceDetail(maintenance);
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('닫기'),
),
],
),
);
}
void _deleteMaintenance(MaintenanceDto maintenance) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('유지보수 삭제'),
content: Text(
'${maintenance.equipmentSerial ?? "선택된"} 장비의 유지보수를 삭제하시겠습니까?\n'
'삭제된 데이터는 복구할 수 있습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
final success = await _controller.deleteMaintenance(maintenance.id!);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('유지보수가 삭제되었습니다')),
);
}
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _showDeleteConfirmation() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('선택된 유지보수 삭제'),
content: Text('선택된 ${_selectedItems.length}개의 유지보수를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
// TODO: 일괄 삭제 구현
setState(() {
_selectedItems.clear();
});
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}

View File

@@ -179,47 +179,6 @@ class _MaintenanceScheduleScreenState extends State<MaintenanceScheduleScreen>
);
}
Widget _buildStatCard(
String title,
String value,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
],
),
);
}
Widget _buildFilterBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
@@ -513,17 +472,6 @@ class _MaintenanceScheduleScreenState extends State<MaintenanceScheduleScreen>
);
}
void _showCreateMaintenanceDialog() {
showDialog(
context: context,
builder: (context) => const MaintenanceFormDialog(),
).then((result) {
if (result == true) {
context.read<MaintenanceController>().loadMaintenances(refresh: true);
}
});
}
void _showMaintenanceDetails(MaintenanceDto maintenance) {
showDialog(
context: context,

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/data/models/model/model_dto.dart';
import 'package:superport/data/models/vendor_dto.dart';
import 'package:superport/screens/model/controllers/model_controller.dart';
@@ -143,14 +143,14 @@ class ModelGroupedTable extends StatelessWidget {
SizedBox(
width: 80,
child: ShadBadge(
backgroundColor: model.isActive
backgroundColor: !model.isDeleted
? Colors.green.shade100
: Colors.grey.shade200,
child: Text(
model.isActive ? '활성' : '비활성',
!model.isDeleted ? '활성' : '비활성',
style: TextStyle(
fontSize: 12,
color: model.isActive ? Colors.green.shade700 : Colors.grey.shade700,
color: !model.isDeleted ? Colors.green.shade700 : Colors.grey.shade700,
),
),
),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/data/models/model/model_dto.dart';
import 'package:superport/data/models/vendor_dto.dart';
import 'package:superport/screens/model/controllers/model_controller.dart';
@@ -206,6 +206,8 @@ class _ModelVendorCascadeState extends State<ModelVendorCascade> {
id: value,
vendorsId: _selectedVendorId!,
name: 'Unknown',
isDeleted: false,
registeredAt: DateTime.now(),
),
);
return Text(model.name);

View File

@@ -1,17 +1,29 @@
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/data/models/model/model_dto.dart';
import 'package:superport/data/models/vendor_dto.dart';
import 'package:superport/domain/usecases/model_usecase.dart';
import 'package:superport/domain/usecases/models/get_models_usecase.dart';
import 'package:superport/domain/usecases/models/create_model_usecase.dart';
import 'package:superport/domain/usecases/models/update_model_usecase.dart';
import 'package:superport/domain/usecases/models/delete_model_usecase.dart';
import 'package:superport/domain/usecases/vendor_usecase.dart';
/// Model 관리 화면의 상태 관리 Controller
@lazySingleton
class ModelController extends ChangeNotifier {
final ModelUseCase _modelUseCase;
final GetModelsUseCase _getModelsUseCase;
final CreateModelUseCase _createModelUseCase;
final UpdateModelUseCase _updateModelUseCase;
final DeleteModelUseCase _deleteModelUseCase;
final VendorUseCase _vendorUseCase;
ModelController(this._modelUseCase, this._vendorUseCase);
ModelController(
this._getModelsUseCase,
this._createModelUseCase,
this._updateModelUseCase,
this._deleteModelUseCase,
this._vendorUseCase,
);
// 상태 변수들
List<ModelDto> _models = [];
@@ -20,8 +32,10 @@ class ModelController extends ChangeNotifier {
final Map<int, List<ModelDto>> _modelsByVendor = {};
bool _isLoading = false;
bool _isLoadingVendors = false;
bool _isSubmitting = false;
String? _errorMessage;
String? _vendorsError;
String _searchQuery = '';
int? _selectedVendorId;
@@ -31,8 +45,10 @@ class ModelController extends ChangeNotifier {
List<VendorDto> get vendors => _vendors;
Map<int, List<ModelDto>> get modelsByVendor => _modelsByVendor;
bool get isLoading => _isLoading;
bool get isLoadingVendors => _isLoadingVendors;
bool get isSubmitting => _isSubmitting;
String? get errorMessage => _errorMessage;
String? get vendorsError => _vendorsError;
String get searchQuery => _searchQuery;
int? get selectedVendorId => _selectedVendorId;
int get totalCount => _filteredModels.length;
@@ -44,25 +60,40 @@ class ModelController extends ChangeNotifier {
notifyListeners();
try {
// Vendor와 Model 데이터 병렬 로드
final results = await Future.wait([
_vendorUseCase.getVendors(),
_modelUseCase.getModels(),
]);
// VendorListResponse에서 items 추출
final vendorResponse = results[0] as VendorListResponse;
_vendors = vendorResponse.items;
// Vendor 데이터 로드 (완전 안전한 오류 처리)
try {
final vendorResponse = await _vendorUseCase.getVendors();
if (vendorResponse != null && vendorResponse.items.isNotEmpty) {
_vendors = List<VendorDto>.from(vendorResponse.items);
} else {
_vendors = <VendorDto>[];
}
} catch (vendorError) {
_vendors = <VendorDto>[];
_vendorsError = "제조사 목록 로드 실패: $vendorError";
}
// ModelUseCase는 이미 List<ModelDto>를 반환
_models = results[1] as List<ModelDto>;
_filteredModels = List.from(_models);
// Model 데이터 로드 (안전한 처리)
const params = GetModelsParams(page: 1, perPage: 100);
final modelResult = await _getModelsUseCase(params);
modelResult.fold(
(failure) => throw Exception(failure.message),
(modelResponse) {
if (modelResponse != null && modelResponse.items.isNotEmpty) {
_models = List<ModelDto>.from(modelResponse.items);
_filteredModels = List.from(_models);
} else {
_models = <ModelDto>[];
_filteredModels = <ModelDto>[];
}
},
);
// Vendor별로 모델 그룹핑
await _groupModelsByVendor();
} catch (e) {
_errorMessage = e.toString();
_errorMessage = "모델 목록 조회 실패: ${e.toString()}";
} finally {
_isLoading = false;
notifyListeners();
@@ -74,8 +105,23 @@ class ModelController extends ChangeNotifier {
_errorMessage = null;
try {
_models = List.from(await _modelUseCase.getModels(vendorId: _selectedVendorId));
_applyFilters();
final params = GetModelsParams(
page: 1,
perPage: 100,
vendorId: _selectedVendorId,
);
final result = await _getModelsUseCase(params);
result.fold(
(failure) => throw Exception(failure.message),
(modelResponse) {
if (modelResponse != null && modelResponse.items.isNotEmpty) {
_models = List<ModelDto>.from(modelResponse.items);
} else {
_models = <ModelDto>[];
}
_applyFilters();
},
);
await _groupModelsByVendor();
} catch (e) {
_errorMessage = e.toString();
@@ -94,17 +140,22 @@ class ModelController extends ChangeNotifier {
notifyListeners();
try {
final newModel = await _modelUseCase.createModel(
vendorsId: vendorsId,
name: name,
);
// 목록에 추가
_models = [..._models, newModel];
_applyFilters();
await _groupModelsByVendor();
final request = CreateModelRequest(vendorsId: vendorsId, name: name);
final result = await _createModelUseCase(request);
return true;
return result.fold(
(failure) {
_errorMessage = failure.message;
return false;
},
(newModel) {
// 목록에 추가
_models = [..._models, newModel];
_applyFilters();
_groupModelsByVendor();
return true;
},
);
} catch (e) {
_errorMessage = e.toString();
return false;
@@ -125,23 +176,28 @@ class ModelController extends ChangeNotifier {
notifyListeners();
try {
final updatedModel = await _modelUseCase.updateModel(
id: id,
vendorsId: vendorsId,
name: name,
);
// 목록에서 업데이트
final index = _models.indexWhere((m) => m.id == id);
if (index != -1) {
_models = _models.map((model) =>
model.id == id ? updatedModel : model
).toList();
_applyFilters();
await _groupModelsByVendor();
}
final request = UpdateModelRequest(vendorsId: vendorsId, name: name);
final params = UpdateModelParams(id: id, request: request);
final result = await _updateModelUseCase(params);
return true;
return result.fold(
(failure) {
_errorMessage = failure.message;
return false;
},
(updatedModel) {
// 목록에서 업데이트
final index = _models.indexWhere((m) => m.id == id);
if (index != -1) {
_models = _models.map((model) =>
model.id == id ? updatedModel : model
).toList();
_applyFilters();
_groupModelsByVendor();
}
return true;
},
);
} catch (e) {
_errorMessage = e.toString();
return false;
@@ -158,14 +214,21 @@ class ModelController extends ChangeNotifier {
notifyListeners();
try {
await _modelUseCase.deleteModel(id);
final result = await _deleteModelUseCase(id);
// 목록에서 제거 또는 비활성화 표시
_models = _models.where((m) => m.id != id).toList();
_applyFilters();
await _groupModelsByVendor();
return true;
return result.fold(
(failure) {
_errorMessage = failure.message;
return false;
},
(_) {
// 목록에서 제거 또는 비활성화 표시
_models = _models.where((m) => m.id != id).toList();
_applyFilters();
_groupModelsByVendor();
return true;
},
);
} catch (e) {
_errorMessage = e.toString();
return false;

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/data/models/model/model_dto.dart';
import 'package:superport/screens/model/controllers/model_controller.dart';
class ModelFormDialog extends StatefulWidget {
@@ -52,27 +52,90 @@ class _ModelFormDialogState extends State<ModelFormDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Vendor 선택
ShadSelect<int>(
placeholder: const Text('제조사 선택'),
options: widget.controller.vendors.map(
(vendor) => ShadOption(
value: vendor.id,
child: Text(vendor.name),
),
).toList(),
selectedOptionBuilder: (context, value) {
final vendor = widget.controller.vendors.firstWhere(
(v) => v.id == value,
);
return Text(vendor.name);
},
onChanged: (value) {
setState(() {
_selectedVendorId = value;
});
},
initialValue: _selectedVendorId,
// 제조사 선택 (3단계 상태 처리)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('제조사 선택 *', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
// 3단계 상태 처리 (UserForm 패턴 적용)
widget.controller.isLoadingVendors
? const SizedBox(
height: 56,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ShadProgress(),
SizedBox(width: 8),
Text('제조사 목록을 불러오는 중...'),
],
),
),
)
// 오류 발생 시 오류 컨테이너 표시
: widget.controller.vendorsError != null
? Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border.all(color: Colors.red.shade200),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.error, color: Colors.red.shade600, size: 20),
const SizedBox(width: 8),
const Text('제조사 목록 로딩 실패', style: TextStyle(fontWeight: FontWeight.w500)),
],
),
const SizedBox(height: 8),
Text(
widget.controller.vendorsError!,
style: TextStyle(color: Colors.red.shade700, fontSize: 14),
),
const SizedBox(height: 12),
ShadButton(
onPressed: () => widget.controller.loadInitialData(),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 16),
SizedBox(width: 4),
Text('다시 시도'),
],
),
),
],
),
)
// 정상 상태: 드롭다운 표시
: ShadSelect<int>(
placeholder: const Text('제조사를 선택하세요'),
options: widget.controller.vendors.map(
(vendor) => ShadOption(
value: vendor.id,
child: Text(vendor.name),
),
).toList(),
selectedOptionBuilder: (context, value) {
final vendor = widget.controller.vendors.firstWhere(
(v) => v.id == value,
);
return Text(vendor.name);
},
onChanged: (value) {
setState(() {
_selectedVendorId = value;
});
},
initialValue: _selectedVendorId,
),
],
),
const SizedBox(height: 16),
@@ -202,7 +265,7 @@ class _ModelFormDialogState extends State<ModelFormDialog> {
if (widget.model != null) {
// 수정
success = await widget.controller.updateModel(
id: widget.model!.id!,
id: widget.model!.id,
vendorsId: _selectedVendorId!,
name: _nameController.text,
);

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/model_dto.dart';
import 'package:superport/data/models/model/model_dto.dart';
import 'package:superport/screens/model/controllers/model_controller.dart';
import 'package:superport/screens/model/model_form_dialog.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
@@ -113,12 +113,12 @@ class _ModelListScreenState extends State<ModelListScreen> {
child: ShadSelect<int?>(
placeholder: const Text('제조사 선택'),
options: [
const ShadOption(
const ShadOption<int?>(
value: null,
child: Text('전체'),
),
...controller.vendors.map(
(vendor) => ShadOption(
(vendor) => ShadOption<int?>(
value: vendor.id,
child: Text(vendor.name),
),
@@ -128,8 +128,12 @@ class _ModelListScreenState extends State<ModelListScreen> {
if (value == null) {
return const Text('전체');
}
final vendor = controller.vendors.firstWhere((v) => v.id == value);
return Text(vendor.name);
try {
final vendor = controller.vendors.firstWhere((v) => v.id == value);
return Text(vendor.name);
} catch (_) {
return const Text('전체');
}
},
onChanged: controller.setVendorFilter,
),
@@ -266,7 +270,7 @@ class _ModelListScreenState extends State<ModelListScreen> {
_buildDataCell(
Text(
model.registeredAt != null
? DateFormat('yyyy-MM-dd').format(model.registeredAt!)
? DateFormat('yyyy-MM-dd').format(model.registeredAt)
: '-',
style: ShadcnTheme.bodySmall,
),
@@ -455,7 +459,7 @@ class _ModelListScreenState extends State<ModelListScreen> {
ShadButton.destructive(
onPressed: () async {
Navigator.of(context).pop();
await _controller.deleteModel(model.id!);
await _controller.deleteModel(model.id);
},
child: const Text('삭제'),
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
import '../../../core/constants/app_constants.dart';
import '../../../data/models/rent_dto.dart';
import '../../../domain/usecases/rent_usecase.dart';
@@ -34,7 +35,7 @@ class RentController with ChangeNotifier {
// 페이징 관련 getter (UI 호환성)
int get currentPage => 1; // 단순화된 페이징 구조
int get totalPages => (_rents.length / 10).ceil();
int get totalPages => (_rents.length / AppConstants.rentPageSize).ceil();
int get totalItems => _rents.length;
@@ -53,11 +54,15 @@ class RentController with ChangeNotifier {
notifyListeners();
}
/// 임대 목록 조회
/// 임대 목록 조회 (백엔드 실제 파라미터)
Future<void> loadRents({
int page = 1,
int pageSize = 10,
String? search,
int perPage = AppConstants.rentPageSize,
int? equipmentId,
int? companyId,
bool? isActive,
DateTime? dateFrom,
DateTime? dateTo,
bool refresh = false,
}) async {
try {
@@ -70,14 +75,16 @@ class RentController with ChangeNotifier {
final response = await _rentUseCase.getRents(
page: page,
pageSize: pageSize,
search: search,
// status: _selectedStatus, // 삭제된 파라미터
equipmentHistoryId: _selectedEquipmentHistoryId,
perPage: perPage,
equipmentId: equipmentId,
companyId: companyId,
isActive: isActive,
dateFrom: dateFrom,
dateTo: dateTo,
);
// response를 List<RentDto>로 캐스팅
_rents = response as List<RentDto>;
// 올바른 RentListResponse.items 접근
_rents = response.items;
clearError();
} catch (e) {
@@ -192,16 +199,36 @@ class RentController with ChangeNotifier {
);
}
/// 상태 필터 설정
void setStatusFilter(String? status) {
_selectedStatus = status;
notifyListeners();
/// 진행중인 임대 조회 (백엔드 실존 API)
Future<void> loadActiveRents() async {
try {
_setLoading(true);
final activeRents = await _rentUseCase.getActiveRents();
_rents = activeRents;
clearError();
} catch (e) {
_setError('진행중인 임대를 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 장비 이력 필터 설정
void setEquipmentHistoryFilter(int? equipmentHistoryId) {
_selectedEquipmentHistoryId = equipmentHistoryId;
notifyListeners();
/// 장비별 임대 조회 (백엔드 필터링)
Future<void> loadRentsByEquipment(int equipmentId) async {
try {
_setLoading(true);
final equipmentRents = await _rentUseCase.getRentsByEquipment(equipmentId);
_rents = equipmentRents;
clearError();
} catch (e) {
_setError('장비별 임대를 불러오는데 실패했습니다: $e');
} finally {
_setLoading(false);
}
}
/// 필터 초기화
@@ -210,6 +237,12 @@ class RentController with ChangeNotifier {
_selectedEquipmentHistoryId = null;
notifyListeners();
}
/// 장비 이력 필터 설정 (UI 호환성)
void setEquipmentHistoryFilter(int? equipmentHistoryId) {
_selectedEquipmentHistoryId = equipmentHistoryId;
notifyListeners();
}
/// 선택된 임대 초기화
void clearSelectedRent() {
@@ -217,19 +250,28 @@ class RentController with ChangeNotifier {
notifyListeners();
}
// 간단한 기간 계산 (클라이언트 사이드)
int calculateRentDays(DateTime startDate, DateTime endDate) {
return endDate.difference(startDate).inDays;
}
// 임대 상태 간단 판단
// 백엔드 계산 필드 활용 (강력한 기능)
String getRentStatus(RentDto rent) {
// 백엔드에서 계산된 is_active 필드 활용
if (rent.isActive == true) return '진행중';
// 날짜 기반 판단 (Fallback)
final now = DateTime.now();
if (rent.startedAt.isAfter(now)) return '예약';
if (rent.endedAt.isBefore(now)) return '종료';
return '진행중';
}
// 백엔드에서 계산된 남은 일수 활용
int getRemainingDays(RentDto rent) {
return rent.daysRemaining ?? 0;
}
// 백엔드에서 계산된 총 일수 활용
int getTotalDays(RentDto rent) {
return rent.totalDays ?? 0;
}
// UI 호환성을 위한 상태 표시명
String getRentStatusDisplayName(String status) {
switch (status.toLowerCase()) {
@@ -252,13 +294,24 @@ class RentController with ChangeNotifier {
await loadRents(refresh: true);
}
/// 임대 반납 처리 (endedAt를 현재 시간으로 수정)
/// 임대 반납 처리 (백엔드 PUT API 활용)
Future<bool> returnRent(int id) async {
return await updateRent(
id: id,
endedAt: DateTime.now(),
);
}
/// 상태 필터 설정 (UI 호환성)
void setStatusFilter(String? status) {
_selectedStatus = status;
notifyListeners();
}
/// 임대 기간 계산 (UI 호환성)
int calculateRentDays(DateTime startDate, DateTime endDate) {
return endDate.difference(startDate).inDays;
}
/// 임대 총 비용 계산 (문자열 날짜 기반)
double calculateTotalRent(String startDate, String endDate, double dailyRate) {

View File

@@ -1,10 +1,16 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../common/theme_shadcn.dart';
import '../../injection_container.dart';
import '../common/widgets/standard_states.dart';
import 'controllers/rent_controller.dart';
import '../maintenance/controllers/maintenance_controller.dart';
import '../inventory/controllers/equipment_history_controller.dart';
import '../../data/models/rent_dto.dart';
import '../../data/models/maintenance_dto.dart';
import '../../data/models/stock_status_dto.dart';
class RentDashboard extends StatefulWidget {
const RentDashboard({super.key});
@@ -14,34 +20,49 @@ class RentDashboard extends StatefulWidget {
}
class _RentDashboardState extends State<RentDashboard> {
late final RentController _controller;
late final RentController _rentController;
late final MaintenanceController _maintenanceController;
late final EquipmentHistoryController _equipmentHistoryController;
@override
void initState() {
super.initState();
_controller = getIt<RentController>();
_rentController = getIt<RentController>();
_maintenanceController = getIt<MaintenanceController>();
_equipmentHistoryController = getIt<EquipmentHistoryController>();
_loadData();
}
Future<void> _loadData() async {
await _controller.loadRents();
await Future.wait([
_rentController.loadActiveRents(),
_maintenanceController.loadExpiringMaintenances(days: 30),
_equipmentHistoryController.loadStockStatus(),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ShadcnTheme.background,
body: ChangeNotifierProvider.value(
value: _controller,
child: Consumer<RentController>(
builder: (context, controller, child) {
if (controller.isLoading) {
return const StandardLoadingState(message: '임대 정보를 불러오는 중...');
body: MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _rentController),
ChangeNotifierProvider.value(value: _maintenanceController),
ChangeNotifierProvider.value(value: _equipmentHistoryController),
],
child: Consumer3<RentController, MaintenanceController, EquipmentHistoryController>(
builder: (context, rentController, maintenanceController, equipmentHistoryController, child) {
bool isLoading = rentController.isLoading || maintenanceController.isLoading || equipmentHistoryController.isLoading;
if (isLoading) {
return const StandardLoadingState(message: '대시보드 정보를 불러오는 중...');
}
if (controller.hasError) {
if (rentController.hasError || maintenanceController.error != null || equipmentHistoryController.errorMessage != null) {
String errorMsg = rentController.error ?? maintenanceController.error ?? equipmentHistoryController.errorMessage ?? '알 수 없는 오류';
return StandardErrorState(
message: controller.error!,
message: errorMsg,
onRetry: _loadData,
);
}
@@ -52,35 +73,29 @@ class _RentDashboardState extends State<RentDashboard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 제목
Text('임대 현황', style: ShadcnTheme.headingH3),
const SizedBox(height: 24),
// 임대 목록 보기 버튼
_buildViewRentListButton(),
Row(
children: [
Icon(Icons.dashboard_outlined, size: 32, color: ShadcnTheme.primary),
const SizedBox(width: 12),
Text('ERP 대시보드', style: ShadcnTheme.headingH2),
],
),
const SizedBox(height: 32),
// 백엔드에 대시보드 API가 없어 연체/진행중 데이터를 표시할 수 없음
Center(
child: Container(
padding: const EdgeInsets.all(40),
child: Column(
children: [
Icon(Icons.info_outline, size: 64, color: Colors.blue[400]),
const SizedBox(height: 16),
Text(
'임대 대시보드 기능은 백엔드 API가 준비되면 제공될 예정입니다.',
style: ShadcnTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'임대 목록에서 단순 데이터를 확인하세요.',
style: ShadcnTheme.bodyMedium.copyWith(color: ShadcnTheme.foregroundMuted),
),
],
),
),
),
// 대시보드 카드들
_buildDashboardCards(rentController, maintenanceController, equipmentHistoryController),
const SizedBox(height: 32),
// 진행중 임대 현황
_buildActiveRentsSection(rentController),
const SizedBox(height: 24),
// 만료 예정 정비
_buildExpiringMaintenanceSection(maintenanceController),
const SizedBox(height: 24),
// 재고 현황 요약
_buildStockStatusSection(equipmentHistoryController),
],
),
);
@@ -90,27 +105,392 @@ class _RentDashboardState extends State<RentDashboard> {
);
}
Widget _buildViewRentListButton() {
return Center(
child: ElevatedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/rent/list');
},
icon: const Icon(Icons.list),
label: const Text('임대 목록 보기'),
style: ElevatedButton.styleFrom(
backgroundColor: ShadcnTheme.primary,
foregroundColor: ShadcnTheme.primaryForeground,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
/// 대시보드 카드들
Widget _buildDashboardCards(RentController rentController, MaintenanceController maintenanceController, EquipmentHistoryController equipmentHistoryController) {
return Row(
children: [
Expanded(
child: _buildDashboardCard(
'진행중 임대',
rentController.rents.length.toString(),
Icons.business,
ShadcnTheme.primary,
() => Navigator.pushNamed(context, '/rent/list'),
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDashboardCard(
'만료 예정 정비',
maintenanceController.expiringMaintenances.length.toString(),
Icons.warning,
Colors.orange,
() => Navigator.pushNamed(context, '/maintenance/list'),
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDashboardCard(
'재고 현황',
equipmentHistoryController.stockStatus.length.toString(),
Icons.inventory,
Colors.green,
() => Navigator.pushNamed(context, '/inventory/history'),
),
),
],
);
}
/// 대시보드 카드 단일 사용자
Widget _buildDashboardCard(String title, String count, IconData icon, Color color, VoidCallback onTap) {
return ShadCard(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(icon, size: 28, color: color),
Text(
count,
style: ShadcnTheme.headingH1.copyWith(color: color),
),
],
),
const SizedBox(height: 12),
Text(
title,
style: ShadcnTheme.bodyLarge.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'자세히 보기',
style: ShadcnTheme.bodySmall.copyWith(
color: color,
),
),
],
),
),
),
);
}
String _formatCurrency(dynamic amount) {
if (amount == null) return '0';
final num = amount is String ? double.tryParse(amount) ?? 0 : amount.toDouble();
return num.toStringAsFixed(0);
/// 진행중 임대 섹션
Widget _buildActiveRentsSection(RentController controller) {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('진행중 임대 현황', style: ShadcnTheme.headingH3),
ShadButton.outline(
onPressed: () => Navigator.pushNamed(context, '/rent/list'),
child: const Text('전체 보기'),
),
],
),
const SizedBox(height: 16),
if (controller.rents.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('진행중인 임대가 없습니다.', style: ShadcnTheme.bodyMedium),
),
)
else
...controller.rents.take(5).map((rent) => _buildRentItem(rent)),
],
),
),
);
}
/// 임대 아이템
Widget _buildRentItem(RentDto rent) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: ShadcnTheme.border,
width: 0.5,
),
),
),
child: Row(
children: [
// 장비 정보
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
rent.equipmentSerial ?? 'N/A',
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
if (rent.equipmentModel != null)
Text(
rent.equipmentModel!,
style: ShadcnTheme.bodySmall.copyWith(color: ShadcnTheme.foregroundMuted),
),
],
),
),
// 임대 회사
Expanded(
flex: 2,
child: Text(
rent.companyName ?? 'N/A',
style: ShadcnTheme.bodyMedium,
),
),
// 남은 일수
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: _getRentStatusColor(rent).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${rent.daysRemaining ?? 0}',
style: ShadcnTheme.bodySmall.copyWith(
color: _getRentStatusColor(rent),
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
/// 만료 예정 정비 섹션
Widget _buildExpiringMaintenanceSection(MaintenanceController controller) {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('만료 예정 정비 (30일 이내)', style: ShadcnTheme.headingH3),
ShadButton.outline(
onPressed: () => Navigator.pushNamed(context, '/maintenance/list'),
child: const Text('전체 보기'),
),
],
),
const SizedBox(height: 16),
if (controller.expiringMaintenances.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('만료 예정 정비가 없습니다.', style: ShadcnTheme.bodyMedium),
),
)
else
...controller.expiringMaintenances.take(5).map((maintenance) => _buildMaintenanceItem(maintenance)),
],
),
),
);
}
/// 정비 아이템
Widget _buildMaintenanceItem(MaintenanceDto maintenance) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: ShadcnTheme.border,
width: 0.5,
),
),
),
child: Row(
children: [
// 정비 정보
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getMaintenanceTypeName(maintenance.maintenanceType),
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
Text(
'종료일: ${_formatDate(maintenance.endedAt)}',
style: ShadcnTheme.bodySmall.copyWith(color: ShadcnTheme.foregroundMuted),
),
],
),
),
// 남은 일수
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${maintenance.daysRemaining ?? 0}',
style: ShadcnTheme.bodySmall.copyWith(
color: Colors.orange,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
/// 재고 현황 섹션
Widget _buildStockStatusSection(EquipmentHistoryController controller) {
return ShadCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('재고 현황 요약', style: ShadcnTheme.headingH3),
ShadButton.outline(
onPressed: () => Navigator.pushNamed(context, '/inventory/dashboard'),
child: const Text('상세 보기'),
),
],
),
const SizedBox(height: 16),
if (controller.stockStatus.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('재고 정보가 없습니다.', style: ShadcnTheme.bodyMedium),
),
)
else
...controller.stockStatus.take(5).map((stock) => _buildStockItem(stock)),
],
),
),
);
}
/// 재고 아이템
Widget _buildStockItem(StockStatusDto stock) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: ShadcnTheme.border,
width: 0.5,
),
),
),
child: Row(
children: [
// 장비 정보
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stock.equipmentSerial,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
if (stock.modelName != null)
Text(
stock.modelName!,
style: ShadcnTheme.bodySmall.copyWith(color: ShadcnTheme.foregroundMuted),
),
],
),
),
// 창고 위치
Expanded(
flex: 1,
child: Text(
stock.warehouseName,
style: ShadcnTheme.bodyMedium,
),
),
// 재고 수량
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: stock.currentQuantity > 0 ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${stock.currentQuantity}',
style: ShadcnTheme.bodySmall.copyWith(
color: stock.currentQuantity > 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
// 유틸리티 메서드들
Color _getRentStatusColor(RentDto rent) {
final remainingDays = rent.daysRemaining ?? 0;
if (remainingDays <= 0) return Colors.red;
if (remainingDays <= 7) return Colors.orange;
return Colors.green;
}
String _getMaintenanceTypeName(String? type) {
switch (type) {
case 'WARRANTY':
return '무상보증';
case 'CONTRACT':
return '유상계약';
case 'INSPECTION':
return '점검';
default:
return '알수없음';
}
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:get_it/get_it.dart';
import '../../data/models/rent_dto.dart';
import '../equipment/controllers/equipment_history_controller.dart';
class RentFormDialog extends StatefulWidget {
final RentDto? rent;
@@ -19,6 +21,7 @@ class RentFormDialog extends StatefulWidget {
class _RentFormDialogState extends State<RentFormDialog> {
final _formKey = GlobalKey<FormState>();
late final EquipmentHistoryController _historyController;
// 백엔드 스키마에 맞는 필드만 유지
int? _selectedEquipmentHistoryId;
@@ -26,13 +29,43 @@ class _RentFormDialogState extends State<RentFormDialog> {
DateTime? _endDate;
bool _isLoading = false;
bool _isLoadingHistories = false;
String? _historiesError;
@override
void initState() {
super.initState();
_historyController = GetIt.instance<EquipmentHistoryController>();
if (widget.rent != null) {
_initializeForm(widget.rent!);
}
_loadEquipmentHistories();
}
Future<void> _loadEquipmentHistories() async {
setState(() {
_isLoadingHistories = true;
_historiesError = null; // 오류 상태 초기화
});
try {
await _historyController.loadHistory();
setState(() => _historiesError = null); // 성공 시 오류 상태 클리어
} catch (e) {
debugPrint('장비 이력 로딩 실패: $e');
setState(() => _historiesError = '장비 이력을 불러오는 중 오류가 발생했습니다: ${e.toString()}');
} finally {
if (mounted) {
setState(() => _isLoadingHistories = false);
}
}
}
// 재시도 기능 추가 (UserForm 패턴과 동일)
void _retryLoadHistories() {
_loadEquipmentHistories();
}
@override
@@ -132,26 +165,109 @@ class _RentFormDialogState extends State<RentFormDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 장비 이력 ID (백엔드 필수 필드)
TextFormField(
decoration: const InputDecoration(
labelText: '장비 이력 ID *',
border: OutlineInputBorder(),
helperText: '임대할 장비의 이력 ID를 입력하세요',
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (value) {
if (value == null || value.isEmpty) {
return '장비 이력 ID는 필수입니다';
}
return null;
},
onChanged: (value) {
_selectedEquipmentHistoryId = int.tryParse(value);
},
initialValue: _selectedEquipmentHistoryId?.toString(),
),
// 장비 이력 선택 (백엔드 필수 필드)
Text('장비 선택 *', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
// 3단계 상태 처리 (UserForm 패턴 적용)
_isLoadingHistories
? const SizedBox(
height: 56,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ShadProgress(),
SizedBox(width: 8),
Text('장비 이력을 불러오는 중...'),
],
),
),
)
// 오류 발생 시 오류 컨테이너 표시
: _historiesError != null
? Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border.all(color: Colors.red.shade200),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.error, color: Colors.red.shade600, size: 20),
const SizedBox(width: 8),
const Text('장비 이력 로딩 실패', style: TextStyle(fontWeight: FontWeight.w500)),
],
),
const SizedBox(height: 8),
Text(
_historiesError!,
style: TextStyle(color: Colors.red.shade700, fontSize: 14),
),
const SizedBox(height: 12),
ShadButton(
onPressed: _retryLoadHistories,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.refresh, size: 16),
SizedBox(width: 4),
Text('다시 시도'),
],
),
),
],
),
)
// 정상 상태: 드롭다운 표시
: ShadSelect<int>(
placeholder: const Text('장비를 선택하세요'),
options: _historyController.historyList.map((history) {
return ShadOption(
value: history.id!,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${history.equipment?.serialNumber ?? "Serial N/A"} - ${history.equipment?.modelName ?? "Model N/A"}',
style: const TextStyle(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
Text(
'거래: ${history.transactionType} | 수량: ${history.quantity} | ${history.transactedAt.toString().split(' ')[0]}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}).toList(),
selectedOptionBuilder: (context, value) {
final selectedHistory = _historyController.historyList
.firstWhere((h) => h.id == value);
return Text(
'${selectedHistory.equipment?.serialNumber ?? "Serial N/A"} - ${selectedHistory.equipment?.modelName ?? "Model N/A"}',
overflow: TextOverflow.ellipsis,
);
},
onChanged: (value) {
setState(() {
_selectedEquipmentHistoryId = value;
});
},
initialValue: _selectedEquipmentHistoryId,
),
const SizedBox(height: 20),
// 임대 기간 (백엔드 필수 필드)

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/constants/app_constants.dart';
import '../../data/models/rent_dto.dart';
import '../../injection_container.dart';
import '../common/widgets/pagination.dart';
@@ -37,10 +38,6 @@ class _RentListScreenState extends State<RentListScreen> {
await _controller.loadRents();
}
Future<void> _refresh() async {
await _controller.loadRents(refresh: true);
}
void _showCreateDialog() {
showDialog(
context: context,
@@ -313,19 +310,6 @@ class _RentListScreenState extends State<RentListScreen> {
);
}
Color _getStatusColor(String? status) {
switch (status) {
case '진행중':
return Colors.blue;
case '종료':
return Colors.green;
case '예약':
return Colors.orange;
default:
return Colors.grey;
}
}
/// 상태 배지 빌더
Widget _buildStatusChip(String? status) {
switch (status) {
@@ -486,7 +470,7 @@ class _RentListScreenState extends State<RentListScreen> {
return Pagination(
totalCount: controller.totalRents,
currentPage: controller.currentPage,
pageSize: 20,
pageSize: AppConstants.rentPageSize,
onPageChanged: (page) => controller.loadRents(page: page),
);
},

View File

@@ -49,6 +49,7 @@ class UserFormController extends ChangeNotifier {
// 회사 목록 (드롭다운용)
Map<int, String> _companies = {};
bool _isLoadingCompanies = false;
String? _companiesError;
// Getters
bool get isLoading => _isLoading;
@@ -57,6 +58,7 @@ class UserFormController extends ChangeNotifier {
String? get emailDuplicateMessage => _emailDuplicateMessage;
Map<int, String> get companies => _companies;
bool get isLoadingCompanies => _isLoadingCompanies;
String? get companiesError => _companiesError;
/// 현재 전화번호 (드롭다운 + 텍스트 필드 → 통합 형태)
String get combinedPhoneNumber {
@@ -143,6 +145,7 @@ class UserFormController extends ChangeNotifier {
/// 회사 목록 로드
Future<void> _loadCompanies() async {
_isLoadingCompanies = true;
_companiesError = null;
notifyListeners();
try {
@@ -151,6 +154,7 @@ class UserFormController extends ChangeNotifier {
result.fold(
(failure) {
debugPrint('회사 목록 로드 실패: ${failure.message}');
_companiesError = '회사 목록을 불러오는데 실패했습니다: ${failure.message}';
},
(paginatedResponse) {
_companies = {};
@@ -159,16 +163,23 @@ class UserFormController extends ChangeNotifier {
_companies[company.id!] = company.name;
}
}
_companiesError = null; // 성공 시 에러 상태 초기화
},
);
} catch (e) {
debugPrint('회사 목록 로드 오류: $e');
_companiesError = '회사 목록을 불러오는 중 오류가 발생했습니다: $e';
} finally {
_isLoadingCompanies = false;
notifyListeners();
}
}
/// 회사 목록 재로드 (사용자가 재시도할 때 호출)
Future<void> retryLoadCompanies() async {
await _loadCompanies();
}
/// 이메일 중복 검사 (저장 시점에만 실행)
Future<bool> checkDuplicateEmail(String email) async {

View File

@@ -5,6 +5,7 @@ import 'package:superport/utils/validators.dart';
import 'package:flutter/services.dart';
import 'package:superport/screens/user/controllers/user_form_controller.dart';
import 'package:superport/utils/formatters/korean_phone_formatter.dart';
import 'package:superport/screens/common/widgets/standard_dropdown.dart';
// 사용자 등록/수정 화면 (UI만 담당, 상태/로직 분리)
class UserFormScreen extends StatefulWidget {
@@ -213,40 +214,35 @@ class _UserFormScreenState extends State<UserFormScreen> {
);
}
// 회사 선택 드롭다운
// 회사 선택 드롭다운 (StandardDropdown 사용)
Widget _buildCompanyDropdown(UserFormController controller) {
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('회사 *', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
controller.isLoadingCompanies
? const ShadProgress()
: ShadSelect<int?>(
selectedOptionBuilder: (context, value) {
if (value == null) {
return const Text('회사를 선택하세요');
}
final companyName = controller.companies[value];
return Text(companyName ?? '알 수 없는 회사 (ID: $value)');
},
placeholder: const Text('회사를 선택하세요'),
initialValue: controller.companiesId,
options: controller.companies.entries.map((entry) {
return ShadOption(
value: entry.key,
child: Text(entry.value),
);
}).toList(),
onChanged: (value) {
if (value != null) {
controller.companiesId = value;
}
},
),
],
child: StandardIntDropdown<MapEntry<int, String>>(
label: '회사',
isRequired: true,
items: controller.companies.entries.toList(),
isLoading: controller.isLoadingCompanies,
error: controller.companiesError,
onRetry: () => controller.retryLoadCompanies(),
selectedValue: controller.companiesId != null
? controller.companies.entries
.where((entry) => entry.key == controller.companiesId)
.firstOrNull
: null,
onChanged: (MapEntry<int, String>? selectedCompany) {
controller.companiesId = selectedCompany?.key;
},
itemBuilder: (MapEntry<int, String> company) => Text(company.value),
selectedItemBuilder: (MapEntry<int, String> company) => Text(company.value),
idExtractor: (MapEntry<int, String> company) => company.key,
placeholder: '회사를 선택하세요',
validator: (MapEntry<int, String>? value) {
if (value == null) {
return '회사를 선택해 주세요';
}
return null;
},
),
);
}

View File

@@ -8,6 +8,7 @@ import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/user/controllers/user_list_controller.dart';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/utils/constants.dart';
/// shadcn/ui 스타일로 재설계된 사용자 관리 화면
@@ -28,7 +29,7 @@ class _UserListState extends State<UserList> {
_controller = UserListController();
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.initialize(pageSize: 10);
_controller.initialize(pageSize: AppConstants.userPageSize);
});
_searchController.addListener(() {

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/data/models/vendor_dto.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/constants/app_constants.dart';
class VendorTable extends StatelessWidget {
final List<VendorDto> vendors;
@@ -45,7 +45,7 @@ class VendorTable extends StatelessWidget {
rows: vendors.asMap().entries.map((entry) {
final index = entry.key;
final vendor = entry.value;
final rowNumber = (currentPage - 1) * PaginationConstants.defaultPageSize + index + 1;
final rowNumber = (currentPage - 1) * AppConstants.vendorPageSize + index + 1;
return DataRow(
cells: [

View File

@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/data/models/vendor_dto.dart';
import 'package:superport/domain/usecases/vendor_usecase.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/constants/app_constants.dart';
@injectable
class VendorController extends ChangeNotifier {
@@ -20,7 +20,7 @@ class VendorController extends ChangeNotifier {
int _currentPage = 1;
int _totalPages = 1;
int _totalCount = 0;
final int _pageSize = PaginationConstants.defaultPageSize;
final int _pageSize = AppConstants.vendorPageSize;
// 필터 및 검색
String _searchQuery = '';

View File

@@ -8,10 +8,10 @@ import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/common/widgets/unified_search_bar.dart';
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
import 'package:superport/screens/common/widgets/standard_states.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/warehouse_location/controllers/warehouse_location_list_controller.dart';
import 'package:superport/services/auth_service.dart';
import 'package:superport/core/constants/app_constants.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/widgets/auth_guard.dart';
@@ -36,7 +36,7 @@ class _WarehouseLocationListState
void initState() {
super.initState();
_controller = WarehouseLocationListController();
_controller.pageSize = 10; // 페이지 크기를 10으로 설정
_controller.pageSize = AppConstants.warehousePageSize; // 페이지 크기를 10으로 설정
// 초기 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) async {
_controller.loadWarehouseLocations();
@@ -215,7 +215,7 @@ class _WarehouseLocationListState
// 검색바
searchBar: UnifiedSearchBar(
controller: _searchController,
placeholder: '창고명, 주소, 담당자로 검색',
placeholder: '창고명, 주소로 검색',
onChanged: (value) => _controller.search(value),
onSearch: () => _controller.search(_searchController.text),
onClear: () {
@@ -325,12 +325,9 @@ class _WarehouseLocationListState
_buildHeaderCell('번호', flex: 0, useExpanded: false, minWidth: 50),
_buildHeaderCell('창고명', flex: 2, useExpanded: true, minWidth: 80),
_buildHeaderCell('주소', flex: 3, useExpanded: true, minWidth: 120),
_buildHeaderCell('담당자', flex: 2, useExpanded: true, minWidth: 80),
_buildHeaderCell('연락처', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('수용량', flex: 1, useExpanded: true, minWidth: 70),
_buildHeaderCell('상태', flex: 0, useExpanded: false, minWidth: 70),
_buildHeaderCell('생성일', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('관리', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('관리', flex: 0, useExpanded: false, minWidth: 90),
];
}
@@ -381,36 +378,6 @@ class _WarehouseLocationListState
useExpanded: true,
minWidth: 120,
),
_buildDataCell(
Text(
location.managerName ?? '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
flex: 2,
useExpanded: true,
minWidth: 80,
),
_buildDataCell(
Text(
location.managerPhone ?? '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
Text(
location.capacity?.toString() ?? '-',
style: ShadcnTheme.bodySmall,
textAlign: TextAlign.center,
),
flex: 1,
useExpanded: true,
minWidth: 70,
),
_buildDataCell(
_buildStatusChip(location.isActive),
flex: 0,
@@ -446,7 +413,7 @@ class _WarehouseLocationListState
),
flex: 0,
useExpanded: false,
minWidth: 80,
minWidth: 90,
),
],
),

View File

@@ -1,12 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class ZipcodeSearchFilter extends StatefulWidget {
class ZipcodeSearchFilter extends StatelessWidget {
final Function(String) onSearch;
final Function(String?) onSidoChanged;
final Function(String?) onGuChanged;
final VoidCallback onClearFilters;
final List<String> sidoList;
final List<String> guList;
final String? selectedSido;
@@ -17,72 +15,33 @@ class ZipcodeSearchFilter extends StatefulWidget {
required this.onSearch,
required this.onSidoChanged,
required this.onGuChanged,
required this.onClearFilters,
required this.sidoList,
required this.guList,
this.selectedSido,
this.selectedGu,
});
@override
State<ZipcodeSearchFilter> createState() => _ZipcodeSearchFilterState();
}
class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _sidoScrollController = ScrollController();
final ScrollController _guScrollController = ScrollController();
Timer? _debounceTimer;
bool _hasFilters = false;
@override
void dispose() {
_searchController.dispose();
_sidoScrollController.dispose();
_guScrollController.dispose();
_debounceTimer?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
// 디바운스 처리 (300ms)
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
widget.onSearch(value);
});
_updateHasFilters();
}
void _onSidoChanged(String? value) {
// 빈 문자열을 null로 변환
final actualValue = (value == '') ? null : value;
widget.onSidoChanged(actualValue);
_updateHasFilters();
final actualValue = (value == '' || value == '전체') ? null : value;
onSidoChanged(actualValue);
}
void _onGuChanged(String? value) {
// 빈 문자열을 null로 변환
final actualValue = (value == '') ? null : value;
widget.onGuChanged(actualValue);
_updateHasFilters();
final actualValue = (value == '' || value == '전체') ? null : value;
onGuChanged(actualValue);
}
void _clearFilters() {
setState(() {
_searchController.clear();
_hasFilters = false;
});
_debounceTimer?.cancel();
widget.onClearFilters();
}
void _updateHasFilters() {
setState(() {
_hasFilters = _searchController.text.isNotEmpty ||
widget.selectedSido != null ||
widget.selectedGu != null;
});
Widget _buildDisabledPlaceholder(String text, ShadThemeData theme) {
return Container(
height: 38,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.border),
borderRadius: BorderRadius.circular(6),
),
child: Text(text, style: theme.textTheme.muted),
);
}
@override
@@ -91,69 +50,10 @@ class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
return Column(
children: [
// 첫 번째 행: 검색 입력
// 첫 번째 행: 2개 드롭다운
Row(
children: [
// 검색 입력
Expanded(
flex: 3,
child: Row(
children: [
Icon(
Icons.search,
size: 18,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(width: 12),
Expanded(
child: ShadInputFormField(
controller: _searchController,
placeholder: const Text('우편번호, 시도, 구/군, 상세주소로 검색'),
onChanged: _onSearchChanged,
),
),
if (_searchController.text.isNotEmpty) ...[
const SizedBox(width: 8),
ShadButton.ghost(
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
size: ShadButtonSize.sm,
child: Icon(
Icons.clear,
size: 16,
color: theme.colorScheme.mutedForeground,
),
),
],
],
),
),
const SizedBox(width: 16),
// 필터 초기화 버튼
if (_hasFilters)
ShadButton.outline(
onPressed: _clearFilters,
size: ShadButtonSize.sm,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.filter_alt_off, size: 16),
SizedBox(width: 6),
Text('초기화'),
],
),
),
],
),
const SizedBox(height: 16),
// 두 번째 행: 시도/구 선택
Row(
children: [
// 시도 선택
// 시도 드롭다운
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -165,17 +65,8 @@ class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
),
),
const SizedBox(height: 6),
widget.sidoList.isEmpty
? Container(
height: 38,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.border),
borderRadius: BorderRadius.circular(6),
),
child: Text('로딩 중...', style: theme.textTheme.muted),
)
sidoList.isEmpty
? _buildDisabledPlaceholder('로딩 중...', theme)
: SizedBox(
width: double.infinity,
child: ShadSelect<String>(
@@ -184,20 +75,19 @@ class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
shrinkWrap: true,
showScrollToBottomChevron: true,
showScrollToTopChevron: true,
scrollController: _sidoScrollController,
onChanged: (value) => _onSidoChanged(value),
onChanged: _onSidoChanged,
options: [
const ShadOption(
value: '',
value: '전체',
child: Text('전체'),
),
...widget.sidoList.map((sido) => ShadOption(
...sidoList.map((sido) => ShadOption(
value: sido,
child: Text(sido),
)),
],
selectedOptionBuilder: (context, value) {
if (value == '') {
if (value == '전체') {
return const Text('전체');
}
return Text(value);
@@ -207,9 +97,10 @@ class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
],
),
),
const SizedBox(width: 16),
// 구 선택
// 구/군 드롭다운
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -221,17 +112,8 @@ class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
),
),
const SizedBox(height: 6),
widget.selectedSido == null
? Container(
height: 38,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.border),
borderRadius: BorderRadius.circular(6),
),
child: Text('시도를 먼저 선택하세요', style: theme.textTheme.muted),
)
selectedSido == null
? _buildDisabledPlaceholder('시도를 먼저 선택하세요', theme)
: SizedBox(
width: double.infinity,
child: ShadSelect<String>(
@@ -240,20 +122,19 @@ class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
shrinkWrap: true,
showScrollToBottomChevron: true,
showScrollToTopChevron: true,
scrollController: _guScrollController,
onChanged: (value) => _onGuChanged(value),
onChanged: _onGuChanged,
options: [
const ShadOption(
value: '',
value: '전체',
child: Text('전체'),
),
...widget.guList.map((gu) => ShadOption(
...guList.map((gu) => ShadOption(
value: gu,
child: Text(gu),
)),
],
selectedOptionBuilder: (context, value) {
if (value == '') {
if (value == '전체') {
return const Text('전체');
}
return Text(value);
@@ -263,35 +144,24 @@ class _ZipcodeSearchFilterState extends State<ZipcodeSearchFilter> {
],
),
),
const SizedBox(width: 16),
// 빠른 검색 팁
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.accent.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tips_and_updates_outlined,
size: 16,
color: theme.colorScheme.accent,
),
const SizedBox(width: 6),
Text(
'팁: 우편번호나 동네 이름으로 빠르게 검색하세요',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.accent,
fontSize: 11,
),
),
],
],
),
const SizedBox(height: 16),
// 두 번째 행: 텍스트 검색
Row(
children: [
Icon(
Icons.search,
size: 18,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(width: 12),
Expanded(
child: ShadInputFormField(
placeholder: const Text('동/읍/면, 상세주소 검색'),
onChanged: onSearch, // 실시간 검색 (디바운스 없음)
),
),
],

View File

@@ -1,9 +1,8 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
import 'package:superport/data/models/zipcode_dto.dart';
import 'package:superport/domain/usecases/zipcode_usecase.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/core/constants/app_constants.dart';
@injectable
class ZipcodeController extends ChangeNotifier {
@@ -11,7 +10,7 @@ class ZipcodeController extends ChangeNotifier {
ZipcodeController(this._zipcodeUseCase);
// 상태 변수들
// 핵심 상태만 유지
List<ZipcodeDto> _zipcodes = [];
ZipcodeDto? _selectedZipcode;
bool _isLoading = false;
@@ -21,20 +20,17 @@ class ZipcodeController extends ChangeNotifier {
int _currentPage = 1;
int _totalPages = 1;
int _totalCount = 0;
final int _pageSize = PaginationConstants.defaultPageSize;
final int _pageSize = AppConstants.defaultPageSize;
// 검색 및 필터
String _searchQuery = '';
// 단순한 필터 (2개 드롭다운 + 1개 텍스트)
String? _selectedSido;
String? _selectedGu;
String? _selectedGu;
String _searchQuery = '';
// 시도/구 목록 캐시
// Hierarchy 데이터 (단 2개만)
List<String> _sidoList = [];
List<String> _guList = [];
// 디바운스를 위한 타이머
Timer? _debounceTimer;
// Getters
List<ZipcodeDto> get zipcodes => _zipcodes;
ZipcodeDto? get selectedZipcode => _selectedZipcode;
@@ -51,20 +47,20 @@ class ZipcodeController extends ChangeNotifier {
bool get hasNextPage => _currentPage < _totalPages;
bool get hasPreviousPage => _currentPage > 1;
// 초기 데이터 로드
// 초기화 (단순함)
Future<void> initialize() async {
try {
_isLoading = true;
_setLoading(true);
_zipcodes = [];
_selectedZipcode = null;
_errorMessage = null;
notifyListeners();
// 시도 목록 로드
// 시도 목록 로드 (Hierarchy API만 사용)
await _loadSidoList();
// 초기 우편번호 목록 로드 (첫 페이지)
await searchZipcodes();
await _executeSearch();
} catch (e) {
_errorMessage = '초기화 중 오류가 발생했습니다.';
_isLoading = false;
@@ -72,8 +68,94 @@ class ZipcodeController extends ChangeNotifier {
}
}
// 우편번호 검색
Future<void> searchZipcodes({bool refresh = false}) async {
// 시도 선택 (단순함)
Future<void> setSido(String? sido) async {
try {
_selectedSido = sido;
_selectedGu = null; // 구 초기화
_guList = [];
notifyListeners();
// 선택된 시도의 구 목록 로드
if (sido != null && sido.isNotEmpty) {
await _loadGuListBySido(sido);
}
// 실시간 검색 실행
await _executeSearch();
} catch (e) {
debugPrint('시도 선택 오류: $e');
}
}
// 구 선택 (단순함)
Future<void> setGu(String? gu) async {
try {
_selectedGu = gu;
notifyListeners();
// 실시간 검색 실행
await _executeSearch();
} catch (e) {
debugPrint('구 선택 오류: $e');
}
}
// 검색어 설정 (실시간, 디바운스 없음)
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
_executeSearch(); // 실시간 검색
}
// 필터 초기화 (단순함)
Future<void> clearFilters() async {
_searchQuery = '';
_selectedSido = null;
_selectedGu = null;
_guList = [];
_currentPage = 1;
notifyListeners();
await _executeSearch();
}
// 페이지 이동
Future<void> goToPage(int page) async {
if (page < 1 || page > _totalPages) return;
_currentPage = page;
await _executeSearch();
}
// 다음 페이지
Future<void> nextPage() async {
if (hasNextPage) {
await goToPage(_currentPage + 1);
}
}
// 이전 페이지
Future<void> previousPage() async {
if (hasPreviousPage) {
await goToPage(_currentPage - 1);
}
}
// 우편번호 선택
void selectZipcode(ZipcodeDto zipcode) {
_selectedZipcode = zipcode;
notifyListeners();
}
// 선택 초기화
void clearSelection() {
_selectedZipcode = null;
notifyListeners();
}
// 검색 실행 (핵심 로직)
Future<void> _executeSearch({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
}
@@ -103,154 +185,35 @@ class ZipcodeController extends ChangeNotifier {
}
}
// 특정 우편번호로 정확한 주소 조회
Future<void> getZipcodeByNumber(int zipcode) async {
_setLoading(true);
_clearError();
try {
_selectedZipcode = await _zipcodeUseCase.getZipcodeByNumber(zipcode);
notifyListeners();
} catch (e) {
_setError('우편번호 조회에 실패했습니다: ${e.toString()}');
} finally {
_setLoading(false);
}
}
// 주소로 우편번호 빠른 검색
Future<List<ZipcodeDto>> quickSearchByAddress(String address) async {
if (address.trim().isEmpty) return [];
try {
return await _zipcodeUseCase.searchByAddress(address);
} catch (e) {
return [];
}
}
// 검색어 설정 (디바운스 적용)
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
// 디바운스 처리 (500ms 대기 후 검색 실행)
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
searchZipcodes(refresh: true);
});
}
// 즉시 검색 실행 (디바운스 무시)
Future<void> executeSearch() async {
_debounceTimer?.cancel();
_currentPage = 1;
await searchZipcodes();
}
// 시도 선택
Future<void> setSido(String? sido) async {
try {
_selectedSido = sido;
_selectedGu = null; // 시도 변경 시 구 초기화
_guList = []; // 구 목록 초기화
notifyListeners();
// 선택된 시도에 따른 구 목록 로드
if (sido != null && sido.isNotEmpty) {
await _loadGuListBySido(sido);
}
// 검색 새로고침
await searchZipcodes(refresh: true);
} catch (e) {
debugPrint('시도 선택 오류: $e');
}
}
// 구 선택
Future<void> setGu(String? gu) async {
try {
_selectedGu = gu;
notifyListeners();
// 검색 새로고침
await searchZipcodes(refresh: true);
} catch (e) {
debugPrint('구 선택 오류: $e');
}
}
// 필터 초기화
Future<void> clearFilters() async {
_searchQuery = '';
_selectedSido = null;
_selectedGu = null;
_guList = [];
_currentPage = 1;
notifyListeners();
await searchZipcodes();
}
// 페이지 이동
Future<void> goToPage(int page) async {
if (page < 1 || page > _totalPages) return;
_currentPage = page;
await searchZipcodes();
}
// 다음 페이지
Future<void> nextPage() async {
if (hasNextPage) {
await goToPage(_currentPage + 1);
}
}
// 이전 페이지
Future<void> previousPage() async {
if (hasPreviousPage) {
await goToPage(_currentPage - 1);
}
}
// 시도 목록 로드 (캐시)
// 시도 목록 로드 (Hierarchy API만 사용, fallback 없음)
Future<void> _loadSidoList() async {
try {
_sidoList = await _zipcodeUseCase.getAllSidoList();
debugPrint('=== 시도 목록 로드 완료 ===');
debugPrint('총 시도 개수: ${_sidoList.length}');
debugPrint('시도 목록: $_sidoList');
final response = await _zipcodeUseCase.getHierarchySidos();
_sidoList = response.data;
debugPrint('=== Hierarchy 시도 목록 로드 완료 ===');
debugPrint('시도 개수: ${response.meta.total}');
debugPrint('시도 목록: ${response.data}');
} catch (e) {
debugPrint('시도 목록 로드 실패: $e');
debugPrint('Hierarchy 시도 목록 로드 실패: $e');
_sidoList = [];
}
}
// 선택된 시도의 구 목록 로드
// 구 목록 로드 (Hierarchy API만 사용, fallback 없음)
Future<void> _loadGuListBySido(String sido) async {
try {
_guList = await _zipcodeUseCase.getGuListBySido(sido);
final response = await _zipcodeUseCase.getHierarchyGusBySido(sido);
_guList = response.data;
debugPrint('=== Hierarchy 구 목록 로드 완료 ===');
debugPrint('시도: $sido, 구 개수: ${response.meta.total}');
notifyListeners();
} catch (e) {
debugPrint('구 목록 로드 실패: $e');
debugPrint('Hierarchy 구 목록 로드 실패: $e');
_guList = [];
notifyListeners();
}
}
// 우편번호 선택
void selectZipcode(ZipcodeDto zipcode) {
_selectedZipcode = zipcode;
notifyListeners();
}
// 선택 초기화
void clearSelection() {
_selectedZipcode = null;
notifyListeners();
}
// 내부 헬퍼 메서드
void _setLoading(bool loading) {
_isLoading = loading;
@@ -268,7 +231,6 @@ class ZipcodeController extends ChangeNotifier {
@override
void dispose() {
_debounceTimer?.cancel();
_zipcodes = [];
_selectedZipcode = null;
super.dispose();

View File

@@ -134,7 +134,6 @@ class _ZipcodeSearchScreenState extends State<ZipcodeSearchScreen> {
onSearch: controller.setSearchQuery,
onSidoChanged: controller.setSido,
onGuChanged: controller.setGu,
onClearFilters: controller.clearFilters,
sidoList: controller.sidoList,
guList: controller.guList,
selectedSido: controller.selectedSido,