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:
@@ -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 = '';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 재설계된 사이드바 메뉴 (접기/펼치기 지원)
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
273
lib/screens/common/widgets/standard_dropdown.dart
Normal file
273
lib/screens/common/widgets/standard_dropdown.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
299
lib/screens/company/controllers/company_controller.dart
Normal file
299
lib/screens/company/controllers/company_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
209
lib/screens/company/widgets/company_restore_dialog.dart
Normal file
209
lib/screens/company/widgets/company_restore_dialog.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
280
lib/screens/equipment/controllers/equipment_controller.dart
Normal file
280
lib/screens/equipment/controllers/equipment_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
: '모델 정보 없음',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
210
lib/screens/equipment/widgets/equipment_restore_dialog.dart
Normal file
210
lib/screens/equipment/widgets/equipment_restore_dialog.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
374
lib/screens/equipment/widgets/equipment_search_dialog.dart
Normal file
374
lib/screens/equipment/widgets/equipment_search_dialog.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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에 추가 예정
|
||||
},
|
||||
|
||||
@@ -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('비고'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1048,13 +1048,4 @@ class _MaintenanceHistoryScreenState extends State<MaintenanceHistoryScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _exportHistory() {
|
||||
ShadToaster.of(context).show(
|
||||
const ShadToast(
|
||||
title: Text('엑셀 내보내기'),
|
||||
description: Text('엑셀 내보내기 기능은 준비 중입니다'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
629
lib/screens/maintenance/maintenance_list.dart
Normal file
629
lib/screens/maintenance/maintenance_list.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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('삭제'),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
// 임대 기간 (백엔드 필수 필드)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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, // 실시간 검색 (디바운스 없음)
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user