diff --git a/lib/core/migrations/execute_migration.dart b/lib/core/migrations/execute_migration.dart deleted file mode 100644 index 614d29e..0000000 --- a/lib/core/migrations/execute_migration.dart +++ /dev/null @@ -1,326 +0,0 @@ -/// License → Maintenance 마이그레이션 실행 스크립트 -/// -/// 사용법: -/// dart run lib/core/migrations/execute_migration.dart -/// -/// 옵션: -/// --dry-run: 실제 데이터 변경 없이 시뮬레이션만 수행 -/// --rollback: 이전 백업에서 롤백 실행 -/// --validate: 마이그레이션 후 데이터 검증만 수행 -library; - -import 'dart:io'; -import 'dart:convert'; -import 'package:dio/dio.dart'; -import 'license_to_maintenance_migration.dart'; -import 'maintenance_data_validator.dart'; - -class MigrationExecutor { - static const String apiBaseUrl = 'http://43.201.34.104:8080/api/v1'; - static const String backupPath = './migration_backup.json'; - - final Dio _dio; - String? _authToken; - - MigrationExecutor() : _dio = Dio(BaseOptions( - baseUrl: apiBaseUrl, - connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(seconds: 30), - )); - - /// 마이그레이션 실행 - Future execute({bool isDryRun = false}) async { - print('=' * 60); - print('License → Maintenance 마이그레이션 시작'); - print('=' * 60); - print('모드: ${isDryRun ? "DRY RUN (시뮬레이션)" : "실제 실행"}'); - print('시작 시간: ${DateTime.now()}'); - print('-' * 60); - - try { - // 1. 인증 - print('\n[1/7] API 인증 중...'); - await _authenticate(); - - // 2. 기존 License 데이터 가져오기 - print('\n[2/7] License 데이터 로딩 중...'); - final licenseData = await _fetchLicenseData(); - print(' → ${licenseData.length}개 License 발견'); - - // 3. Equipment 및 Equipment History 데이터 가져오기 - print('\n[3/7] Equipment 관련 데이터 로딩 중...'); - final equipmentData = await _fetchEquipmentData(); - final equipmentHistoryData = await _fetchEquipmentHistoryData(); - print(' → ${equipmentData.length}개 Equipment 발견'); - print(' → ${equipmentHistoryData.length}개 Equipment History 발견'); - - // 4. 마이그레이션 실행 - print('\n[4/7] 데이터 변환 중...'); - final result = await LicenseToMaintenanceMigration.migrate( - licenseData: licenseData, - equipmentData: equipmentData, - equipmentHistoryData: equipmentHistoryData, - ); - - if (!result.success) { - throw Exception('마이그레이션 실패: ${result.error}'); - } - - // 5. 백업 저장 - print('\n[5/7] 백업 저장 중...'); - if (!isDryRun) { - await _saveBackup(result.backup!); - print(' → 백업 저장 완료: $backupPath'); - } else { - print(' → [DRY RUN] 백업 저장 건너뜀'); - } - - // 6. Maintenance 데이터 저장 - print('\n[6/7] Maintenance 데이터 저장 중...'); - if (!isDryRun) { - await _saveMaintenanceData(result.maintenanceData!); - print(' → ${result.maintenanceData!.length}개 Maintenance 저장 완료'); - } else { - print(' → [DRY RUN] 실제 저장 건너뜀'); - _printSampleData(result.maintenanceData!); - } - - // 7. 검증 - print('\n[7/7] 데이터 검증 중...'); - final validationReport = await MaintenanceDataValidator.validate( - maintenanceData: result.maintenanceData!, - equipmentHistoryData: [], // TODO: 실제 equipment history 데이터 로드 - ); - - _printValidationReport(validationReport); - - // 완료 - print('\n${'=' * 60}'); - print('마이그레이션 ${isDryRun ? "시뮬레이션" : "실행"} 완료!'); - print('=' * 60); - print('통계:'); - print(' - 총 License: ${result.statistics!.totalCount}개'); - print(' - 활성: ${result.statistics!.activeCount}개'); - print(' - 만료 예정: ${result.statistics!.upcomingCount}개'); - print(' - 만료됨: ${result.statistics!.expiredCount}개'); - print('종료 시간: ${DateTime.now()}'); - - } catch (e) { - print('\n❌ 마이그레이션 실패!'); - print('오류: $e'); - print('\n롤백이 필요한 경우 다음 명령을 실행하세요:'); - print('dart run lib/core/migrations/execute_migration.dart --rollback'); - exit(1); - } - } - - /// 롤백 실행 - Future rollback() async { - print('=' * 60); - print('License → Maintenance 롤백 시작'); - print('=' * 60); - - try { - // 백업 파일 로드 - final backupFile = File(backupPath); - if (!await backupFile.exists()) { - throw Exception('백업 파일을 찾을 수 없습니다: $backupPath'); - } - - final backupContent = await backupFile.readAsString(); - final backup = jsonDecode(backupContent) as Map; - - print('백업 정보:'); - print(' - 생성 시간: ${backup['timestamp']}'); - print(' - 버전: ${backup['version']}'); - print(' - 데이터 수: ${(backup['data'] as List).length}개'); - - // 롤백 확인 - print('\n정말로 롤백하시겠습니까? (y/n)'); - final confirm = stdin.readLineSync(); - if (confirm?.toLowerCase() != 'y') { - print('롤백 취소됨'); - return; - } - - // 롤백 실행 - print('\n롤백 실행 중...'); - final success = await LicenseToMaintenanceMigration.rollback(backup); - - if (success) { - print('✅ 롤백 완료!'); - - // Maintenance 데이터 삭제 - print('Maintenance 데이터 정리 중...'); - await _deleteMaintenanceData(); - - // License 데이터 복원 - print('License 데이터 복원 중...'); - await _restoreLicenseData(backup['data'] as List); - - print('\n롤백이 성공적으로 완료되었습니다.'); - } else { - throw Exception('롤백 실패'); - } - - } catch (e) { - print('\n❌ 롤백 실패!'); - print('오류: $e'); - print('\n수동 복구가 필요할 수 있습니다.'); - exit(1); - } - } - - /// API 인증 - Future _authenticate() async { - try { - final response = await _dio.post('/auth/login', data: { - 'email': 'admin@example.com', - 'password': 'password123', - }); - - _authToken = response.data['token']; - _dio.options.headers['Authorization'] = 'Bearer $_authToken'; - } catch (e) { - throw Exception('인증 실패: $e'); - } - } - - /// License 데이터 가져오기 - Future>> _fetchLicenseData() async { - try { - // 실제 환경에서는 API 호출 - // 여기서는 더미 데이터 반환 (실제 구현 시 수정 필요) - return [ - { - 'id': 1, - 'equipment_id': 1, - 'license_type': 'O', - 'period_months': 12, - 'cost': 1000000, - 'vendor_name': '삼성전자서비스', - 'vendor_contact': '1588-3366', - 'start_date': '2024-01-01T00:00:00Z', - 'expiry_date': '2024-12-31T23:59:59Z', - 'created_at': '2024-01-01T00:00:00Z', - }, - // 추가 데이터... - ]; - } catch (e) { - throw Exception('License 데이터 로딩 실패: $e'); - } - } - - /// Equipment 데이터 가져오기 - Future>> _fetchEquipmentData() async { - try { - final response = await _dio.get('/equipments'); - return (response.data['data'] as List) - .map((e) => e as Map) - .toList(); - } catch (e) { - throw Exception('Equipment 데이터 로딩 실패: $e'); - } - } - - /// Equipment History 데이터 가져오기 - Future>> _fetchEquipmentHistoryData() async { - try { - final response = await _dio.get('/equipment_history'); - return (response.data['data'] as List) - .map((e) => e as Map) - .toList(); - } catch (e) { - // Equipment History가 아직 구현되지 않았을 수 있음 - print(' ⚠️ Equipment History API 미구현, 빈 데이터 사용'); - return []; - } - } - - /// 백업 저장 - Future _saveBackup(Map backup) async { - final file = File(backupPath); - await file.writeAsString(jsonEncode(backup)); - } - - /// Maintenance 데이터 저장 - Future _saveMaintenanceData(List> data) async { - for (final maintenance in data) { - try { - await _dio.post('/maintenances', data: maintenance); - } catch (e) { - print(' ⚠️ Maintenance #${maintenance['id']} 저장 실패: $e'); - } - } - } - - /// Maintenance 데이터 삭제 - Future _deleteMaintenanceData() async { - try { - // 모든 Maintenance 데이터 삭제 - await _dio.delete('/maintenances/all'); - } catch (e) { - print(' ⚠️ Maintenance 데이터 삭제 실패: $e'); - } - } - - /// License 데이터 복원 - Future _restoreLicenseData(List data) async { - for (final license in data) { - try { - await _dio.post('/licenses', data: license); - } catch (e) { - print(' ⚠️ License #${license['id']} 복원 실패: $e'); - } - } - } - - /// 샘플 데이터 출력 (DRY RUN용) - void _printSampleData(List> data) { - print('\n 샘플 변환 데이터 (처음 3개):'); - for (var i = 0; i < (data.length > 3 ? 3 : data.length); i++) { - final item = data[i]; - print(' ${i + 1}. Maintenance #${item['id']}'); - print(' - Equipment History ID: ${item['equipment_history_id']}'); - print(' - Type: ${item['maintenance_type']}'); - print(' - Period: ${item['period_months']} months'); - print(' - Status: ${item['status']}'); - print(' - Next Date: ${item['next_date']}'); - } - } - - /// 검증 보고서 출력 - void _printValidationReport(ValidationReport report) { - print('\n 검증 결과:'); - print(' - 전체 검증: ${report.isValid ? "✅ 통과" : "❌ 실패"}'); - print(' - 데이터 무결성: ${report.dataIntegrity ? "✅" : "❌"}'); - print(' - FK 관계: ${report.foreignKeyIntegrity ? "✅" : "❌"}'); - print(' - 비즈니스 규칙: ${report.businessRulesValid ? "✅" : "❌"}'); - - if (report.warnings.isNotEmpty) { - print('\n 경고:'); - for (final warning in report.warnings) { - print(' ⚠️ $warning'); - } - } - - if (report.errors.isNotEmpty) { - print('\n 오류:'); - for (final error in report.errors) { - print(' ❌ $error'); - } - } - } -} - -/// 메인 실행 함수 -Future main(List args) async { - final executor = MigrationExecutor(); - - if (args.contains('--rollback')) { - await executor.rollback(); - } else { - final isDryRun = args.contains('--dry-run'); - await executor.execute(isDryRun: isDryRun); - } -} \ No newline at end of file diff --git a/lib/core/utils/login_diagnostics.dart b/lib/core/utils/login_diagnostics.dart deleted file mode 100644 index b05b068..0000000 --- a/lib/core/utils/login_diagnostics.dart +++ /dev/null @@ -1,329 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:superport/core/utils/debug_logger.dart'; -import 'package:superport/core/config/environment.dart' as env; - -/// 로그인 문제 진단을 위한 유틸리티 클래스 -class LoginDiagnostics { - /// 로그인 프로세스 전체 진단 - static Future> runFullDiagnostics() async { - final results = {}; - - try { - // 1. 환경 설정 확인 - results['environment'] = _checkEnvironment(); - - // 2. 네트워크 연결 확인 - results['network'] = await _checkNetworkConnectivity(); - - // 3. API 엔드포인트 확인 - results['apiEndpoint'] = await _checkApiEndpoint(); - - // 4. 모델 직렬화 테스트 - results['serialization'] = _testSerialization(); - - // 5. 저장소 접근 테스트 - results['storage'] = await _testStorageAccess(); - - DebugLogger.log( - '로그인 진단 완료', - tag: 'DIAGNOSTICS', - data: results, - ); - - return results; - } catch (e, stackTrace) { - DebugLogger.logError( - '진단 중 오류 발생', - error: e, - stackTrace: stackTrace, - ); - return { - 'error': e.toString(), - 'stackTrace': stackTrace.toString(), - }; - } - } - - /// 환경 설정 확인 - static Map _checkEnvironment() { - return { - 'apiBaseUrl': env.Environment.apiBaseUrl, - 'isDebugMode': kDebugMode, - 'platform': defaultTargetPlatform.toString(), - }; - } - - /// 네트워크 연결 확인 - static Future> _checkNetworkConnectivity() async { - final dio = Dio(); - final results = {}; - - try { - // Google DNS로 연결 테스트 - final response = await dio.get('https://dns.google/resolve?name=google.com'); - results['internetConnection'] = response.statusCode == 200; - } catch (e) { - results['internetConnection'] = false; - results['error'] = e.toString(); - } - - // API 서버 연결 테스트 - try { - final response = await dio.get( - '${env.Environment.apiBaseUrl}/health', - options: Options( - validateStatus: (status) => status != null && status < 500, - ), - ); - results['apiServerReachable'] = true; - results['apiServerStatus'] = response.statusCode; - } catch (e) { - results['apiServerReachable'] = false; - results['apiServerError'] = e.toString(); - } - - return results; - } - - /// API 엔드포인트 확인 - static Future> _checkApiEndpoint() async { - - final dio = Dio(); - final results = {}; - - try { - // OPTIONS 요청으로 CORS 확인 - final response = await dio.request( - '${env.Environment.apiBaseUrl}/auth/login', - options: Options( - method: 'OPTIONS', - validateStatus: (status) => true, - ), - ); - - results['corsEnabled'] = response.statusCode == 200 || response.statusCode == 204; - results['allowedMethods'] = response.headers['access-control-allow-methods']; - results['allowedHeaders'] = response.headers['access-control-allow-headers']; - - // 실제 로그인 엔드포인트 테스트 (잘못된 자격 증명으로) - final loginResponse = await dio.post( - '${env.Environment.apiBaseUrl}/auth/login', - data: { - 'email': 'test@test.com', - 'password': 'test', - }, - options: Options( - validateStatus: (status) => true, - ), - ); - - results['loginEndpointStatus'] = loginResponse.statusCode; - results['loginResponseType'] = loginResponse.data?.runtimeType.toString(); - - // 응답 구조 분석 - if (loginResponse.data is Map) { - final data = loginResponse.data as Map; - results['responseKeys'] = data.keys.toList(); - results['hasSuccessField'] = data.containsKey('success'); - results['hasDataField'] = data.containsKey('data'); - results['hasAccessToken'] = data.containsKey('accessToken') || data.containsKey('access_token'); - } - - } catch (e) { - results['error'] = e.toString(); - if (e is DioException) { - results['dioErrorType'] = e.type.toString(); - results['dioMessage'] = e.message; - } - } - - return results; - } - - /// 모델 직렬화 테스트 - static Map _testSerialization() { - final results = {}; - - try { - // LoginRequest 테스트 - results['loginRequestValid'] = true; - - // LoginResponse 테스트 (형식 1) - final loginResponse1 = { - 'success': true, - 'data': { - 'accessToken': 'test_token', - 'refreshToken': 'refresh_token', - 'tokenType': 'Bearer', - 'expiresIn': 3600, - 'user': { - 'id': 1, - 'username': 'testuser', - 'email': 'test@example.com', - 'name': '테스트', - 'role': 'USER', - }, - }, - }; - - results['format1Valid'] = _validateResponseFormat1(loginResponse1); - - // LoginResponse 테스트 (형식 2) - final loginResponse2 = { - 'accessToken': 'test_token', - 'refreshToken': 'refresh_token', - 'tokenType': 'Bearer', - 'expiresIn': 3600, - 'user': { - 'id': 1, - 'username': 'testuser', - 'email': 'test@example.com', - 'name': '테스트', - 'role': 'USER', - }, - }; - - results['format2Valid'] = _validateResponseFormat2(loginResponse2); - - } catch (e) { - results['error'] = e.toString(); - } - - return results; - } - - /// 저장소 접근 테스트 - static Future> _testStorageAccess() async { - final results = {}; - - try { - // 실제 FlutterSecureStorage 테스트는 의존성 주입이 필요하므로 - // 여기서는 기본적인 체크만 수행 - results['platformSupported'] = true; - - // 플랫폼별 특이사항 체크 - if (defaultTargetPlatform == TargetPlatform.iOS) { - results['note'] = 'iOS Keychain 사용'; - } else if (defaultTargetPlatform == TargetPlatform.android) { - results['note'] = 'Android KeyStore 사용'; - } else { - results['note'] = '웹 또는 데스크톱 플랫폼'; - } - - } catch (e) { - results['error'] = e.toString(); - } - - return results; - } - - /// 응답 형식 1 검증 - static bool _validateResponseFormat1(Map response) { - try { - if (!response.containsKey('success') || response['success'] != true) { - return false; - } - - if (!response.containsKey('data') || response['data'] is! Map) { - return false; - } - - final data = response['data'] as Map; - final requiredFields = ['accessToken', 'refreshToken', 'user']; - - for (final field in requiredFields) { - if (!data.containsKey(field)) { - return false; - } - } - - return true; - } catch (e) { - return false; - } - } - - /// 응답 형식 2 검증 - static bool _validateResponseFormat2(Map response) { - try { - final requiredFields = ['accessToken', 'refreshToken', 'user']; - - for (final field in requiredFields) { - if (!response.containsKey(field)) { - return false; - } - } - - if (response['user'] is! Map) { - return false; - } - - return true; - } catch (e) { - return false; - } - } - - /// 진단 결과를 읽기 쉬운 형식으로 포맷 - static String formatDiagnosticsReport(Map diagnostics) { - final buffer = StringBuffer(); - - buffer.writeln('=== 로그인 진단 보고서 ===\n'); - - // 환경 설정 - if (diagnostics.containsKey('environment')) { - buffer.writeln('## 환경 설정'); - final env = diagnostics['environment'] as Map; - env.forEach((key, value) { - buffer.writeln('- $key: $value'); - }); - buffer.writeln(); - } - - // 네트워크 상태 - if (diagnostics.containsKey('network')) { - buffer.writeln('## 네트워크 상태'); - final network = diagnostics['network'] as Map; - buffer.writeln('- 인터넷 연결: ${network['internetConnection'] == true ? '✅' : '❌'}'); - if (network.containsKey('apiServerReachable')) { - buffer.writeln('- API 서버 접근: ${network['apiServerReachable'] == true ? '✅' : '❌'}'); - } - buffer.writeln(); - } - - // API 엔드포인트 - if (diagnostics.containsKey('apiEndpoint')) { - buffer.writeln('## API 엔드포인트'); - final api = diagnostics['apiEndpoint'] as Map; - if (api['skip'] == true) { - buffer.writeln('- Mock 모드로 건너뜀'); - } else { - buffer.writeln('- CORS 활성화: ${api['corsEnabled'] == true ? '✅' : '❌'}'); - buffer.writeln('- 로그인 엔드포인트 상태: ${api['loginEndpointStatus']}'); - if (api.containsKey('responseKeys')) { - buffer.writeln('- 응답 키: ${api['responseKeys']}'); - } - } - buffer.writeln(); - } - - // 직렬화 테스트 - if (diagnostics.containsKey('serialization')) { - buffer.writeln('## 모델 직렬화'); - final serial = diagnostics['serialization'] as Map; - buffer.writeln('- LoginRequest: ${serial['loginRequestValid'] == true ? '✅' : '❌'}'); - buffer.writeln('- 응답 형식 1: ${serial['format1Valid'] == true ? '✅' : '❌'}'); - buffer.writeln('- 응답 형식 2: ${serial['format2Valid'] == true ? '✅' : '❌'}'); - buffer.writeln(); - } - - // 오류 정보 - if (diagnostics.containsKey('error')) { - buffer.writeln('## ⚠️ 오류 발생'); - buffer.writeln(diagnostics['error']); - } - - return buffer.toString(); - } -} \ No newline at end of file diff --git a/lib/models/user_phone_field.dart b/lib/models/user_phone_field.dart deleted file mode 100644 index 3fa42fe..0000000 --- a/lib/models/user_phone_field.dart +++ /dev/null @@ -1,19 +0,0 @@ -// 전화번호 입력 필드 관리를 위한 클래스 -// 타입 안정성 및 코드 간결성을 위해 사용 -import 'package:flutter/material.dart'; - -class UserPhoneField { - // 전화번호 종류(휴대폰, 사무실 등) - String type; - // 전화번호 입력 컨트롤러 - final TextEditingController controller; - - UserPhoneField({required this.type, String? initialValue}) - : controller = TextEditingController(text: initialValue); - - // 현재 입력된 전화번호 반환 - String get number => controller.text; - - // 컨트롤러 해제 - void dispose() => controller.dispose(); -} diff --git a/lib/screens/common/widgets/category_autocomplete_field.dart b/lib/screens/common/widgets/category_autocomplete_field.dart deleted file mode 100644 index b395af6..0000000 --- a/lib/screens/common/widgets/category_autocomplete_field.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/material.dart'; -import '../custom_widgets.dart'; // AutocompleteDropdown, HighlightText 등 사용 - -// 입력 필드 + 자동완성 드롭다운을 하나로 묶은 공통 위젯 -class CategoryAutocompleteField extends StatefulWidget { - // 입력 필드의 힌트 텍스트 - final String hintText; - // 현재 선택된 값 - final String value; - // 항목 리스트 - final List items; - // 필수 입력 여부 - final bool isRequired; - // 선택 시 콜백 - final void Function(String) onSelect; - // 입력값 변경 시 콜백(옵션) - final void Function(String)? onChanged; - // 비활성화 여부 - final bool enabled; - - const CategoryAutocompleteField({ - super.key, - required this.hintText, - required this.value, - required this.items, - required this.onSelect, - this.isRequired = false, - this.onChanged, - this.enabled = true, - }); - - @override - State createState() => - _CategoryAutocompleteFieldState(); -} - -class _CategoryAutocompleteFieldState extends State { - // 텍스트 입력 컨트롤러 - late final TextEditingController _controller; - // 포커스 노드 - final FocusNode _focusNode = FocusNode(); - // 드롭다운 표시 여부 - bool _showDropdown = false; - // 필터링된 항목 리스트 - List _filteredItems = []; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.value); - _filteredItems = List.from(widget.items); - _controller.addListener(_onTextChanged); - _focusNode.addListener(() { - setState(() { - if (_focusNode.hasFocus) { - _showDropdown = _filteredItems.isNotEmpty; - } else { - _showDropdown = false; - } - }); - }); - } - - @override - void didUpdateWidget(covariant CategoryAutocompleteField oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.value != _controller.text) { - _controller.text = widget.value; - } - if (widget.items != oldWidget.items) { - _filteredItems = List.from(widget.items); - } - } - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - // 입력값 변경 시 필터링 - void _onTextChanged() { - final String text = _controller.text; - setState(() { - if (text.isEmpty) { - _filteredItems = List.from(widget.items); - } else { - _filteredItems = - widget.items - .where( - (item) => item.toLowerCase().contains(text.toLowerCase()), - ) - .toList(); - // 시작 부분이 일치하는 항목 우선 정렬 - _filteredItems.sort((a, b) { - bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase()); - bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase()); - if (aStartsWith && !bStartsWith) return -1; - if (!aStartsWith && bStartsWith) return 1; - return a.compareTo(b); - }); - } - _showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus; - if (widget.onChanged != null) { - widget.onChanged!(text); - } - }); - } - - // 항목 선택 시 처리 - void _handleSelect(String value) { - setState(() { - _controller.text = value; - _showDropdown = false; - }); - widget.onSelect(value); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _controller, - focusNode: _focusNode, - decoration: InputDecoration( - hintText: widget.hintText, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - suffixIcon: - _controller.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: - widget.enabled - ? () { - setState(() { - _controller.clear(); - _filteredItems = List.from(widget.items); - _showDropdown = _focusNode.hasFocus; - widget.onSelect(''); - }); - } - : null, - ) - : IconButton( - icon: const Icon(Icons.arrow_drop_down), - onPressed: - widget.enabled - ? () { - setState(() { - _showDropdown = !_showDropdown; - }); - } - : null, - ), - ), - enabled: widget.enabled, - validator: (value) { - if (widget.isRequired && (value == null || value.isEmpty)) { - return '${widget.hintText}를 선택해주세요'; - } - return null; - }, - onTap: () { - setState(() { - if (!_showDropdown) { - _showDropdown = true; - } - }); - }, - ), - AutocompleteDropdown( - items: _filteredItems, - inputText: _controller.text, - onSelect: _handleSelect, - showDropdown: _showDropdown, - ), - ], - ); - } -} diff --git a/lib/screens/common/widgets/company_branch_dropdown.dart b/lib/screens/common/widgets/company_branch_dropdown.dart deleted file mode 100644 index ba21266..0000000 --- a/lib/screens/common/widgets/company_branch_dropdown.dart +++ /dev/null @@ -1,76 +0,0 @@ -// 회사/지점 드롭다운 공통 위젯 -// 여러 도메인에서 재사용 가능 -import 'package:flutter/material.dart'; -import '../../../models/company_model.dart'; - -class CompanyBranchDropdown extends StatelessWidget { - final List companies; - final int? selectedCompanyId; - final int? selectedBranchId; - final List branches; - final void Function(int? companyId) onCompanyChanged; - final void Function(int? branchId) onBranchChanged; - - const CompanyBranchDropdown({ - super.key, - required this.companies, - required this.selectedCompanyId, - required this.selectedBranchId, - required this.branches, - required this.onCompanyChanged, - required this.onBranchChanged, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 회사 드롭다운 - DropdownButtonFormField( - value: selectedCompanyId, - decoration: const InputDecoration(hintText: '소속 회사를 선택하세요'), - items: - companies - .map( - (company) => DropdownMenuItem( - value: company.id, - child: Text(company.name), - ), - ) - .toList(), - onChanged: onCompanyChanged, - validator: (value) { - if (value == null) { - return '소속 회사를 선택해주세요'; - } - return null; - }, - ), - const SizedBox(height: 12), - // 지점 드롭다운 (지점이 있을 때만) - if (branches.isNotEmpty) - DropdownButtonFormField( - value: selectedBranchId, - decoration: const InputDecoration(hintText: '소속 지점을 선택하세요'), - items: - branches - .map( - (branch) => DropdownMenuItem( - value: branch.id, - child: Text(branch.name), - ), - ) - .toList(), - onChanged: onBranchChanged, - validator: (value) { - if (branches.isNotEmpty && value == null) { - return '소속 지점을 선택해주세요'; - } - return null; - }, - ), - ], - ); - } -} diff --git a/lib/screens/rent/rent_list_screen_simple.dart b/lib/screens/rent/rent_list_screen_simple.dart deleted file mode 100644 index f308708..0000000 --- a/lib/screens/rent/rent_list_screen_simple.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../injection_container.dart'; -import 'controllers/rent_controller.dart'; -import 'rent_form_dialog.dart'; - -class RentListScreen extends StatefulWidget { - const RentListScreen({super.key}); - - @override - State createState() => _RentListScreenState(); -} - -class _RentListScreenState extends State { - late final RentController _controller; - final _searchController = TextEditingController(); - - @override - void initState() { - super.initState(); - _controller = getIt(); - _loadData(); - } - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - Future _loadData() async { - await _controller.loadRents(); - } - - Future _refresh() async { - await _controller.loadRents(refresh: true); - } - - void _showCreateDialog() { - showDialog( - context: context, - builder: (context) => RentFormDialog( - onSubmit: (request) async { - final success = await _controller.createRent( - equipmentHistoryId: request.equipmentHistoryId, - startedAt: request.startedAt, - endedAt: request.endedAt, - ); - if (success && mounted) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('임대 계약이 생성되었습니다')), - ); - } - return success; - }, - ), - ); - } - - void _onSearch(String query) { - _controller.loadRents(search: query.isEmpty ? null : query); - } - - - @override - Widget build(BuildContext context) { - return Scaffold( - body: ChangeNotifierProvider.value( - value: _controller, - child: Consumer( - builder: (context, controller, child) { - return Column( - children: [ - // 헤더 - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Text( - '임대 관리', - style: Theme.of(context).textTheme.headlineSmall, - ), - const Spacer(), - // 검색 필드 - SizedBox( - width: 300, - child: TextFormField( - controller: _searchController, - decoration: const InputDecoration( - hintText: '검색...', - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), - ), - onFieldSubmitted: _onSearch, - ), - ), - const SizedBox(width: 8), - // 새로고침 버튼 - IconButton( - onPressed: _refresh, - icon: const Icon(Icons.refresh), - tooltip: '새로고침', - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: _showCreateDialog, - icon: const Icon(Icons.add), - label: const Text('새 임대'), - ), - ], - ), - ), - - // 콘텐츠 - Expanded( - child: controller.isLoading - ? const Center(child: CircularProgressIndicator()) - : controller.hasError - ? Center(child: Text('오류: ${controller.error}')) - : controller.rents.isEmpty - ? const Center(child: Text('임대 계약이 없습니다')) - : ListView.builder( - itemCount: controller.rents.length, - itemBuilder: (context, index) { - final rent = controller.rents[index]; - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: ListTile( - leading: CircleAvatar( - child: Text( - '${rent.id ?? 0}', - ), - ), - title: Text('임대 #${rent.id ?? 0}'), - subtitle: Text( - '${_formatDate(rent.startedAt)} ~ ${_formatDate(rent.endedAt)}', - ), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${_calculateDays(rent.startedAt, rent.endedAt)}일', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text( - controller.getRentStatusDisplayName(controller.getRentStatus(rent)), - style: TextStyle( - color: _getStatusColor(controller.getRentStatus(rent)), - fontSize: 12, - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ); - }, - ), - ), - ); - } - - Color _getStatusColor(String status) { - switch (status) { - case '진행중': - return Colors.blue; - case '종료': - return Colors.green; - case '예약': - return Colors.orange; - default: - return Colors.grey; - } - } - - String _formatDate(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - } - - int _calculateDays(DateTime startDate, DateTime endDate) { - return endDate.difference(startDate).inDays; - } -} \ No newline at end of file diff --git a/lib/utils/equipment_display_helper.dart b/lib/utils/equipment_display_helper.dart deleted file mode 100644 index f2e1ae9..0000000 --- a/lib/utils/equipment_display_helper.dart +++ /dev/null @@ -1,40 +0,0 @@ -/// 장비 정보 표시를 위한 헬퍼 클래스 (SRP, 재사용성, 테스트 용이성 중심) -class EquipmentDisplayHelper { - /// 제조사명 포맷팅 (빈 값은 대시로 표시) - static String formatManufacturer(String? manufacturer) { - if (manufacturer == null || manufacturer.isEmpty) return '-'; - return manufacturer; - } - - /// 장비명 포맷팅 (빈 값은 대시로 표시) - static String formatEquipmentName(String? name) { - if (name == null || name.isEmpty) return '-'; - return name; - } - - /// 카테고리 포맷팅 (비어있지 않은 카테고리만 합침) - static String formatCategory( - String? category, - String? subCategory, - String? subSubCategory, - ) { - final parts = [ - if (category != null && category.isNotEmpty) category, - if (subCategory != null && subCategory.isNotEmpty) subCategory, - if (subSubCategory != null && subSubCategory.isNotEmpty) subSubCategory, - ]; - if (parts.isEmpty) return '-'; - return parts.join(' > '); - } - - /// 시리얼 번호 포맷팅 (없으면 대시) - static String formatSerialNumber(String? serialNumber) { - return serialNumber?.isNotEmpty == true ? serialNumber! : '-'; - } - - /// 날짜 포맷팅 (YYYY-MM-DD, null이면 대시) - static String formatDate(DateTime? date) { - if (date == null) return '-'; - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - } -} diff --git a/lib/utils/formatters/business_number_formatter.dart b/lib/utils/formatters/business_number_formatter.dart deleted file mode 100644 index e8479b6..0000000 --- a/lib/utils/formatters/business_number_formatter.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter/services.dart'; - -/// 한국 사업자 번호 자동 포맷팅 (000-00-00000) -class BusinessNumberFormatter extends TextInputFormatter { - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - // 숫자만 추출 - final digitsOnly = newValue.text.replaceAll(RegExp(r'[^\d]'), ''); - - // 최대 10자리 제한 (000-00-00000 = 10자리) - final truncated = digitsOnly.length > 10 - ? digitsOnly.substring(0, 10) - : digitsOnly; - - // 포맷팅 - String formatted = ''; - for (int i = 0; i < truncated.length; i++) { - if ((i == 3 || i == 5) && i < truncated.length) { - formatted += '-'; - } - formatted += truncated[i]; - } - - // 커서 위치 계산 - int cursorPosition = formatted.length; - - // 백스페이스 처리: 하이픈 앞에서 백스페이스를 누르면 하이픈도 함께 삭제 - if (oldValue.text.length > newValue.text.length) { - if (newValue.selection.baseOffset == 4 || newValue.selection.baseOffset == 7) { - formatted = formatted.substring(0, formatted.length - 1); - cursorPosition = formatted.length; - } - } - - // 입력 중 커서 위치 조정 - if (newValue.selection.baseOffset < newValue.text.length) { - final beforeCursor = newValue.text.substring(0, newValue.selection.baseOffset); - final digitsBeforeCursor = beforeCursor.replaceAll(RegExp(r'[^\d]'), '').length; - - cursorPosition = 0; - int digitCount = 0; - for (int i = 0; i < formatted.length; i++) { - if (formatted[i] != '-') { - digitCount++; - } - cursorPosition++; - if (digitCount == digitsBeforeCursor) { - break; - } - } - } - - return TextEditingValue( - text: formatted, - selection: TextSelection.collapsed(offset: cursorPosition), - ); - } -} - -/// 사업자 번호 유효성 검증 -class BusinessNumberValidator { - /// 사업자 번호 체크섬 검증 - /// 대한민국 사업자등록번호 검증 알고리즘 사용 - static bool isValid(String businessNumber) { - // 하이픈 제거 - final digitsOnly = businessNumber.replaceAll(RegExp(r'[^\d]'), ''); - - // 10자리가 아니면 무효 - if (digitsOnly.length != 10) { - return false; - } - - // 체크섬 계산을 위한 가중치 - const weights = [1, 3, 7, 1, 3, 7, 1, 3, 5]; - - int sum = 0; - for (int i = 0; i < 9; i++) { - sum += int.parse(digitsOnly[i]) * weights[i]; - } - - // 9번째 자리(5)에 대한 추가 계산 - sum += (int.parse(digitsOnly[8]) * 5) ~/ 10; - - // 체크섬 계산 - final checksum = (10 - (sum % 10)) % 10; - - // 마지막 자리와 체크섬 비교 - return checksum == int.parse(digitsOnly[9]); - } - - /// 사업자 번호 유효성 검증 (폼 필드용) - static String? validate(String? value) { - if (value == null || value.isEmpty) { - return '사업자 번호를 입력하세요'; - } - - final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), ''); - - if (digitsOnly.length != 10) { - return '사업자 번호는 10자리여야 합니다'; - } - - if (!isValid(value)) { - return '유효하지 않은 사업자 번호입니다'; - } - - return null; - } - - /// 사업자 번호 타입 추출 - static String getBusinessType(String businessNumber) { - final digitsOnly = businessNumber.replaceAll(RegExp(r'[^\d]'), ''); - - if (digitsOnly.length < 5) return '알 수 없음'; - - final typeCode = digitsOnly.substring(3, 5); - final typeNum = int.parse(typeCode); - - // 사업자 유형 분류 (국세청 기준) - if (typeNum >= 1 && typeNum <= 79) { - return '개인사업자'; - } else if (typeNum >= 80 && typeNum <= 89) { - return '법인사업자'; - } else if (typeNum >= 90 && typeNum <= 99) { - return '기타'; - } - - return '알 수 없음'; - } - - /// 지역 코드 추출 (첫 3자리 기준) - static String? getRegionFromBusinessNumber(String businessNumber) { - final digitsOnly = businessNumber.replaceAll(RegExp(r'[^\d]'), ''); - - if (digitsOnly.length < 3) return null; - - final regionCode = digitsOnly.substring(0, 3); - final code = int.parse(regionCode); - - // 국세청 지역 코드 매핑 (주요 지역만) - if (code >= 101 && code <= 115) return '서울 중부'; - if (code >= 116 && code <= 123) return '서울 동부'; - if (code >= 124 && code <= 133) return '서울 서부'; - if (code >= 134 && code <= 139) return '서울 남부'; - if (code >= 140 && code <= 149) return '서울 북부'; - if (code >= 201 && code <= 209) return '부산'; - if (code >= 210 && code <= 219) return '인천'; - if (code >= 220 && code <= 229) return '경기 북부'; - if (code >= 230 && code <= 239) return '경기 남부'; - if (code >= 240 && code <= 249) return '강원'; - if (code >= 301 && code <= 309) return '대전'; - if (code >= 310 && code <= 319) return '충남'; - if (code >= 320 && code <= 329) return '충북'; - if (code >= 401 && code <= 409) return '광주'; - if (code >= 410 && code <= 419) return '전남'; - if (code >= 420 && code <= 429) return '전북'; - if (code >= 501 && code <= 509) return '대구'; - if (code >= 510 && code <= 519) return '경북'; - if (code >= 601 && code <= 609) return '울산'; - if (code >= 610 && code <= 619) return '경남'; - if (code >= 701 && code <= 709) return '제주'; - - return null; - } -} \ No newline at end of file diff --git a/lib/utils/user_utils.dart b/lib/utils/user_utils.dart deleted file mode 100644 index 870a252..0000000 --- a/lib/utils/user_utils.dart +++ /dev/null @@ -1,16 +0,0 @@ -// 사용자 관련 유틸리티 함수 모음 -// 역할명 변환 등 공통 로직을 관리 - -import '../utils/constants.dart'; - -// 역할 코드 → 한글명 변환 함수 -String getRoleName(String role) { - switch (role) { - case UserRoles.admin: - return '관리자'; - case UserRoles.member: - return '일반 사용자'; - default: - return '알 수 없음'; - } -}