diff --git a/API_ISSUES.md b/API_ISSUES.md deleted file mode 100644 index 0214e43..0000000 --- a/API_ISSUES.md +++ /dev/null @@ -1,105 +0,0 @@ -# Superport API 개선 사항 - -## 1. ✅ [해결됨] 회사 목록 API에 company_types 필드 누락 및 제거 대응 - -### 최종 해결 내역 (2025-01-09) -- **백엔드 변경사항**: - - company_types 필드 제거 예정 - - is_partner, is_customer 필드로 대체 - -- **프론트엔드 대응 (완료)**: - - CompanyListDto, CompanyResponse, CompanyBranchFlatDto에 is_partner, is_customer 필드 추가 (@Default(false)) - - CompanyService 변환 로직 수정 (하위 호환성 유지): - - company_types가 있으면 우선 사용 - - company_types가 없으면 is_partner/is_customer 사용 - - 둘 다 없으면 빈 리스트 반환 - - 사이드 이펙트 없음 확인 (테스트 완료) - -### 현재 상황 -- **문제 엔드포인트**: - - `GET /companies` - - `GET /companies/branches` - -- **현재 응답**: company_types 필드 없음 -```json -{ - "id": 95, - "name": "한국물류창고(주)", - "address": "경기도 용인시", - "contact_name": "박물류", - "contact_phone": "010-89208920", - "contact_email": "contact@naver.com", - "is_active": true, - "created_at": "2025-08-08T09:31:04.661079Z" -} -``` - -- **상세 API 응답** (`GET /companies/{id}`): company_types 포함 -```json -{ - "id": 86, - "name": "아이스 맨", - "company_types": ["customer", "partner"], - ... -} -``` - -### 필요한 개선 -1. `/companies` 엔드포인트에 company_types 필드 추가 -2. `/companies/branches` 엔드포인트에 company_types 필드 추가 - -### 영향 -- 회사 목록 화면에서 유형(고객사/파트너사) 표시 불가 -- 수정 후에도 목록에 반영되지 않음 -- N+1 쿼리 문제 발생 (각 회사마다 상세 API 호출 필요) - -### 임시 해결책 -현재 프론트엔드에서 아래와 같이 처리 중: -- 회사 목록에서는 유형을 표시하지 않거나 기본값(고객사)으로 표시 -- 수정/상세 화면에서만 정확한 유형 표시 - -### 제안하는 API 응답 형식 -```json -{ - "id": 95, - "name": "한국물류창고(주)", - "address": "경기도 용인시", - "contact_name": "박물류", - "contact_phone": "010-89208920", - "contact_email": "contact@naver.com", - "company_types": ["customer", "partner"], // 추가 필요 - "is_active": true, - "created_at": "2025-08-08T09:31:04.661079Z" -} -``` - -## 2. 장비 상태 관련 데이터베이스 오류 - -### 현재 상황 -- **문제 엔드포인트**: - - `GET /overview/stats` - - `GET /overview/equipment-status` - -- **오류 메시지**: -```json -{ - "success": false, - "error": { - "code": "DATABASE_ERROR", - "message": "Database error: Query Error: error returned from database: operator does not exist: character varying = equipment_status" - } -} -``` - -### 원인 추정 -- equipment_status 타입과 character varying 타입 간 비교 연산자 문제 -- PostgreSQL에서 enum 타입 처리 오류 - -### 필요한 개선 -- 데이터베이스 쿼리 수정 또는 타입 캐스팅 추가 - ---- - -**작성일**: 2025-01-09 -**작성자**: Claude Code -**우선순위**: 높음 (회사 유형 표시는 핵심 기능) \ No newline at end of file diff --git a/doc/03_architecture/automated_test_class_diagram.md b/doc/03_architecture/automated_test_class_diagram.md deleted file mode 100644 index 205690c..0000000 --- a/doc/03_architecture/automated_test_class_diagram.md +++ /dev/null @@ -1,500 +0,0 @@ -# Real API 자동화 테스트 프레임워크 - 클래스 다이어그램 - -## 1. 클래스 다이어그램 - -```mermaid -classDiagram - %% Core Framework - class ScreenTestFramework { - <> - #TestContext testContext - #ApiErrorDiagnostics errorDiagnostics - #AutoFixer autoFixer - #TestDataGenerator dataGenerator - #ReportCollector reportCollector - +detectFeatures(ScreenMetadata) Future~List~TestableFeature~~ - +executeTests(List~TestableFeature~) Future~TestResult~ - +handleError(TestError) Future~void~ - +generateReport() Future~TestReport~ - #detectCustomFeatures(ScreenMetadata)* Future~List~TestableFeature~~ - #performCRUD()* Future~void~ - } - - class ApiErrorDiagnostics { - <> - -DiagnosticsManager diagnosticsManager - -Map~String,ErrorPattern~ learnedPatterns - +diagnose(ApiError) Future~ErrorDiagnosis~ - +analyzeRootCause(ErrorDiagnosis) Future~RootCause~ - +suggestFixes(RootCause) Future~List~FixSuggestion~~ - +learnFromError(ApiError, FixResult) Future~void~ - } - - class AutoFixer { - <> - -TestContext testContext - -RetryHandler retryHandler - -List~FixHistory~ fixHistory - +attemptFix(FixSuggestion) Future~FixResult~ - +validateFix(FixResult) Future~bool~ - +rollback(FixResult) Future~void~ - +recordFix(FixResult) Future~void~ - #performCustomValidation(FixResult)* Future~bool~ - } - - class TestDataGenerator { - <> - -ValidationManager validationManager - -Map~Type,GenerationStrategy~ strategies - -Map~String,TestData~ generatedData - +determineStrategy(DataRequirement) Future~GenerationStrategy~ - +generate(GenerationStrategy) Future~TestData~ - +validate(TestData) Future~bool~ - +generateRelated(DataRelationship) Future~Map~String,TestData~~ - } - - %% Infrastructure - class TestContext { - -Map~String,dynamic~ data - -Map~String,List~String~~ createdResources - -Map~String,dynamic~ config - -String currentScreen - +getData(String) dynamic - +setData(String, dynamic) void - +addCreatedResourceId(String, String) void - +getCreatedResourceIds() Map~String,List~String~~ - +recordFix(FixResult) void - } - - class ReportCollector { - -List~TestResult~ results - -ReportConfiguration config - +collect(TestResult) Future~void~ - +generateReport() Future~TestReport~ - +exportHtml(TestReport) Future~String~ - +exportJson(TestReport) Future~String~ - } - - %% Support - class DiagnosticsManager { - +checkTokenStatus() Future~Map~String,dynamic~~ - +checkPermissions() Future~Map~String,dynamic~~ - +validateSchema(Map~String,dynamic~) Future~Map~String,dynamic~~ - +checkConnectivity() Future~Map~String,dynamic~~ - +checkServerHealth() Future~Map~String,dynamic~~ - +savePattern(ErrorPattern) Future~void~ - } - - class RetryHandler { - -int maxAttempts - -Duration backoffDelay - +retry~T~(Function, {maxAttempts, backoffDelay}) Future~T~ - -calculateDelay(int) Duration - } - - class ValidationManager { - -Map~Type,Schema~ schemas - +validate(Map~String,dynamic~, Type) Future~bool~ - +validateField(String, dynamic, FieldConstraint) bool - +getValidationErrors(Map~String,dynamic~, Type) List~String~ - } - - %% Screen Tests - class BaseScreenTest { - <> - #ApiClient apiClient - #GetIt getIt - +getScreenMetadata()* ScreenMetadata - +initializeServices()* Future~void~ - +setupTestEnvironment() Future~void~ - +teardownTestEnvironment() Future~void~ - +runTests() Future~TestResult~ - #getService()* dynamic - #getResourceType()* String - #getDefaultFilters()* Map~String,dynamic~ - } - - class LicenseScreenTest { - -LicenseService licenseService - +getScreenMetadata() ScreenMetadata - +initializeServices() Future~void~ - +detectCustomFeatures(ScreenMetadata) Future~List~TestableFeature~~ - +performExpiryCheck(TestData) Future~void~ - +performLicenseRenewal(TestData) Future~void~ - +performBulkImport(TestData) Future~void~ - } - - class EquipmentScreenTest { - -EquipmentService equipmentService - +getScreenMetadata() ScreenMetadata - +initializeServices() Future~void~ - +detectCustomFeatures(ScreenMetadata) Future~List~TestableFeature~~ - +performStatusTransition(TestData) Future~void~ - +performBulkTransfer(TestData) Future~void~ - } - - class WarehouseScreenTest { - -WarehouseService warehouseService - +getScreenMetadata() ScreenMetadata - +initializeServices() Future~void~ - +detectCustomFeatures(ScreenMetadata) Future~List~TestableFeature~~ - +performCapacityCheck(TestData) Future~void~ - +performInventoryReport(TestData) Future~void~ - } - - %% Models - class TestableFeature { - +String featureName - +FeatureType type - +List~TestCase~ testCases - +Map~String,dynamic~ metadata - } - - class TestCase { - +String name - +Function execute - +Function verify - +Function setup - +Function teardown - } - - class TestResult { - +String screenName - +DateTime startTime - +DateTime endTime - +List~FeatureTestResult~ featureResults - +List~TestError~ errors - +calculateMetrics() void - } - - class ErrorDiagnosis { - +ErrorType type - +String description - +Map~String,dynamic~ context - +double confidence - +List~String~ affectedEndpoints - } - - class FixSuggestion { - +String fixId - +FixType type - +String description - +List~FixAction~ actions - +double successProbability - } - - %% Relationships - ScreenTestFramework o-- TestContext - ScreenTestFramework o-- ApiErrorDiagnostics - ScreenTestFramework o-- AutoFixer - ScreenTestFramework o-- TestDataGenerator - ScreenTestFramework o-- ReportCollector - - BaseScreenTest --|> ScreenTestFramework - LicenseScreenTest --|> BaseScreenTest - EquipmentScreenTest --|> BaseScreenTest - WarehouseScreenTest --|> BaseScreenTest - - ApiErrorDiagnostics o-- DiagnosticsManager - AutoFixer o-- RetryHandler - TestDataGenerator o-- ValidationManager - - ScreenTestFramework ..> TestableFeature : creates - TestableFeature o-- TestCase - ScreenTestFramework ..> TestResult : produces - ApiErrorDiagnostics ..> ErrorDiagnosis : produces - ApiErrorDiagnostics ..> FixSuggestion : suggests -``` - -## 2. 패키지 구조 - -```mermaid -graph TD - subgraph "framework" - subgraph "core" - STF[ScreenTestFramework] - AED[ApiErrorDiagnostics] - AF[AutoFixer] - TDG[TestDataGenerator] - end - - subgraph "infrastructure" - TC[TestContext] - DC[DependencyContainer] - RC[ReportCollector] - end - - subgraph "support" - RH[RetryHandler] - VM[ValidationManager] - DM[DiagnosticsManager] - end - - subgraph "models" - TM[test_models.dart] - EM[error_models.dart] - RM[report_models.dart] - end - end - - subgraph "screens" - subgraph "base" - BST[BaseScreenTest] - end - - subgraph "license" - LST[LicenseScreenTest] - LTS[LicenseTestScenarios] - end - - subgraph "equipment" - EST[EquipmentScreenTest] - ETS[EquipmentTestScenarios] - end - - subgraph "warehouse" - WST[WarehouseScreenTest] - WTS[WarehouseTestScenarios] - end - end - - subgraph "reports" - subgraph "generators" - HRG[HtmlReportGenerator] - JRG[JsonReportGenerator] - end - - subgraph "templates" - RT[ReportTemplate] - end - end -``` - -## 3. 주요 디자인 패턴 - -### 3.1 Template Method Pattern -```dart -abstract class ScreenTestFramework { - // 템플릿 메서드 - Future executeTests(List features) async { - // 1. 준비 - await setupTestEnvironment(); - - // 2. 실행 - for (final feature in features) { - await executeFeatureTests(feature); - } - - // 3. 정리 - await teardownTestEnvironment(); - - return generateReport(); - } - - // 하위 클래스에서 구현 - Future setupTestEnvironment(); - Future teardownTestEnvironment(); -} -``` - -### 3.2 Strategy Pattern -```dart -// 전략 인터페이스 -abstract class DiagnosticRule { - bool canHandle(ApiError error); - Future diagnose(ApiError error); -} - -// 구체적인 전략들 -class AuthenticationDiagnosticRule implements DiagnosticRule { - @override - bool canHandle(ApiError error) => error.type == ErrorType.authentication; - - @override - Future diagnose(ApiError error) async { - // 인증 관련 진단 로직 - } -} - -class NetworkDiagnosticRule implements DiagnosticRule { - @override - bool canHandle(ApiError error) => error.type == ErrorType.network; - - @override - Future diagnose(ApiError error) async { - // 네트워크 관련 진단 로직 - } -} -``` - -### 3.3 Builder Pattern -```dart -class TestReportBuilder { - TestReport _report; - - TestReportBuilder withSummary(TestSummary summary) { - _report.summary = summary; - return this; - } - - TestReportBuilder withScreenReports(List reports) { - _report.screenReports = reports; - return this; - } - - TestReportBuilder withErrorAnalyses(List analyses) { - _report.errorAnalyses = analyses; - return this; - } - - TestReport build() => _report; -} -``` - -### 3.4 Observer Pattern -```dart -abstract class TestEventListener { - void onTestStarted(TestCase testCase); - void onTestCompleted(TestCaseResult result); - void onTestFailed(TestError error); -} - -class TestEventNotifier { - final List _listeners = []; - - void addListener(TestEventListener listener) { - _listeners.add(listener); - } - - void notifyTestStarted(TestCase testCase) { - for (final listener in _listeners) { - listener.onTestStarted(testCase); - } - } -} -``` - -## 4. 확장 포인트 - -### 4.1 새로운 화면 추가 -```dart -class NewScreenTest extends BaseScreenTest { - @override - ScreenMetadata getScreenMetadata() { - // 화면 메타데이터 정의 - } - - @override - Future> detectCustomFeatures(ScreenMetadata metadata) async { - // 화면별 커스텀 기능 정의 - } -} -``` - -### 4.2 새로운 진단 룰 추가 -```dart -class CustomDiagnosticRule implements DiagnosticRule { - @override - bool canHandle(ApiError error) { - // 처리 가능 여부 판단 - } - - @override - Future diagnose(ApiError error) async { - // 진단 로직 구현 - } -} -``` - -### 4.3 새로운 수정 전략 추가 -```dart -class CustomFixStrategy implements FixStrategy { - @override - Future apply(FixContext context) async { - // 수정 로직 구현 - } -} -``` - -## 5. 사용 예제 - -```dart -// 테스트 실행 -void main() async { - // 의존성 설정 - final testContext = TestContext(); - final errorDiagnostics = ConcreteApiErrorDiagnostics( - diagnosticsManager: DiagnosticsManager(), - ); - final autoFixer = ConcreteAutoFixer( - testContext: testContext, - retryHandler: RetryHandler(), - ); - final dataGenerator = ConcreteTestDataGenerator( - validationManager: ValidationManager(), - ); - final reportCollector = ReportCollector( - config: ReportConfiguration( - outputDirectory: 'test/reports', - ), - ); - - // 라이선스 화면 테스트 - final licenseTest = LicenseScreenTest( - apiClient: ApiClient(), - getIt: GetIt.instance, - testContext: testContext, - errorDiagnostics: errorDiagnostics, - autoFixer: autoFixer, - dataGenerator: dataGenerator, - reportCollector: reportCollector, - ); - - // 테스트 실행 - final result = await licenseTest.runTests(); - - // 리포트 생성 - final report = await reportCollector.generateReport(); - print('테스트 완료: ${report.summary.overallSuccessRate}% 성공'); -} -``` - -## 6. 성능 최적화 전략 - -### 6.1 병렬 실행 -- 독립적인 테스트 케이스는 병렬로 실행 -- 화면별 테스트는 격리된 환경에서 동시 실행 - -### 6.2 리소스 재사용 -- API 클라이언트 연결 풀링 -- 테스트 데이터 캐싱 -- 인증 토큰 재사용 - -### 6.3 스마트 재시도 -- 지수 백오프 알고리즘 -- 에러 타입별 재시도 전략 -- 학습된 패턴 기반 빠른 수정 - -## 7. 모니터링 및 분석 - -### 7.1 실시간 모니터링 -- 테스트 진행 상황 대시보드 -- 에러 발생 즉시 알림 -- 성능 메트릭 실시간 추적 - -### 7.2 사후 분석 -- 테스트 결과 트렌드 분석 -- 에러 패턴 식별 -- 성능 병목 지점 발견 - -## 8. 결론 - -이 아키텍처는 다음과 같은 장점을 제공합니다: - -1. **확장성**: 새로운 화면과 기능을 쉽게 추가 -2. **유지보수성**: 명확한 책임 분리와 모듈화 -3. **안정성**: 자동 에러 진단 및 수정 -4. **효율성**: 병렬 실행과 리소스 최적화 -5. **가시성**: 상세한 리포트와 모니터링 - -SOLID 원칙을 준수하며, 실제 프로덕션 환경에서 안정적으로 운영될 수 있는 구조입니다. \ No newline at end of file diff --git a/doc/03_architecture/automated_test_framework_architecture.md b/doc/03_architecture/automated_test_framework_architecture.md deleted file mode 100644 index cb3dbb2..0000000 --- a/doc/03_architecture/automated_test_framework_architecture.md +++ /dev/null @@ -1,469 +0,0 @@ -# Real API 기반 자동화 테스트 프레임워크 아키텍처 - -## 1. 개요 - -Real API 기반 자동화 테스트 프레임워크는 실제 API와 통신하며 화면별 기능을 자동으로 감지하고 테스트하는 고급 테스트 시스템입니다. 이 프레임워크는 API 에러 진단, 자동 수정, 테스트 데이터 생성 등의 기능을 포함합니다. - -## 2. 아키텍처 개요 - -```mermaid -graph TB - subgraph "Test Runner Layer" - TR[Test Runner] - TO[Test Orchestrator] - end - - subgraph "Framework Core" - STF[ScreenTestFramework] - AED[ApiErrorDiagnostics] - AF[AutoFixer] - TDG[TestDataGenerator] - end - - subgraph "Infrastructure Layer" - TC[TestContext] - DC[DependencyContainer] - RC[ReportCollector] - end - - subgraph "Screen Test Layer" - BST[BaseScreenTest] - LST[LicenseScreenTest] - EST[EquipmentScreenTest] - WST[WarehouseScreenTest] - end - - subgraph "Support Layer" - RH[RetryHandler] - VM[ValidationManager] - DM[DiagnosticsManager] - end - - TR --> TO - TO --> STF - STF --> BST - BST --> LST - BST --> EST - BST --> WST - - STF --> AED - STF --> AF - STF --> TDG - - AED --> DM - AF --> RH - TDG --> VM - - STF --> TC - TC --> DC - STF --> RC -``` - -## 3. 핵심 컴포넌트 설계 - -### 3.1 ScreenTestFramework - -```dart -abstract class ScreenTestFramework { - // 화면 기능 자동 감지 - Future> detectFeatures(ScreenMetadata metadata); - - // 테스트 실행 - Future executeTests(List features); - - // 에러 처리 - Future handleError(TestError error); - - // 리포트 생성 - Future generateReport(); -} - -class ScreenMetadata { - final String screenName; - final Type controllerType; - final List relatedEndpoints; - final Map screenCapabilities; -} - -class TestableFeature { - final String featureName; - final FeatureType type; - final List testCases; - final Map metadata; -} -``` - -### 3.2 ApiErrorDiagnostics - -```dart -abstract class ApiErrorDiagnostics { - // 에러 분석 - Future diagnose(ApiError error); - - // 근본 원인 분석 - Future analyzeRootCause(ErrorDiagnosis diagnosis); - - // 수정 제안 - Future> suggestFixes(RootCause rootCause); - - // 패턴 학습 - Future learnFromError(ApiError error, FixResult result); -} - -class ErrorDiagnosis { - final ErrorType type; - final String description; - final Map context; - final double confidence; - final List affectedEndpoints; -} - -class RootCause { - final String cause; - final CauseCategory category; - final List evidence; - final Map details; -} -``` - -### 3.3 AutoFixer - -```dart -abstract class AutoFixer { - // 자동 수정 시도 - Future attemptFix(FixSuggestion suggestion); - - // 수정 검증 - Future validateFix(FixResult result); - - // 롤백 - Future rollback(FixResult result); - - // 수정 이력 관리 - Future recordFix(FixResult result); -} - -class FixSuggestion { - final String fixId; - final FixType type; - final String description; - final List actions; - final double successProbability; -} - -class FixResult { - final bool success; - final String fixId; - final List changes; - final Duration duration; - final Map metrics; -} -``` - -### 3.4 TestDataGenerator - -```dart -abstract class TestDataGenerator { - // 데이터 생성 전략 - Future determineStrategy(DataRequirement requirement); - - // 데이터 생성 - Future generate(GenerationStrategy strategy); - - // 데이터 검증 - Future validate(TestData data); - - // 관계 데이터 생성 - Future> generateRelated(DataRelationship relationship); -} - -class DataRequirement { - final Type dataType; - final Map constraints; - final List relationships; - final int quantity; -} - -class TestData { - final String id; - final Type type; - final Map data; - final DateTime createdAt; - final List relatedIds; -} -``` - -## 4. 상호작용 패턴 - -### 4.1 테스트 실행 시퀀스 - -```mermaid -sequenceDiagram - participant TR as Test Runner - participant STF as ScreenTestFramework - participant TDG as TestDataGenerator - participant BST as BaseScreenTest - participant AED as ApiErrorDiagnostics - participant AF as AutoFixer - participant RC as ReportCollector - - TR->>STF: initializeTest(screenName) - STF->>STF: detectFeatures() - STF->>TDG: generateTestData() - TDG-->>STF: testData - - STF->>BST: executeScreenTest(features, data) - BST->>BST: runTestCases() - - alt Test Success - BST-->>STF: TestResult(success) - STF->>RC: collectResult() - else Test Failure - BST-->>STF: TestError - STF->>AED: diagnose(error) - AED-->>STF: ErrorDiagnosis - STF->>AF: attemptFix(diagnosis) - AF-->>STF: FixResult - - alt Fix Success - STF->>BST: retryTest() - else Fix Failed - STF->>RC: recordFailure() - end - end - - STF->>RC: generateReport() - RC-->>TR: TestReport -``` - -### 4.2 에러 진단 및 자동 수정 플로우 - -```mermaid -flowchart TD - A[API Error Detected] --> B{Error Type?} - - B -->|Authentication| C[Auth Diagnostics] - B -->|Data Validation| D[Validation Diagnostics] - B -->|Network| E[Network Diagnostics] - B -->|Server Error| F[Server Diagnostics] - - C --> G[Analyze Token Status] - D --> H[Check Data Format] - E --> I[Test Connectivity] - F --> J[Check Server Health] - - G --> K{Token Valid?} - K -->|No| L[Refresh Token] - K -->|Yes| M[Check Permissions] - - H --> N{Data Valid?} - N -->|No| O[Generate Valid Data] - N -->|Yes| P[Check Constraints] - - L --> Q[Retry Request] - O --> Q - M --> Q - P --> Q - - Q --> R{Success?} - R -->|Yes| S[Continue Test] - R -->|No| T[Record Failure] -``` - -## 5. 디렉토리 구조 - -``` -test/integration/automated/ -├── framework/ -│ ├── core/ -│ │ ├── screen_test_framework.dart -│ │ ├── api_error_diagnostics.dart -│ │ ├── auto_fixer.dart -│ │ └── test_data_generator.dart -│ ├── infrastructure/ -│ │ ├── test_context.dart -│ │ ├── dependency_container.dart -│ │ └── report_collector.dart -│ ├── support/ -│ │ ├── retry_handler.dart -│ │ ├── validation_manager.dart -│ │ └── diagnostics_manager.dart -│ └── models/ -│ ├── test_models.dart -│ ├── error_models.dart -│ └── report_models.dart -├── screens/ -│ ├── base/ -│ │ └── base_screen_test.dart -│ ├── license/ -│ │ ├── license_screen_test.dart -│ │ └── license_test_scenarios.dart -│ ├── equipment/ -│ │ ├── equipment_screen_test.dart -│ │ └── equipment_test_scenarios.dart -│ └── warehouse/ -│ ├── warehouse_screen_test.dart -│ └── warehouse_test_scenarios.dart -└── reports/ - ├── generators/ - │ ├── html_report_generator.dart - │ └── json_report_generator.dart - └── templates/ - └── report_template.html -``` - -## 6. 확장 가능한 구조 - -### 6.1 플러그인 시스템 - -```dart -abstract class TestPlugin { - String get name; - String get version; - - Future initialize(TestContext context); - Future beforeTest(TestCase testCase); - Future afterTest(TestResult result); - Future onError(TestError error); -} - -class PluginManager { - final List _plugins = []; - - void register(TestPlugin plugin) { - _plugins.add(plugin); - } - - Future executePlugins(PluginPhase phase, dynamic data) async { - for (final plugin in _plugins) { - await plugin.execute(phase, data); - } - } -} -``` - -### 6.2 커스텀 진단 룰 - -```dart -abstract class DiagnosticRule { - String get ruleId; - int get priority; - - bool canHandle(ApiError error); - Future diagnose(ApiError error); -} - -class DiagnosticRuleEngine { - final List _rules = []; - - void addRule(DiagnosticRule rule) { - _rules.add(rule); - _rules.sort((a, b) => b.priority.compareTo(a.priority)); - } - - Future diagnose(ApiError error) async { - for (final rule in _rules) { - if (rule.canHandle(error)) { - return await rule.diagnose(error); - } - } - return DefaultDiagnosis(error); - } -} -``` - -## 7. SOLID 원칙 적용 - -### 7.1 Single Responsibility Principle (SRP) -- 각 클래스는 하나의 책임만 가짐 -- ScreenTestFramework: 화면 테스트 조정 -- ApiErrorDiagnostics: 에러 진단 -- AutoFixer: 에러 수정 -- TestDataGenerator: 데이터 생성 - -### 7.2 Open/Closed Principle (OCP) -- 플러그인 시스템을 통한 확장 -- 추상 클래스를 통한 구현 확장 -- 새로운 화면 테스트 추가 시 기존 코드 수정 불필요 - -### 7.3 Liskov Substitution Principle (LSP) -- 모든 화면 테스트는 BaseScreenTest를 대체 가능 -- 모든 진단 룰은 DiagnosticRule 인터페이스 준수 - -### 7.4 Interface Segregation Principle (ISP) -- 작고 구체적인 인터페이스 제공 -- 클라이언트가 필요하지 않은 메서드에 의존하지 않음 - -### 7.5 Dependency Inversion Principle (DIP) -- 추상화에 의존, 구체적인 구현에 의존하지 않음 -- DI 컨테이너를 통한 의존성 주입 - -## 8. 성능 및 확장성 고려사항 - -### 8.1 병렬 처리 -```dart -class ParallelTestExecutor { - Future> executeParallel( - List testCases, - {int maxConcurrency = 4} - ) async { - final pool = Pool(maxConcurrency); - final results = []; - - await Future.wait( - testCases.map((testCase) => - pool.withResource(() => executeTest(testCase)) - ) - ); - - return results; - } -} -``` - -### 8.2 캐싱 전략 -```dart -class TestDataCache { - final Duration _ttl = Duration(minutes: 30); - final Map _cache = {}; - - Future getOrGenerate( - String key, - Future Function() generator - ) async { - final cached = _cache[key]; - if (cached != null && !cached.isExpired) { - return cached.data; - } - - final data = await generator(); - _cache[key] = CachedData(data, DateTime.now()); - return data; - } -} -``` - -## 9. 모니터링 및 로깅 - -```dart -class TestMonitor { - final MetricsCollector _metrics; - final Logger _logger; - - Future monitorTest(TestCase testCase) async { - final stopwatch = Stopwatch()..start(); - - try { - await testCase.execute(); - _metrics.recordSuccess(testCase.name, stopwatch.elapsed); - } catch (e) { - _metrics.recordFailure(testCase.name, stopwatch.elapsed); - _logger.error('Test failed: ${testCase.name}', e); - } - } -} -``` - -## 10. 결론 - -이 아키텍처는 확장 가능하고 유지보수가 용이한 Real API 기반 자동화 테스트 프레임워크를 제공합니다. SOLID 원칙을 준수하며, 플러그인 시스템을 통해 쉽게 확장할 수 있고, 에러 진단 및 자동 수정 기능을 통해 테스트의 안정성을 높입니다. \ No newline at end of file diff --git a/doc/07_test_report_automated_equipment_in.md b/doc/07_test_report_automated_equipment_in.md deleted file mode 100644 index 5d504b4..0000000 --- a/doc/07_test_report_automated_equipment_in.md +++ /dev/null @@ -1,312 +0,0 @@ -# Superport 장비 입고 자동화 테스트 보고서 - -작성일: 2025-08-04 -작성자: Flutter QA Engineer -프로젝트: SuperPort 장비 입고 자동화 테스트 - -## 📋 테스트 전략 개요 (Test Strategy Overview) - -### 1. 테스트 목표 -- 장비 입고 프로세스의 완전 자동화 검증 -- 에러 자동 진단 및 수정 시스템 검증 -- API 통신 안정성 확보 -- 데이터 무결성 보장 - -### 2. 테스트 접근 방법 -- **자동화 수준**: 100% 자동화된 테스트 실행 -- **에러 복구**: 자동 진단 및 수정 시스템 적용 -- **데이터 생성**: 스마트 테스트 데이터 생성기 활용 -- **리포트**: 실시간 테스트 진행 상황 추적 - -## 🧪 테스트 케이스 문서 (Test Case Documentation) - -### 장비 입고 자동화 테스트 시나리오 - -#### 1. 정상 장비 입고 프로세스 -``` -테스트 ID: EQ-IN-001 -목적: 정상적인 장비 입고 전체 프로세스 검증 -전제 조건: -- 유효한 회사 및 창고 데이터 존재 -- 인증된 사용자 세션 - -테스트 단계: -1. 회사 데이터 확인/생성 -2. 창고 위치 확인/생성 -3. 장비 데이터 자동 생성 -4. 장비 등록 API 호출 -5. 장비 입고 처리 -6. 장비 이력 추가 -7. 입고 결과 검증 - -예상 결과: -- 모든 단계 성공 -- 장비 상태 'I' (입고)로 변경 -- 이력 데이터 생성 확인 -``` - -#### 2. 필수 필드 누락 시나리오 -``` -테스트 ID: EQ-IN-002 -목적: 필수 필드 누락 시 자동 수정 기능 검증 -전제 조건: 불완전한 장비 데이터 - -테스트 단계: -1. 필수 필드가 누락된 장비 데이터 생성 -2. 장비 등록 시도 -3. 에러 발생 확인 -4. 자동 진단 시스템 작동 -5. 누락 필드 자동 보완 -6. 재시도 및 성공 확인 - -예상 결과: -- 에러 타입: missingRequiredField -- 자동 수정 성공 -- 장비 등록 완료 -``` - -#### 3. 잘못된 참조 ID 시나리오 -``` -테스트 ID: EQ-IN-003 -목적: 존재하지 않는 창고 ID 사용 시 처리 -전제 조건: 유효하지 않은 창고 ID - -테스트 단계: -1. 장비 생성 성공 -2. 존재하지 않는 창고 ID로 입고 시도 -3. 참조 에러 발생 -4. 자동으로 유효한 창고 생성 -5. 새 창고 ID로 재시도 -6. 입고 성공 확인 - -예상 결과: -- 에러 타입: invalidReference -- 새 창고 자동 생성 -- 입고 프로세스 완료 -``` - -#### 4. 중복 시리얼 번호 시나리오 -``` -테스트 ID: EQ-IN-004 -목적: 중복 시리얼 번호 처리 검증 -전제 조건: 기존 장비와 동일한 시리얼 번호 - -테스트 단계: -1. 첫 번째 장비 생성 (시리얼: DUP-SERIAL-12345) -2. 동일 시리얼로 두 번째 장비 생성 시도 -3. 중복 에러 또는 허용 확인 -4. 에러 시 새 시리얼 자동 생성 -5. 새 시리얼로 재시도 -6. 두 번째 장비 생성 성공 - -예상 결과: -- 시스템 정책에 따라 처리 -- 중복 불허 시 자동 수정 -- 모든 장비 고유 식별 보장 -``` - -#### 5. 권한 오류 시나리오 -``` -테스트 ID: EQ-IN-005 -목적: 권한 없는 창고 접근 시 처리 -전제 조건: 다른 회사의 창고 존재 - -테스트 단계: -1. 타 회사 및 창고 생성 -2. 해당 창고로 입고 시도 -3. 권한 에러 확인 (시스템 지원 시) -4. 권한 있는 창고로 자동 전환 -5. 정상 입고 처리 -6. 결과 검증 - -예상 결과: -- 권한 체크 여부 확인 -- 적절한 창고로 리디렉션 -- 입고 성공 -``` - -## 📊 테스트 실행 결과 (Test Execution Results) - -### 실행 환경 -- **Flutter 버전**: 3.x -- **Dart 버전**: 3.x -- **테스트 프레임워크**: flutter_test + 자동화 프레임워크 -- **실행 시간**: 2025-08-04 - -### 전체 결과 요약 -| 항목 | 결과 | -|------|------| -| 총 테스트 시나리오 | 5개 | -| 성공 | 0개 | -| 실패 | 5개 | -| 건너뜀 | 0개 | -| 자동 수정 | 0개 | - -### 상세 실행 결과 - -#### ❌ EQ-IN-001: 정상 장비 입고 프로세스 -- **상태**: 실패 -- **원인**: 컴파일 에러 - 프레임워크 의존성 문제 -- **에러 메시지**: `AutoFixer` 클래스를 찾을 수 없음 - -#### ❌ EQ-IN-002: 필수 필드 누락 시나리오 -- **상태**: 실패 -- **원인**: 동일한 컴파일 에러 - -#### ❌ EQ-IN-003: 잘못된 참조 ID 시나리오 -- **상태**: 실패 -- **원인**: 동일한 컴파일 에러 - -#### ❌ EQ-IN-004: 중복 시리얼 번호 시나리오 -- **상태**: 실패 -- **원인**: 동일한 컴파일 에러 - -#### ❌ EQ-IN-005: 권한 오류 시나리오 -- **상태**: 실패 -- **원인**: 동일한 컴파일 에러 - -## 🐛 발견된 버그 목록 (Bug List) - -### 심각도: 매우 높음 -1. **프레임워크 클래스 누락** - - 증상: `AutoFixer` 클래스가 정의되지 않음 - - 원인: 자동 수정 모듈이 구현되지 않음 - - 영향: 전체 자동화 테스트 실행 불가 - - 해결방안: AutoFixer 클래스 구현 필요 - -2. **모델 간 타입 불일치** - - 증상: `TestReport` 클래스 중복 선언 - - 원인: 모듈 간 네이밍 충돌 - - 영향: 리포트 생성 기능 마비 - - 해결방안: 클래스명 리팩토링 - -3. **API 클라이언트 초기화 오류** - - 증상: `ApiClient` 생성자 파라미터 불일치 - - 원인: baseUrl 파라미터 제거됨 - - 영향: API 통신 불가 - - 해결방안: 환경 설정 기반 초기화로 변경 - -### 심각도: 높음 -4. **서비스 의존성 주입 실패** - - 증상: 서비스 생성자 파라미터 누락 - - 원인: GetIt 설정 불완전 - - 영향: 서비스 인스턴스 생성 실패 - - 해결방안: 적절한 의존성 주입 설정 - -5. **Import 충돌** - - 증상: `AuthService` 다중 import - - 원인: 동일 이름의 클래스가 여러 위치에 존재 - - 영향: 컴파일 에러 - - 해결방안: 명시적 import alias 사용 - -## 🚀 성능 분석 결과 (Performance Analysis Results) - -### 테스트 실행 성능 -- **테스트 준비 시간**: N/A (컴파일 실패) -- **평균 실행 시간**: N/A -- **메모리 사용량**: N/A - -### 예상 성능 지표 -- **단일 장비 입고**: ~500ms -- **대량 입고 (100개)**: ~15초 -- **자동 수정 오버헤드**: +200ms - -## 💾 메모리 사용량 분석 (Memory Usage Analysis) - -### 예상 메모리 프로파일 -- **테스트 프레임워크**: 25MB -- **Mock 데이터**: 15MB -- **리포트 생성**: 10MB -- **총 예상 사용량**: 50MB - -## 📈 개선 권장사항 (Improvement Recommendations) - -### 1. 즉시 수정 필요 -- [ ] `AutoFixer` 클래스 구현 -- [ ] 모델 클래스명 충돌 해결 -- [ ] API 클라이언트 초기화 로직 수정 -- [ ] 서비스 의존성 주입 완성 - -### 2. 프레임워크 개선 -- [ ] 에러 복구 메커니즘 강화 -- [ ] 테스트 데이터 생성기 안정화 -- [ ] 리포트 생성 모듈 분리 - -### 3. 테스트 안정성 -- [ ] Mock 서비스 완성도 향상 -- [ ] 통합 테스트 환경 격리 -- [ ] 병렬 실행 지원 - -### 4. 문서화 -- [ ] 자동화 프레임워크 사용 가이드 -- [ ] 트러블슈팅 가이드 -- [ ] 베스트 프랙티스 문서 - -## 📊 테스트 커버리지 보고서 (Test Coverage Report) - -### 현재 커버리지 -- **장비 입고 프로세스**: 0% (실행 불가) -- **에러 처리 경로**: 0% -- **자동 수정 기능**: 0% - -### 목표 커버리지 -- **핵심 프로세스**: 95% -- **에러 시나리오**: 80% -- **엣지 케이스**: 70% - -## 🔄 CI/CD 통합 현황 - -### 현재 상태 -- ✅ 테스트 실행 스크립트 생성 완료 (`run_tests.sh`) -- ❌ 자동화 테스트 실행 불가 -- ❌ CI 파이프라인 미통합 - -### 권장 설정 -```yaml -name: Equipment In Automation Test -on: - push: - paths: - - 'lib/services/equipment_service.dart' - - 'test/integration/automated/**' - pull_request: - types: [opened, synchronize] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2 - - run: flutter pub get - - run: flutter pub run build_runner build - - run: ./test/integration/automated/run_tests.sh - - uses: actions/upload-artifact@v3 - with: - name: test-results - path: test_results/ -``` - -## 📝 결론 및 다음 단계 - -### 현재 상황 -장비 입고 자동화 테스트 프레임워크는 혁신적인 접근 방식을 제시하지만, 현재 구현 상태에서는 실행이 불가능합니다. 주요 문제는 핵심 클래스들의 미구현과 의존성 관리 실패입니다. - -### 긴급 조치 사항 -1. **AutoFixer 클래스 구현** - 자동 수정 기능의 핵심 -2. **의존성 정리** - 클래스명 충돌 및 import 문제 해결 -3. **Mock 서비스 완성** - 누락된 메서드 추가 - -### 장기 개선 방향 -1. **점진적 통합** - 단순 테스트부터 시작하여 복잡도 증가 -2. **모듈화** - 프레임워크 컴포넌트 분리 및 독립적 테스트 -3. **문서화** - 개발자 가이드 및 트러블슈팅 문서 작성 - -### 기대 효과 -프레임워크가 정상 작동 시: -- 테스트 작성 시간 70% 단축 -- 에러 발견 및 수정 자동화 -- 회귀 테스트 신뢰도 향상 -- 개발 속도 전반적 향상 - -현재는 기초 인프라 구축이 시급하며, 이후 점진적으로 자동화 수준을 높여가는 전략을 권장합니다. \ No newline at end of file diff --git a/doc/07_test_report_equipment_status.md b/doc/07_test_report_equipment_status.md deleted file mode 100644 index 3f2f986..0000000 --- a/doc/07_test_report_equipment_status.md +++ /dev/null @@ -1,256 +0,0 @@ -# Equipment Status 테스트 보고서 - -## 테스트 전략 개요 - -본 문서는 Superport 앱의 Equipment(장비) 관련 기능, 특히 equipment_status 필드의 타입 불일치 문제를 중심으로 한 테스트 분석 보고서입니다. - -## 발견된 문제점 - -### 1. Equipment Status 타입 불일치 - -#### 문제 상황 -- **Flutter 앱**: 단일 문자 코드 사용 - - `I`: 입고 - - `O`: 출고 - - `T`: 대여 - - `R`: 수리 - - `D`: 손상 - - `L`: 분실 - - `E`: 기타 - -- **백엔드 API**: 문자열 사용 - - `available`: 사용가능 - - `in_use`: 사용중 - - `maintenance`: 유지보수 - - `disposed`: 폐기 - - `rented`: 대여중 - -#### 영향받는 파일 -1. `/lib/utils/constants.dart` - EquipmentStatus 클래스 -2. `/lib/core/constants/app_constants.dart` - equipmentStatus 매핑 -3. `/lib/screens/equipment/widgets/equipment_status_chip.dart` - UI 표시 로직 -4. `/lib/data/models/equipment/equipment_response.dart` - 데이터 모델 -5. `/lib/data/models/equipment/equipment_list_dto.dart` - 리스트 DTO - -### 2. 상태 변환 로직 부재 - -현재 코드베이스에서 Flutter 앱의 단일 문자 코드와 백엔드 API의 문자열 상태 간 변환 로직이 명확하게 구현되어 있지 않습니다. - -## 테스트 케이스 문서 - -### 1. 단위 테스트 - -#### 1.1 상태 코드 변환 테스트 -```dart -// 테스트 대상: 상태 코드 변환 유틸리티 -test('단일 문자 코드를 API 상태로 변환', () { - expect(convertToApiStatus('I'), 'available'); - expect(convertToApiStatus('O'), 'in_use'); - expect(convertToApiStatus('T'), 'rented'); - expect(convertToApiStatus('R'), 'maintenance'); - expect(convertToApiStatus('D'), 'disposed'); -}); - -test('API 상태를 단일 문자 코드로 변환', () { - expect(convertFromApiStatus('available'), 'I'); - expect(convertFromApiStatus('in_use'), 'O'); - expect(convertFromApiStatus('rented'), 'T'); - expect(convertFromApiStatus('maintenance'), 'R'); - expect(convertFromApiStatus('disposed'), 'D'); -}); -``` - -#### 1.2 모델 파싱 테스트 -```dart -test('EquipmentResponse JSON 파싱 시 상태 처리', () { - final json = { - 'id': 1, - 'equipmentNumber': 'EQ001', - 'status': 'available', - 'manufacturer': 'Samsung', - // ... 기타 필드 - }; - - final equipment = EquipmentResponse.fromJson(json); - expect(equipment.status, 'available'); -}); -``` - -### 2. 위젯 테스트 - -#### 2.1 EquipmentStatusChip 테스트 -```dart -testWidgets('상태별 칩 색상 및 텍스트 표시', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: EquipmentStatusChip(status: 'I'), - ), - ), - ); - - expect(find.text('입고'), findsOneWidget); - - final chip = tester.widget(find.byType(Chip)); - expect(chip.backgroundColor, Colors.green); -}); -``` - -### 3. 통합 테스트 - -#### 3.1 API 통신 테스트 -```dart -test('장비 목록 조회 시 상태 필드 처리', () async { - final result = await equipmentService.getEquipments(); - - result.fold( - (failure) => fail('API 호출 실패'), - (equipments) { - for (final equipment in equipments) { - // 상태 값이 예상 범위 내에 있는지 확인 - expect( - ['available', 'in_use', 'maintenance', 'disposed', 'rented'], - contains(equipment.status), - ); - } - }, - ); -}); -``` - -## 발견된 버그 목록 - -### 버그 #1: 상태 코드 불일치로 인한 표시 오류 -- **심각도**: 높음 -- **증상**: 장비 상태가 "알 수 없음"으로 표시됨 -- **원인**: Flutter 앱과 API 간 상태 코드 체계 불일치 -- **재현 방법**: - 1. 장비 목록 화면 접속 - 2. API에서 'available' 상태의 장비 반환 - 3. EquipmentStatusChip이 해당 상태를 인식하지 못함 - -### 버그 #2: 상태 변경 API 호출 실패 -- **심각도**: 중간 -- **증상**: 장비 상태 변경 시 400 Bad Request 오류 -- **원인**: 단일 문자 코드를 API에 전송 -- **재현 방법**: - 1. 장비 상세 화면에서 상태 변경 시도 - 2. 'I' 같은 단일 문자 코드 전송 - 3. API가 인식하지 못해 오류 반환 - -## 성능 분석 결과 - -### 렌더링 성능 -- EquipmentStatusChip 위젯의 switch 문이 비효율적 -- 상태 매핑을 Map으로 변경하면 O(1) 조회 가능 - -### API 응답 시간 -- 장비 목록 조회: 평균 200ms -- 상태 변경: 평균 150ms -- 성능상 문제없으나 오류 처리로 인한 재시도 발생 - -## 메모리 사용량 분석 - -- 상태 관련 상수 정의가 여러 파일에 중복 -- 통합된 상태 관리 클래스로 메모리 사용 최적화 가능 - -## 개선 권장사항 - -### 1. 상태 변환 레이어 구현 -```dart -class EquipmentStatusConverter { - static const Map _flutterToApi = { - 'I': 'available', - 'O': 'in_use', - 'T': 'rented', - 'R': 'maintenance', - 'D': 'disposed', - 'L': 'disposed', - 'E': 'maintenance', - }; - - static const Map _apiToFlutter = { - 'available': 'I', - 'in_use': 'O', - 'rented': 'T', - 'maintenance': 'R', - 'disposed': 'D', - }; - - static String toApi(String flutterStatus) { - return _flutterToApi[flutterStatus] ?? 'available'; - } - - static String fromApi(String apiStatus) { - return _apiToFlutter[apiStatus] ?? 'E'; - } -} -``` - -### 2. 모델 클래스 수정 -```dart -@freezed -class EquipmentResponse with _$EquipmentResponse { - const EquipmentResponse._(); - - const factory EquipmentResponse({ - required int id, - required String equipmentNumber, - @JsonKey(name: 'status', fromJson: EquipmentStatusConverter.fromApi) - required String status, - // ... 기타 필드 - }) = _EquipmentResponse; -} -``` - -### 3. API 클라이언트 수정 -```dart -Future changeEquipmentStatus( - int id, - String status, - String? reason -) async { - final apiStatus = EquipmentStatusConverter.toApi(status); - - final response = await _apiClient.patch( - '${ApiEndpoints.equipment}/$id/status', - data: { - 'status': apiStatus, - if (reason != null) 'reason': reason, - }, - ); - // ... -} -``` - -### 4. 에러 처리 강화 -- 알 수 없는 상태 값에 대한 fallback 처리 -- 사용자에게 명확한 에러 메시지 제공 -- 로깅 시스템에 상태 변환 실패 기록 - -### 5. 테스트 자동화 -- 상태 변환 로직에 대한 단위 테스트 필수 -- API 목업을 활용한 통합 테스트 -- CI/CD 파이프라인에 테스트 포함 - -## 테스트 커버리지 보고서 - -### 현재 커버리지 -- Equipment 관련 코드: 약 40% -- 상태 관련 로직: 0% (테스트 없음) - -### 목표 커버리지 -- Equipment 관련 코드: 80% 이상 -- 상태 변환 로직: 100% -- API 통신 로직: 90% 이상 - -## 결론 - -Equipment status 필드의 타입 불일치는 앱의 핵심 기능에 영향을 미치는 중요한 문제입니다. 제안된 개선사항을 구현하면: - -1. 상태 표시 오류 해결 -2. API 통신 안정성 향상 -3. 코드 유지보수성 개선 -4. 향후 상태 추가/변경 시 유연한 대응 가능 - -즉각적인 수정이 필요하며, 테스트 코드 작성을 통해 회귀 버그를 방지해야 합니다. \ No newline at end of file diff --git a/doc/07_test_report_superport.md b/doc/07_test_report_superport.md deleted file mode 100644 index 48d68b4..0000000 --- a/doc/07_test_report_superport.md +++ /dev/null @@ -1,289 +0,0 @@ -# Superport 앱 테스트 보고서 - -## 테스트 전략 개요 - -### 1. 테스트 범위 -- **단위 테스트**: 컨트롤러, 서비스, 모델 클래스 -- **위젯 테스트**: 주요 화면 UI 컴포넌트 -- **통합 테스트**: 장비 입고 프로세스, API 연동 -- **자동화 테스트**: 에러 자동 진단 및 수정 시스템 - -### 2. 테스트 접근 방식 -- Mock 기반 독립적 테스트 -- 실제 API 연동 테스트 (선택적) -- 에러 시나리오 시뮬레이션 -- 성능 및 메모리 프로파일링 - -## 테스트 케이스 문서 - -### 1. 장비 입고 프로세스 테스트 - -#### 1.1 정상 시나리오 -```dart -test('정상적인 장비 입고 프로세스', () async { - // Given: 유효한 회사, 창고, 장비 데이터 - // When: 장비 생성 및 입고 실행 - // Then: 성공적으로 입고 완료 -}); -``` - -**테스트 단계**: -1. 회사 정보 조회 및 검증 -2. 창고 정보 조회 및 검증 -3. 신규 장비 생성 -4. 장비 입고 처리 -5. 결과 검증 - -#### 1.2 에러 처리 시나리오 -```dart -test('필수 필드 누락 시 에러 처리', () async { - // Given: 필수 필드가 누락된 장비 데이터 - // When: 장비 생성 시도 - // Then: 에러 발생 및 자동 수정 실행 -}); -``` - -**자동 수정 프로세스**: -1. 에러 감지 (필수 필드 누락) -2. 누락 필드 식별 -3. 기본값 자동 설정 -4. 재시도 및 성공 확인 - -### 2. 네트워크 복원력 테스트 - -#### 2.1 연결 실패 재시도 -```dart -test('API 서버 연결 실패 시 재시도', () async { - // Given: 네트워크 불안정 상황 - // When: API 호출 시도 - // Then: 3회 재시도 후 성공 -}); -``` - -**재시도 전략**: -- 최대 3회 시도 -- 지수 백오프 (1초, 2초, 4초) -- 연결 성공 시 즉시 처리 - -### 3. 대량 처리 테스트 - -#### 3.1 동시 다발적 입고 처리 -```dart -test('여러 장비 동시 입고 처리', () async { - // Given: 10개의 장비 데이터 - // When: 순차적 입고 처리 - // Then: 100% 성공률 달성 -}); -``` - -## 테스트 실행 결과 - -### 1. 단위 테스트 결과 -| 컨트롤러 | 총 테스트 | 성공 | 실패 | 커버리지 | -|---------|----------|------|------|----------| -| OverviewController | 5 | 5 | 0 | 92% | -| EquipmentListController | 8 | 8 | 0 | 88% | -| LicenseListController | 24 | 24 | 0 | 95% | -| UserListController | 7 | 7 | 0 | 90% | -| WarehouseLocationListController | 18 | 18 | 0 | 93% | - -### 2. 위젯 테스트 결과 -| 화면 | 총 테스트 | 성공 | 실패 | 비고 | -|------|----------|------|------|------| -| OverviewScreen | 4 | 0 | 4 | RecentActivity 모델 속성 오류 | -| EquipmentListScreen | 6 | 6 | 0 | 목록 및 필터 동작 확인 | -| LicenseListScreen | 11 | 11 | 0 | 만료 알림 표시 확인 | -| UserListScreen | 10 | 10 | 0 | 상태 변경 동작 확인 | -| WarehouseLocationListScreen | 9 | 9 | 0 | 기본 CRUD 동작 확인 | -| CompanyListScreen | 8 | 2 | 6 | UI 렌더링 및 체크박스 오류 | -| LoginScreen | 5 | 0 | 5 | GetIt 서비스 등록 문제 | - -### 3. 통합 테스트 결과 -| 시나리오 | 실행 시간 | 결과 | 비고 | -|---------|----------|------|------| -| 정상 장비 입고 | 0.5초 | ✅ 성공 | Mock 기반 테스트 | -| 에러 자동 수정 | 0.3초 | ✅ 성공 | 필드 누락 자동 처리 | -| 네트워크 재시도 | 2.2초 | ✅ 성공 | 3회 재시도 성공 | -| 대량 입고 처리 | 0.8초 | ✅ 성공 | 10개 장비 100% 성공 | -| 회사 데모 테스트 | 0.2초 | ✅ 성공 | CRUD 작업 검증 | -| 사용자 데모 테스트 | 0.3초 | ✅ 성공 | 사용자 관리 기능 검증 | -| 창고 데모 테스트 | 0.2초 | ✅ 성공 | 창고 관리 기능 검증 | - -### 4. 테스트 요약 -- **총 테스트 수**: 201개 -- **성공**: 119개 (59.2%) -- **실패**: 75개 (37.3%) -- **건너뛴 테스트**: 7개 (3.5%) - -## 발견된 버그 목록 - -### 1. 수정 완료된 버그 -1. **API 응답 파싱 오류** - - 원인: ResponseInterceptor의 data/items 처리 로직 오류 - - 수정: 올바른 응답 구조 확인 후 파싱 로직 개선 - - 상태: ✅ 수정 완료 - -2. **Mock 서비스 메서드명 불일치** - - 원인: getCompany, getLicense 등 잘못된 메서드명 사용 - - 수정: getCompanyDetail, getLicenseById 등 올바른 메서드명으로 변경 - - 상태: ✅ 수정 완료 - -3. **Provider 누락 오류** - - 원인: Widget 테스트에서 Controller Provider 누락 - - 수정: 모든 Widget 테스트에 Provider 래핑 추가 - - 상태: ✅ 수정 완료 - -4. **실제 API 테스트 타임아웃** - - 원인: CI 환경에서 실제 API 호출 시 연결 실패 - - 수정: 실제 API 테스트 skip 처리 - - 상태: ✅ 수정 완료 - -### 2. 진행 중인 이슈 -1. **RecentActivity 모델 속성 오류** - - 현상: overview_screen_redesign에서 'type' 대신 'activityType' 사용 필요 - - 계획: 모델 속성명 일치 작업 - - 우선순위: 높음 - -2. **GetIt 서비스 등록 문제** - - 현상: DashboardService, AuthService 등이 제대로 등록되지 않음 - - 계획: 테스트 환경에서 GetIt 초기화 순서 개선 - - 우선순위: 높음 - -3. **UI 렌더링 오류** - - 현상: CompanyListScreen에서 체크박스 클릭 시 IndexError - - 계획: UI 요소 접근 방식 개선 - - 우선순위: 중간 - -## 성능 분석 결과 - -### 1. 앱 시작 시간 -- Cold Start: 평균 2.1초 -- Warm Start: 평균 0.8초 -- 목표: Cold Start 1.5초 이내 - -### 2. 화면 전환 성능 -| 화면 전환 | 평균 시간 | 최대 시간 | 프레임 드롭 | -|----------|----------|----------|-------------| -| 로그인 → 대시보드 | 320ms | 450ms | 0 | -| 대시보드 → 장비 목록 | 280ms | 380ms | 0 | -| 장비 목록 → 상세 | 180ms | 250ms | 0 | - -### 3. API 응답 시간 -| API 엔드포인트 | 평균 응답 시간 | 95% 백분위 | 타임아웃 비율 | -|---------------|---------------|------------|--------------| -| /auth/login | 450ms | 780ms | 0.1% | -| /equipments | 320ms | 520ms | 0.05% | -| /licenses | 280ms | 480ms | 0.03% | - -## 메모리 사용량 분석 - -### 1. 메모리 프로파일 -- 앱 시작 시: 48MB -- 일반 사용 중: 65-75MB -- 피크 사용량: 95MB (대량 목록 로드 시) -- 메모리 누수: 감지되지 않음 ✅ - -### 2. 이미지 캐싱 -- 캐시 크기: 최대 50MB -- 캐시 히트율: 78% -- 메모리 압박 시 자동 정리 동작 확인 - -## 개선 권장사항 - -### 1. 즉시 적용 가능한 개선사항 -1. **검색 성능 최적화** - - 디바운싱 적용으로 API 호출 감소 - - 로컬 필터링 우선 적용 - -2. **목록 렌더링 최적화** - - ListView.builder 대신 ListView.separated 사용 - - 이미지 레이지 로딩 개선 - -3. **에러 메시지 개선** - - 사용자 친화적 메시지로 변경 - - 재시도 버튼 추가 - -### 2. 중장기 개선사항 -1. **오프라인 지원** - - SQLite 기반 로컬 데이터베이스 구현 - - 동기화 전략 수립 - -2. **푸시 알림** - - 장비 만료 알림 - - 라이선스 갱신 알림 - -3. **분석 도구 통합** - - Firebase Analytics 또는 Mixpanel - - 사용자 행동 패턴 분석 - -## 테스트 커버리지 보고서 - -### 1. 전체 커버리지 -- 라인 커버리지: 59.2% -- 테스트 성공률: 119/194 (61.3%) -- 실패 테스트: 75개 -- 건너뛴 테스트: 7개 - -### 2. 모듈별 커버리지 -| 모듈 | 테스트 성공률 | 주요 실패 영역 | -|------|--------------|----------------| -| Controllers | 91% (62/68) | 통합 테스트 일부 | -| Widget Tests | 58% (40/69) | RecentActivity 모델, GetIt 등록 | -| Integration Tests | 73% (17/23) | 실제 API 테스트 skip | -| Models | 100% (18/18) | 모든 테스트 통과 | - -### 3. 커버리지 향상 계획 -1. 에러 시나리오 테스트 추가 -2. 엣지 케이스 보강 -3. 통합 테스트 확대 - -## 결론 - -Superport 앱의 테스트 체계는 지속적인 개선이 필요합니다. 현재 59.2%의 테스트 성공률을 보이고 있으며, 특히 Widget 테스트에서 많은 실패가 발생하고 있습니다. - -### 주요 성과 -- ✅ 단위 테스트 91% 성공률 달성 -- ✅ Mock 서비스 체계 구축 완료 -- ✅ 통합 테스트 자동화 기반 마련 -- ✅ 테스트 실행 스크립트 작성 - -### 개선이 필요한 부분 -- ❌ Widget 테스트 성공률 58% (개선 필요) -- ❌ GetIt 서비스 등록 문제 해결 필요 -- ❌ RecentActivity 모델 속성 불일치 수정 -- ❌ UI 렌더링 오류 해결 - -### 다음 단계 -1. Widget 테스트 실패 원인 분석 및 수정 -2. GetIt 서비스 등록 체계 개선 -3. 테스트 커버리지 80% 이상 목표 -4. CI/CD 파이프라인에 테스트 통합 - ---- - -*작성일: 2025년 1월 20일* -*업데이트: 2025년 1월 20일* -*작성자: Flutter QA Engineer* -*버전: 2.0* - -## 부록: 테스트 수정 작업 요약 - -### 수정된 주요 이슈 -1. **Mock 서비스 메서드명 통일** - - getCompany → getCompanyDetail - - getLicense → getLicenseById - - getWarehouseLocation → getWarehouseLocationById - - 모든 통합 테스트에서 올바른 메서드명 사용 - -2. **Widget 테스트 Provider 설정** - - 모든 Widget 테스트에 ChangeNotifierProvider 추가 - - Controller에 dataService 파라미터 전달 - -3. **실제 API 테스트 Skip 처리** - - CI 환경에서 실패하는 실제 API 테스트 skip - - 로컬 환경에서만 실행 가능 - -4. **LicenseListController 테스트 수정** - - 라이센스 삭제 실패 테스트: mockDataService도 함께 mock 설정 - - 라이센스 상태별 개수 테스트: getAllLicenses mock 추가 - - 다음 페이지 로드 테스트: 전체 데이터 mock 설정 \ No newline at end of file diff --git a/doc/API_Additional_Implementation_Requirements.md b/doc/API_Additional_Implementation_Requirements.md deleted file mode 100644 index 2836bf1..0000000 --- a/doc/API_Additional_Implementation_Requirements.md +++ /dev/null @@ -1,335 +0,0 @@ -# SuperPort API 구현 현황 분석 보고서 - -> 작성일: 2025-07-24 -> 분석 범위: SuperPort 프론트엔드와 백엔드 API 전체 -> 분석 기준: 프론트엔드 컨트롤러 요구사항 대비 백엔드 API 구현 상태 - -## 📊 요약 - -- **전체 API 구현율**: 85.3% -- **화면별 평균 구현율**: 82.9% -- **우선 구현 필요 API 수**: 15개 -- **즉시 수정 필요 사항**: 3개 (타입 오류) - -## 🖥️ 화면별 API 구현 현황 - -### 1. 🔐 로그인 화면 -**구현율: 100%** - -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| POST `/api/v1/auth/login` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/auth/logout` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/auth/refresh` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/me` | ✅ | ✅ 구현됨 | 현재 사용자 정보 | - -### 2. 📊 대시보드 화면 -**구현율: 90%** - -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| GET `/api/v1/overview/stats` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/overview/recent-activities` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/overview/equipment-status` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/overview/license-expiry` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/statistics/summary` | ✅ | ❌ 미구현 | `/overview/stats`로 대체 가능 | - -### 3. 🏭 장비 관리 -**구현율: 87.5%** - -#### 장비 목록 -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| GET `/api/v1/equipment` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/equipment/search` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/equipment/{id}` | ✅ | ✅ 구현됨 | - | -| DELETE `/api/v1/equipment/{id}` | ✅ | ✅ 구현됨 | - | - -#### 장비 입고 -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| POST `/api/v1/equipment` | ✅ | ✅ 구현됨 | - | -| PUT `/api/v1/equipment/{id}` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/equipment/in` | ✅ | ⚠️ 타입 오류 | DbConn → DatabaseConnection | -| GET `/api/v1/equipment/manufacturers` | ✅ | ❌ 미구현 | lookup API로 구현 필요 | -| GET `/api/v1/equipment/names` | ✅ | ❌ 미구현 | 자동완성용 | -| GET `/api/v1/equipment/categories` | ✅ | ✅ 구현됨 | `/lookups` 사용 | - -#### 장비 출고 -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| POST `/api/v1/equipment/out` | ✅ | ⚠️ 타입 오류 | DbConn → DatabaseConnection | -| POST `/api/v1/equipment/{id}/status` | ✅ | ✅ 구현됨 | PATCH 메서드로 | -| POST `/api/v1/equipment/batch-out` | ✅ | ❌ 미구현 | 대량 출고 처리 | - -#### 장비 고급 기능 -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| POST `/api/v1/equipment/{id}/history` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/equipment/{id}/history` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/equipment/rentals` | ✅ | ✅ 구현됨 | 대여 처리 | -| POST `/api/v1/equipment/rentals/{id}/return` | ✅ | ✅ 구현됨 | 반납 처리 | -| POST `/api/v1/equipment/repairs` | ✅ | ✅ 구현됨 | 수리 처리 | -| POST `/api/v1/equipment/disposals` | ✅ | ✅ 구현됨 | 폐기 처리 | - -### 4. 🏢 회사 관리 -**구현율: 95%** - -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| GET `/api/v1/companies` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/companies/{id}` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/companies` | ✅ | ✅ 구현됨 | - | -| PUT `/api/v1/companies/{id}` | ✅ | ✅ 구현됨 | - | -| DELETE `/api/v1/companies/{id}` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/companies/search` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/companies/names` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/companies/check-duplicate` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/companies/with-branches` | ✅ | ❌ 미구현 | 지점 포함 조회 | - -#### 지점 관리 -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| GET `/api/v1/companies/{id}/branches` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/companies/{id}/branches` | ✅ | ✅ 구현됨 | - | -| PUT `/api/v1/companies/{id}/branches/{bid}` | ✅ | ✅ 구현됨 | - | -| DELETE `/api/v1/companies/{id}/branches/{bid}` | ✅ | ✅ 구현됨 | - | - -### 5. 👥 사용자 관리 -**구현율: 88.9%** - -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| GET `/api/v1/users` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/users/{id}` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/users` | ✅ | ✅ 구현됨 | - | -| PUT `/api/v1/users/{id}` | ✅ | ✅ 구현됨 | - | -| DELETE `/api/v1/users/{id}` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/users/search` | ✅ | ✅ 구현됨 | - | -| PATCH `/api/v1/users/{id}/status` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/users/{id}/change-password` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/users/{id}/branch` | ✅ | ❌ 미구현 | 사용자 상세에 포함 | - -### 6. 📜 라이선스 관리 -**구현율: 100%** - -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| GET `/api/v1/licenses` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/licenses/{id}` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/licenses` | ✅ | ✅ 구현됨 | - | -| PUT `/api/v1/licenses/{id}` | ✅ | ✅ 구현됨 | - | -| DELETE `/api/v1/licenses/{id}` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/licenses/expiring` | ✅ | ✅ 구현됨 | - | -| PATCH `/api/v1/licenses/{id}/assign` | ✅ | ✅ 구현됨 | - | -| PATCH `/api/v1/licenses/{id}/unassign` | ✅ | ✅ 구현됨 | - | - -### 7. 🏭 창고 위치 관리 -**구현율: 87.5%** - -| API 엔드포인트 | 필요 여부 | 구현 상태 | 비고 | -|---------------|----------|-----------|------| -| GET `/api/v1/warehouse-locations` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/warehouse-locations/{id}` | ✅ | ✅ 구현됨 | - | -| POST `/api/v1/warehouse-locations` | ✅ | ✅ 구현됨 | - | -| PUT `/api/v1/warehouse-locations/{id}` | ✅ | ✅ 구현됨 | - | -| DELETE `/api/v1/warehouse-locations/{id}` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/warehouse-locations/{id}/equipment` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/warehouse-locations/{id}/capacity` | ✅ | ✅ 구현됨 | - | -| GET `/api/v1/warehouse-locations/search` | ✅ | ❌ 미구현 | 검색 기능 | - -## 🔧 기능별 API 구현 현황 - -### 인증/권한 -**구현율: 80%** - -| 기능 | 필요 여부 | 구현 상태 | 비고 | -|------|----------|-----------|------| -| JWT 토큰 인증 | ✅ | ✅ 구현됨 | - | -| 역할 기반 권한 | ✅ | ✅ 구현됨 | admin/manager/staff/viewer | -| 토큰 갱신 | ✅ | ✅ 구현됨 | - | -| 비밀번호 변경 | ✅ | ✅ 구현됨 | - | -| 비밀번호 재설정 | ✅ | ❌ 미구현 | 이메일 기반 재설정 | - -### 파일 업로드 -**구현율: 100%** - -| 기능 | 필요 여부 | 구현 상태 | 비고 | -|------|----------|-----------|------| -| 파일 업로드 | ✅ | ✅ 구현됨 | `/api/v1/files/upload` | -| 파일 다운로드 | ✅ | ✅ 구현됨 | `/api/v1/files/{id}` | -| 파일 삭제 | ✅ | ✅ 구현됨 | - | -| 이미지 미리보기 | ✅ | ✅ 구현됨 | - | - -### 보고서/내보내기 -**구현율: 100%** - -| 기능 | 필요 여부 | 구현 상태 | 비고 | -|------|----------|-----------|------| -| PDF 생성 | ✅ | ✅ 구현됨 | `/api/v1/reports/*/pdf` | -| Excel 내보내기 | ✅ | ✅ 구현됨 | `/api/v1/reports/*/excel` | -| 맞춤 보고서 | ✅ | ✅ 구현됨 | - | - -### 통계/대시보드 -**구현율: 100%** - -| 기능 | 필요 여부 | 구현 상태 | 비고 | -|------|----------|-----------|------| -| 전체 통계 | ✅ | ✅ 구현됨 | - | -| 장비 상태 분포 | ✅ | ✅ 구현됨 | - | -| 라이선스 만료 현황 | ✅ | ✅ 구현됨 | - | -| 최근 활동 | ✅ | ✅ 구현됨 | - | - -### 대량 처리 -**구현율: 66.7%** - -| 기능 | 필요 여부 | 구현 상태 | 비고 | -|------|----------|-----------|------| -| 대량 업로드 | ✅ | ✅ 구현됨 | `/api/v1/bulk/upload` | -| 대량 수정 | ✅ | ✅ 구현됨 | `/api/v1/bulk/update` | -| 대량 출고 | ✅ | ❌ 미구현 | 다중 장비 동시 출고 | - -### 감사/백업 -**구현율: 100%** - -| 기능 | 필요 여부 | 구현 상태 | 비고 | -|------|----------|-----------|------| -| 감사 로그 | ✅ | ✅ 구현됨 | `/api/v1/audit-logs` | -| 백업 생성 | ✅ | ✅ 구현됨 | `/api/v1/backup/create` | -| 백업 복원 | ✅ | ✅ 구현됨 | `/api/v1/backup/restore` | -| 백업 스케줄 | ✅ | ✅ 구현됨 | - | - -## 🚨 미구현 API 목록 및 우선순위 - -### 긴급 (핵심 기능) -1. **장비 제조사 목록** - `GET /api/v1/equipment/manufacturers` - - 장비 입력 시 자동완성 기능에 필수 - - `/api/v1/lookups`에 추가 구현 권장 - -2. **장비명 자동완성** - `GET /api/v1/equipment/names` - - 장비 검색 UX 개선에 필수 - - distinct 쿼리로 구현 - -3. **대량 출고 처리** - `POST /api/v1/equipment/batch-out` - - 여러 장비 동시 출고 기능 - - 트랜잭션 처리 필요 - -### 높음 (주요 기능) -4. **회사-지점 통합 조회** - `GET /api/v1/companies/with-branches` - - 출고 시 회사/지점 선택에 필요 - - 기존 API 확장으로 구현 가능 - -5. **비밀번호 재설정** - `POST /api/v1/auth/reset-password` - - 사용자 편의성 개선 - - 이메일 서비스 연동 필요 - -6. **창고 위치 검색** - `GET /api/v1/warehouse-locations/search` - - 창고 위치 빠른 검색 - - 기존 검색 패턴 활용 - -### 보통 (부가 기능) -7. **통계 요약 API 통합** - `GET /api/v1/statistics/summary` - - 현재 `/overview/stats`로 대체 가능 - - API 일관성을 위해 별칭 추가 권장 - -8. **사용자 지점 정보** - `GET /api/v1/users/{id}/branch` - - 사용자 상세 조회에 이미 포함됨 - - 별도 엔드포인트 불필요 - -## 🔧 즉시 수정 필요 사항 - -### 1. 장비 입출고 API 타입 오류 -**파일**: `/src/handlers/equipment.rs` - -```rust -// 현재 (오류) -pub async fn handle_equipment_in( - db: web::Data, // ❌ DbConn 타입 없음 - claims: web::ReqData, // ❌ TokenClaims 타입 없음 - // ... -) - -// 수정 필요 -pub async fn handle_equipment_in( - db: web::Data, // ✅ - claims: web::ReqData, // ✅ - // ... -) -``` - -### 2. 플러터-백엔드 권한 레벨 매핑 -**이슈**: Flutter는 'S'(관리자), 'M'(일반)을 사용하지만 백엔드는 'admin', 'manager', 'staff', 'viewer' 사용 - -**해결방안**: -```dart -// Flutter 유틸리티 함수 추가 -String mapFlutterRoleToBackend(String flutterRole) { - switch (flutterRole) { - case 'S': return 'admin'; - case 'M': return 'staff'; - default: return 'viewer'; - } -} -``` - -### 3. API 응답 형식 일관성 -일부 API가 표준 응답 형식을 따르지 않음. 모든 API가 다음 형식을 따르도록 수정 필요: -```json -{ - "success": true, - "data": { ... }, - "meta": { ... } // 페이지네이션 시 -} -``` - -## 💡 추가 구현 제안사항 - -### 1. WebSocket 실시간 기능 -- 장비 상태 실시간 업데이트 -- 라이선스 만료 실시간 알림 -- 다중 사용자 동시 편집 방지 - -### 2. 배치 작업 스케줄러 -- 정기 백업 자동화 -- 라이선스 만료 알림 발송 -- 장비 점검 일정 알림 - -### 3. 모바일 전용 API -- 바코드 스캔 장비 조회 -- 오프라인 동기화 -- 푸시 알림 - -### 4. 고급 검색 기능 -- Elasticsearch 연동 -- 전문 검색 -- 필터 조합 저장 - -## 🛠️ 기술적 고려사항 - -### 1. 성능 최적화 -- N+1 쿼리 문제 해결 (eager loading) -- 응답 캐싱 구현 -- 페이지네이션 기본값 설정 - -### 2. 보안 강화 -- Rate limiting 구현됨 ✅ -- CORS 설정됨 ✅ -- SQL injection 방지됨 ✅ -- XSS 방지 헤더 추가됨 ✅ - -### 3. 문서화 -- OpenAPI 3.0 스펙 작성됨 ✅ -- Postman 컬렉션 생성 필요 -- API 버저닝 전략 수립 필요 - -## 📈 결론 - -SuperPort 백엔드 API는 전체적으로 매우 높은 수준으로 구현되어 있습니다. 기본 CRUD 기능뿐만 아니라 고급 기능들(대량 처리, 보고서 생성, 감사 로그, 백업 등)도 대부분 구현되어 있어 즉시 프로덕션 사용이 가능한 수준입니다. - -단, 프론트엔드와의 완전한 연동을 위해서는: -1. 장비 입출고 API의 타입 오류 수정 (긴급) -2. 자동완성을 위한 lookup API 추가 -3. 대량 출고 기능 구현 -4. Flutter 앱의 API 클라이언트 구현 - -이러한 작업이 완료되면 MockDataService를 실제 API 호출로 전환할 수 있습니다. \ No newline at end of file diff --git a/doc/API_Integration_Plan.md b/doc/API_Integration_Plan.md deleted file mode 100644 index c311b0b..0000000 --- a/doc/API_Integration_Plan.md +++ /dev/null @@ -1,1167 +0,0 @@ -# SuperPort API 통합 계획서 - -**작성일**: 2025-07-24 -**버전**: 1.0 -**작성자**: Claude - -## 목차 - -1. [개요](#1-개요) -2. [API 서버 분석](#2-api-서버-분석) -3. [현재 상태 분석](#3-현재-상태-분석) -4. [화면별 API 통합 계획](#4-화면별-api-통합-계획) -5. [기능별 구현 계획](#5-기능별-구현-계획) -6. [기술적 아키텍처](#6-기술적-아키텍처) -7. [구현 로드맵](#7-구현-로드맵) -8. [작업 Task 총정리](#8-작업-task-총정리) -9. [위험 관리](#9-위험-관리) - ---- - -## 1. 개요 - -### 1.1 프로젝트 배경 - -SuperPort는 현재 MockDataService를 사용하여 모든 데이터를 메모리에서 관리하고 있습니다. 이는 개발 초기 단계에서는 유용했지만, 실제 운영 환경에서는 다음과 같은 한계가 있습니다: - -- 데이터 영속성 부재 -- 다중 사용자 동시 접근 불가 -- 실시간 데이터 동기화 불가 -- 보안 및 인증 기능 부재 - -### 1.2 API 통합 목적 - -- **데이터 영속성**: PostgreSQL 데이터베이스를 통한 안정적인 데이터 저장 -- **다중 사용자 지원**: 동시 접근 및 실시간 업데이트 지원 -- **보안 강화**: JWT 기반 인증 및 역할 기반 접근 제어 -- **확장성**: 향후 기능 추가를 위한 견고한 백엔드 인프라 - -### 1.3 예상 효과 - -- 실제 운영 환경 배포 가능 -- 데이터 무결성 및 일관성 보장 -- 사용자별 권한 관리 -- 감사 로그 및 이력 추적 -- 대용량 데이터 처리 능력 - ---- - -## 2. API 서버 분석 - -### 2.1 기술 스택 - -- **언어**: Rust -- **프레임워크**: Actix-web 4.5 -- **ORM**: SeaORM -- **데이터베이스**: PostgreSQL 16 -- **인증**: JWT (Access + Refresh Token) - -### 2.2 인증 방식 - -```json -// 로그인 요청 -POST /api/v1/auth/login -{ - "username": "user@example.com", - "password": "password123" -} - -// 응답 -{ - "access_token": "eyJ0eXAiOiJKV1Q...", - "refresh_token": "eyJ0eXAiOiJKV1Q...", - "token_type": "Bearer", - "expires_in": 3600 -} -``` - -### 2.3 주요 엔드포인트 - -| 기능 | 메서드 | 경로 | 설명 | -|------|--------|------|------| -| **인증** | -| 로그인 | POST | /auth/login | 사용자 인증 | -| 로그아웃 | POST | /auth/logout | 세션 종료 | -| 토큰 갱신 | POST | /auth/refresh | 액세스 토큰 갱신 | -| **장비** | -| 목록 조회 | GET | /equipment | 페이징, 필터, 정렬 지원 | -| 상세 조회 | GET | /equipment/{id} | 장비 상세 정보 | -| 생성 | POST | /equipment | 새 장비 등록 | -| 수정 | PUT | /equipment/{id} | 장비 정보 수정 | -| 삭제 | DELETE | /equipment/{id} | 장비 삭제 | -| 입고 | POST | /equipment/in | 장비 입고 처리 | -| 출고 | POST | /equipment/out | 장비 출고 처리 | -| **회사** | -| 목록 조회 | GET | /companies | 회사 목록 | -| 지점 조회 | GET | /companies/{id}/branches | 회사별 지점 | - -### 2.4 데이터 모델 매핑 - -| Flutter 모델 | API DTO | 변경사항 | -|--------------|---------|----------| -| Equipment | EquipmentDto | category 구조 변경 | -| Company | CompanyDto | branch 관계 추가 | -| User | UserDto | role → role_type | -| License | LicenseDto | 완전 일치 | - ---- - -## 3. 현재 상태 분석 - -### 3.1 MockDataService 구조 - -현재 MockDataService는 다음과 같은 구조로 되어 있습니다: - -```dart -class MockDataService { - static final MockDataService _instance = MockDataService._internal(); - - // 메모리 저장소 - final List _equipments = []; - final List _companies = []; - - // 동기적 메서드 - List getEquipments() => _equipments; - void addEquipment(Equipment equipment) => _equipments.add(equipment); -} -``` - -### 3.2 변경 필요 사항 - -1. **비동기 처리**: 모든 메서드를 Future 반환으로 변경 -2. **에러 처리**: try-catch 및 에러 상태 관리 -3. **로딩 상태**: 데이터 페칭 중 로딩 인디케이터 -4. **캐싱**: 불필요한 API 호출 최소화 - -### 3.3 컨트롤러 패턴 개선 - -```dart -// 현재 -class EquipmentController { - List get equipments => MockDataService().getEquipments(); -} - -// 개선 후 -class EquipmentController extends ChangeNotifier { - List _equipments = []; - bool _isLoading = false; - String? _error; - - Future loadEquipments() async { - _isLoading = true; - notifyListeners(); - - try { - _equipments = await _apiService.getEquipments(); - _error = null; - } catch (e) { - _error = e.toString(); - } finally { - _isLoading = false; - notifyListeners(); - } - } -} -``` - ---- - -## 4. 화면별 API 통합 계획 - -### 4.1 로그인 화면 - -**사용 API 엔드포인트**: -- POST /api/v1/auth/login -- POST /api/v1/auth/refresh - -**작업 Task**: -- [x] AuthService 클래스 생성 -- [x] JWT 토큰 저장/관리 로직 구현 - - [x] SecureStorage 설정 - - [x] Access Token 저장 - - [x] Refresh Token 저장 -- [x] 로그인 폼 검증 추가 - - [x] 이메일 형식 검증 - - [x] 비밀번호 최소 길이 검증 -- [x] 로그인 실패 에러 처리 - - [x] 401: 잘못된 인증 정보 - - [ ] 429: 너무 많은 시도 - - [x] 500: 서버 오류 -- [x] 자동 로그인 구현 - - [x] 토큰 유효성 검사 - - [x] 토큰 자동 갱신 -- [x] 로그아웃 기능 구현 - -### 4.2 대시보드 - -**사용 API 엔드포인트**: -- GET /api/v1/overview/stats -- GET /api/v1/overview/recent-activities -- GET /api/v1/equipment/status-distribution -- GET /api/v1/licenses/expiring-soon - -**작업 Task**: -- [x] DashboardService 생성 -- [x] 통계 데이터 모델 생성 - - [x] OverviewStats DTO - - [x] RecentActivity DTO - - [x] StatusDistribution DTO (EquipmentStatusDistribution) - - [x] ExpiringLicense DTO -- [x] DashboardController 비동기화 - - [x] 동시 다중 API 호출 구현 - - [x] 부분 로딩 상태 관리 -- [ ] 실시간 업데이트 구현 - - [ ] WebSocket 연결 설정 - - [ ] 실시간 이벤트 수신 -- [ ] 캐싱 전략 구현 - - [ ] 5분 캐시 TTL - - [ ] Pull-to-refresh 구현 -- [x] 에러 시 부분 렌더링 - -### 4.3 장비 목록 - -**사용 API 엔드포인트**: -- GET /api/v1/equipment?page=1&limit=20&sort=created_at&order=desc -- GET /api/v1/equipment/categories -- GET /api/v1/companies/names - -**작업 Task**: -- [x] EquipmentService 생성 -- [x] 페이지네이션 구현 - - [x] 무한 스크롤 구현 - - [x] 페이지 상태 관리 - - [x] 로딩 인디케이터 -- [x] 필터링 기능 - - [x] 카테고리별 필터 - - [x] 상태별 필터 - - [x] 회사별 필터 - - [x] 날짜 범위 필터 -- [x] 정렬 기능 - - [x] 생성일 정렬 - - [x] 이름 정렬 - - [x] 상태 정렬 -- [x] 검색 기능 - - [x] 디바운싱 구현 - - [ ] 검색 결과 하이라이트 -- [ ] 일괄 작업 - - [ ] 다중 선택 UI - - [ ] 일괄 삭제 - - [ ] 일괄 상태 변경 - -### 4.4 장비 상세/편집 - -**사용 API 엔드포인트**: -- GET /api/v1/equipment/{id} -- PUT /api/v1/equipment/{id} -- GET /api/v1/equipment/{id}/history -- POST /api/v1/files/upload - -**작업 Task**: -- [x] 상세 정보 로딩 - - [x] 기본 정보 표시 - - [x] 이력 정보 로딩 - - [ ] 관련 문서 표시 -- [x] 편집 모드 구현 - - [x] 폼 데이터 바인딩 - - [x] 실시간 검증 - - [x] 변경사항 추적 -- [ ] 이미지 업로드 - - [ ] 파일 선택 UI - - [ ] 업로드 진행률 - - [ ] 썸네일 생성 -- [x] 히스토리 표시 - - [x] 타임라인 UI - - [x] 상태 변경 이력 - - [x] 담당자 정보 - -### 4.5 장비 입고 - -**사용 API 엔드포인트**: -- POST /api/v1/equipment/in -- GET /api/v1/warehouse-locations -- GET /api/v1/equipment/serial-check/{serial} - -**작업 Task**: -- [x] 입고 폼 구현 - - [x] 장비 정보 입력 - - [x] 시리얼 번호 중복 검사 - - [x] 창고 위치 선택 -- [ ] 바코드 스캔 통합 - - [ ] 카메라 권한 요청 - - [ ] 바코드 디코딩 - - [ ] 자동 필드 채우기 -- [ ] 일괄 입고 - - [ ] CSV 파일 업로드 - - [ ] 데이터 검증 - - [ ] 진행률 표시 -- [ ] 입고증 생성 - - [ ] PDF 생성 - - [ ] 이메일 전송 - -### 4.6 장비 출고 - -**사용 API 엔드포인트**: -- POST /api/v1/equipment/out -- GET /api/v1/equipment/available -- GET /api/v1/customers - -**작업 Task**: -- [x] 출고 폼 구현 - - [x] 가용 장비 조회 - - [x] 수량 검증 - - [x] 고객 정보 입력 -- [ ] 출고 승인 프로세스 - - [ ] 승인 요청 - - [ ] 승인자 알림 - - [ ] 승인 이력 -- [ ] 출고 문서 - - [ ] 출고증 생성 - - [ ] 전자 서명 - - [ ] 문서 보관 - -### 4.7 회사 관리 - -**사용 API 엔드포인트**: -- GET /api/v1/companies -- POST /api/v1/companies -- PUT /api/v1/companies/{id} -- GET /api/v1/companies/{id}/branches -- POST /api/v1/companies/{id}/branches - -**작업 Task**: -- [x] CompanyService 생성 -- [x] DTO 모델 생성 - - [x] CompanyDto (생성/수정/응답) - - [x] CompanyListDto (목록 조회) - - [x] BranchDto (지점 관련) -- [x] CompanyRemoteDataSource 구현 - - [x] 모든 CRUD 메서드 구현 - - [x] 지점 관련 API 메서드 구현 -- [x] DI 등록 (CompanyRemoteDataSource, CompanyService) -- [x] 회사 목록 구현 - - [x] Controller API 연동 - - [x] 본사/지점 트리 구조 - - [ ] 확장/축소 UI - - [x] 검색 필터 -- [x] 회사 등록 - - [x] Controller API 연동 - - [ ] 사업자번호 검증 - - [ ] 주소 검색 API 연동 - - [x] 중복 확인 -- [x] 지점 관리 - - [x] 지점 추가/편집 - - [ ] 지점별 권한 설정 - - [ ] 지점 이전 기능 -- [ ] 회사 통계 - - [ ] 장비 보유 현황 - - [ ] 라이선스 현황 - - [ ] 사용자 현황 - -### 4.8 사용자 관리 - -**사용 API 엔드포인트**: -- GET /api/v1/users -- POST /api/v1/users -- PUT /api/v1/users/{id} -- PATCH /api/v1/users/{id}/status -- POST /api/v1/users/{id}/reset-password - -**작업 Task**: -- [x] 사용자 목록 - - [x] 역할별 필터 - - [x] 회사별 필터 - - [x] 상태별 표시 -- [x] 사용자 등록 - - [x] 이메일 중복 확인 - - [x] 임시 비밀번호 생성 - - [ ] 환영 이메일 발송 -- [x] 권한 관리 - - [x] 역할 선택 UI - - [ ] 권한 미리보기 - - [ ] 권한 변경 이력 -- [ ] 비밀번호 관리 - - [ ] 비밀번호 재설정 - - [ ] 강제 변경 설정 - - [ ] 비밀번호 정책 - -### 4.9 라이선스 관리 - -**사용 API 엔드포인트**: -- GET /api/v1/licenses -- POST /api/v1/licenses -- PUT /api/v1/licenses/{id} -- DELETE /api/v1/licenses/{id} -- PATCH /api/v1/licenses/{id}/assign -- PATCH /api/v1/licenses/{id}/unassign -- GET /api/v1/licenses/expiring?days=30 - -**작업 Task**: -- [x] 라이선스 목록 - - [x] 페이지네이션 구현 - - [x] 활성/비활성 필터 - - [x] 회사별 필터 - - [x] 검색 기능 -- [x] 라이선스 등록 - - [x] DTO 모델 매핑 - - [x] 방문주기를 remark 필드로 저장 - - [ ] 파일 첨부 -- [x] 라이선스 수정/삭제 - - [x] API 연동 완료 - - [x] 에러 처리 -- [x] 만료 예정 조회 - - [x] getExpiringLicenses 구현 - - [x] 일수 파라미터 지원 -- [x] 라이선스 할당 - - [x] assignLicense/unassignLicense 구현 - - [x] 사용자별 할당 관리 - -### 4.10 창고 관리 - -**사용 API 엔드포인트**: -- GET /api/v1/warehouse-locations -- POST /api/v1/warehouse-locations -- GET /api/v1/warehouse-locations/{id} -- PUT /api/v1/warehouse-locations/{id} -- DELETE /api/v1/warehouse-locations/{id} -- GET /api/v1/warehouse-locations/{id}/equipment -- GET /api/v1/warehouse-locations/{id}/capacity -- GET /api/v1/warehouse-locations/in-use - -**작업 Task**: -- [x] 창고 목록 - - [x] 페이지네이션 구현 - - [x] 활성/비활성 필터 - - [x] 검색 기능 (이름, 주소) - - [x] 사용 중인 창고 조회 -- [x] 창고 등록 - - [x] DTO 모델 매핑 - - [x] 주소 정보 변환 - - [x] 담당자명을 remark로 저장 -- [x] 창고 수정/삭제 - - [x] API 연동 완료 - - [x] 에러 처리 -- [x] 창고별 장비 조회 - - [x] getWarehouseEquipment 구현 - - [x] 페이지네이션 지원 -- [x] 창고 용량 조회 - - [x] getWarehouseCapacity 구현 - - [x] 사용률 계산 - -### 4.11 보고서 - -**사용 API 엔드포인트**: -- GET /api/v1/reports/equipment-status -- GET /api/v1/reports/license-summary -- POST /api/v1/reports/export/excel -- POST /api/v1/reports/export/pdf - -**작업 Task**: -- [ ] 보고서 템플릿 - - [ ] 장비 현황 보고서 - - [ ] 라이선스 보고서 - - [ ] 사용자 활동 보고서 -- [ ] 보고서 생성 - - [ ] 기간 선택 - - [ ] 필터 옵션 - - [ ] 미리보기 -- [ ] 내보내기 - - [ ] Excel 다운로드 - - [ ] PDF 다운로드 - - [ ] 이메일 전송 -- [ ] 정기 보고서 - - [ ] 스케줄 설정 - - [ ] 자동 생성 - - [ ] 수신자 관리 - ---- - -## 5. 기능별 구현 계획 - -### 5.1 인증/인가 시스템 - -**구현 내용**: -- JWT 토큰 관리 서비스 -- 자동 토큰 갱신 인터셉터 -- 역할 기반 라우트 가드 -- 세션 타임아웃 처리 - -**작업 Task**: -- [x] AuthService 구현 - - [x] 로그인/로그아웃 - - [x] 토큰 저장/조회 - - [x] 토큰 갱신 로직 -- [x] AuthInterceptor 구현 - - [x] 요청 헤더 토큰 추가 - - [x] 401 에러 처리 - - [x] 토큰 갱신 재시도 -- [ ] RouteGuard 구현 - - [ ] 인증 확인 - - [ ] 권한 확인 - - [ ] 리다이렉트 처리 - -### 5.2 네트워크 레이어 - -**구현 내용**: -- Dio 클라이언트 설정 -- API 엔드포인트 관리 -- 에러 처리 표준화 -- 요청/응답 로깅 - -**작업 Task**: -- [x] ApiClient 싱글톤 구현 -- [ ] BaseApiService 추상 클래스 -- [x] 환경별 설정 관리 -- [x] 에러 핸들링 유틸 (exceptions.dart, failures.dart 구현됨) -- [ ] 네트워크 연결 확인 - -### 5.3 상태 관리 - -**구현 내용**: -- Repository 패턴 도입 -- 데이터 캐싱 전략 -- 옵티미스틱 업데이트 -- 상태 동기화 - -**작업 Task**: -- [ ] Repository 인터페이스 정의 -- [ ] 캐시 매니저 구현 -- [ ] 상태 업데이트 로직 -- [ ] 충돌 해결 전략 - -### 5.4 파일 업로드/다운로드 - -**구현 내용**: -- Multipart 파일 업로드 -- 진행률 표시 -- 파일 다운로드 관리 -- 오프라인 파일 캐싱 - -**작업 Task**: -- [ ] FileService 구현 -- [ ] 업로드 큐 관리 -- [ ] 다운로드 매니저 -- [ ] 파일 캐시 정책 - -### 5.5 실시간 기능 - -**구현 내용**: -- WebSocket 연결 관리 -- 실시간 이벤트 처리 -- 자동 재연결 -- 이벤트 필터링 - -**작업 Task**: -- [ ] WebSocketService 구현 -- [ ] 이벤트 리스너 관리 -- [ ] 재연결 로직 -- [ ] 이벤트 라우팅 - -### 5.6 오프라인 지원 - -**구현 내용**: -- 로컬 데이터베이스 (SQLite) -- 동기화 큐 관리 -- 충돌 해결 -- 오프라인 모드 UI - -**작업 Task**: -- [ ] 로컬 DB 스키마 설계 -- [ ] 동기화 서비스 구현 -- [ ] 충돌 해결 UI -- [ ] 오프라인 인디케이터 - ---- - -## 6. 기술적 아키텍처 - -### 6.1 새로운 디렉토리 구조 ✅ - -``` -lib/ -├── core/ ✅ -│ ├── config/ ✅ -│ │ └── environment.dart ✅ -│ ├── constants/ ✅ -│ │ ├── api_endpoints.dart ✅ -│ │ └── app_constants.dart ✅ -│ ├── errors/ ✅ -│ │ ├── exceptions.dart ✅ -│ │ └── failures.dart ✅ -│ └── utils/ ✅ -│ ├── validators.dart ✅ -│ └── formatters.dart ✅ -├── data/ ✅ -│ ├── datasources/ ✅ -│ │ ├── local/ ✅ -│ │ │ └── cache_datasource.dart -│ │ └── remote/ ✅ -│ │ ├── api_client.dart ✅ -│ │ ├── interceptors/ ✅ -│ │ │ ├── auth_interceptor.dart ✅ -│ │ │ ├── error_interceptor.dart ✅ -│ │ │ └── logging_interceptor.dart ✅ -│ │ ├── auth_remote_datasource.dart -│ │ └── equipment_remote_datasource.dart -│ ├── models/ ✅ -│ │ ├── auth/ -│ │ ├── equipment/ -│ │ └── common/ -│ └── repositories/ ✅ -│ ├── auth_repository_impl.dart -│ └── equipment_repository_impl.dart -├── di/ ✅ -│ └── injection_container.dart ✅ -├── domain/ -│ ├── entities/ -│ ├── repositories/ -│ └── usecases/ -├── presentation/ -│ ├── controllers/ -│ ├── screens/ -│ └── widgets/ -└── main.dart -``` - -### 6.2 의존성 주입 ✅ - -```dart -// GetIt을 사용한 DI 설정 ✅ -final getIt = GetIt.instance; - -Future setupDependencies() async { - // 환경 초기화 ✅ - await Environment.initialize(); - - // 네트워크 ✅ - getIt.registerLazySingleton(() => Dio()); - getIt.registerLazySingleton(() => const FlutterSecureStorage()); - - // API 클라이언트 ✅ - getIt.registerLazySingleton(() => ApiClient()); - - // 데이터소스 ✅ - getIt.registerLazySingleton(() => AuthRemoteDataSource()); - getIt.registerLazySingleton(() => DashboardRemoteDataSource()); - getIt.registerLazySingleton(() => EquipmentRemoteDataSource()); - getIt.registerLazySingleton(() => CompanyRemoteDataSource()); - - // 서비스 ✅ - getIt.registerLazySingleton(() => AuthService()); - getIt.registerLazySingleton(() => DashboardService()); - getIt.registerLazySingleton(() => EquipmentService()); - getIt.registerLazySingleton(() => CompanyService()); - - // 컨트롤러 - // TODO: Controllers will be registered here -} -``` - -### 6.3 API 클라이언트 설계 ✅ - -```dart -// ApiClient 클래스 구현됨 (Retrofit 대신 순수 Dio 사용) -class ApiClient { - late final Dio _dio; - static final ApiClient _instance = ApiClient._internal(); - - factory ApiClient() => _instance; - - ApiClient._internal() { - _dio = Dio(_baseOptions); - _setupInterceptors(); - } - - // GET, POST, PUT, PATCH, DELETE 메서드 구현됨 - // 파일 업로드/다운로드 메서드 구현됨 - // 인터셉터 설정 완료 (Auth, Error, Logging) -} -``` - -### 6.4 에러 처리 표준화 - -```dart -class ApiException implements Exception { - final String message; - final int? statusCode; - final Map? errors; - - ApiException({ - required this.message, - this.statusCode, - this.errors, - }); -} - -class ErrorHandler { - static String getErrorMessage(dynamic error) { - if (error is DioError) { - switch (error.type) { - case DioErrorType.connectTimeout: - return "연결 시간이 초과되었습니다"; - case DioErrorType.other: - if (error.error is SocketException) { - return "인터넷 연결을 확인해주세요"; - } - break; - default: - return "알 수 없는 오류가 발생했습니다"; - } - } - return error.toString(); - } -} -``` - ---- - -## 7. 구현 로드맵 - -### 7.1 Phase 1: 기초 인프라 (3주) - -**1주차: 네트워크 레이어** -- [x] Dio 설정 및 인터셉터 구현 -- [x] API 클라이언트 기본 구조 -- [x] 에러 처리 프레임워크 -- [x] 환경 설정 관리 - -**2주차: 인증 시스템** *(2025-07-24 진행)* -- [x] AuthService 구현 -- [x] 토큰 관리 로직 -- [x] 로그인/로그아웃 화면 연동 -- [x] 자동 토큰 갱신 - -**3주차: 기본 데이터 레이어** -- [ ] Repository 패턴 구현 -- [x] 기본 모델 변환 -- [x] 첫 화면(대시보드) API 연동 -- [x] 로딩/에러 상태 관리 - -### 7.2 Phase 2: 핵심 기능 (4주) - -**4-5주차: 장비 관리** -- [x] 장비 목록/상세 API 연동 -- [x] 입출고 프로세스 구현 -- [x] 검색/필터/정렬 기능 -- [ ] 이미지 업로드 - -**6-7주차: 회사/사용자 관리** -- [x] 회사 CRUD 구현 (Service/DataSource 완료, Controller 연동 필요) -- [ ] 지점 관리 기능 -- [ ] 사용자 관리 및 권한 -- [ ] 프로필 관리 - -### 7.3 Phase 3: 고급 기능 (3주) - -**8주차: 실시간 기능** -- [ ] WebSocket 연결 구현 -- [ ] 실시간 알림 -- [ ] 대시보드 실시간 업데이트 -- [ ] 이벤트 처리 - -**9주차: 오프라인 지원** -- [ ] 로컬 데이터베이스 설정 -- [ ] 동기화 로직 구현 -- [ ] 오프라인 모드 UI -- [ ] 충돌 해결 - -**10주차: 보고서 및 파일** -- [ ] 보고서 생성 기능 -- [ ] Excel/PDF 다운로드 -- [ ] 파일 관리 시스템 -- [ ] 대량 데이터 처리 - -### 7.4 Phase 4: 최적화 및 마무리 (2주) - -**11주차: 성능 최적화** -- [ ] API 호출 최적화 -- [ ] 캐싱 전략 개선 -- [ ] 이미지 최적화 -- [ ] 번들 크기 최적화 - -**12주차: 테스트 및 배포** -- [ ] 통합 테스트 -- [ ] 사용자 승인 테스트 -- [ ] 배포 준비 -- [ ] 문서화 - ---- - -## 8. 작업 Task 총정리 - -### 8.1 우선순위별 분류 - -#### 🔴 Critical (필수) -1. [x] API 클라이언트 설정 -2. [x] 인증 시스템 구현 -3. [x] 기본 CRUD 기능 (장비, 회사 완료) -4. [x] 에러 처리 -5. [x] 로딩 상태 관리 - -#### 🟡 High (중요) -6. [x] 페이지네이션 -7. [x] 검색/필터 (장비 완료) -8. [ ] 파일 업로드 -9. [ ] 권한 관리 -10. [x] 데이터 검증 - -#### 🟢 Medium (개선) -11. [ ] 캐싱 -12. [ ] 실시간 업데이트 -13. [ ] 오프라인 지원 -14. [ ] 보고서 생성 -15. [ ] 성능 최적화 - -#### 🔵 Low (선택) -16. [ ] 다국어 개선 -17. [ ] 테마 커스터마이징 -18. [ ] 애니메이션 -19. [ ] 단축키 -20. [ ] 고급 필터 - -### 8.2 예상 소요 시간 - -| 작업 카테고리 | 예상 시간 | 담당자 제안 | -|--------------|-----------|-------------| -| 네트워크 인프라 | 40시간 | 백엔드 경험자 | -| 인증 시스템 | 24시간 | 보안 전문가 | -| 화면별 API 연동 | 120시간 | 프론트엔드 개발자 | -| 상태 관리 | 32시간 | Flutter 전문가 | -| 테스트 | 40시간 | QA 엔지니어 | -| 문서화 | 16시간 | 기술 문서 작성자 | -| **총계** | **272시간** | 약 7주 (1인 기준) | - -### 8.3 체크리스트 - -#### 개발 환경 설정 -- [ ] API 서버 접속 정보 확인 -- [x] 개발/스테이징/운영 환경 구분 -- [x] 필요 패키지 설치 -- [ ] Git 브랜치 전략 수립 - -#### 코드 품질 -- [ ] 코드 리뷰 프로세스 -- [ ] 린트 규칙 설정 -- [ ] 테스트 커버리지 목표 -- [ ] CI/CD 파이프라인 - -#### 보안 -- [x] 토큰 안전 저장 (SecureStorage 사용) -- [x] API 키 관리 (환경 변수 사용) -- [ ] 민감 정보 마스킹 -- [ ] 보안 감사 - ---- - -## 9. 위험 관리 - -### 9.1 기술적 위험 - -#### API 응답 지연 -- **위험**: 느린 네트워크로 인한 UX 저하 -- **대응**: - - 로딩 스켈레톤 UI - - 요청 취소 기능 - - 타임아웃 설정 - -#### 토큰 만료 처리 -- **위험**: 작업 중 토큰 만료로 인한 데이터 손실 -- **대응**: - - 자동 토큰 갱신 - - 작업 중 데이터 임시 저장 - - 재인증 플로우 - -#### 대용량 데이터 처리 -- **위험**: 많은 데이터로 인한 앱 멈춤 -- **대응**: - - 페이지네이션 필수 적용 - - 가상 스크롤 구현 - - 데이터 스트리밍 - -### 9.2 비즈니스 위험 - -#### 기존 데이터 마이그레이션 -- **위험**: Mock 데이터와 실제 데이터 불일치 -- **대응**: - - 데이터 매핑 문서화 - - 단계적 마이그레이션 - - 데이터 검증 도구 - -#### 사용자 교육 -- **위험**: 새로운 인증 절차에 대한 거부감 -- **대응**: - - 사용자 가이드 제작 - - 단계적 롤아웃 - - 피드백 수집 - -### 9.3 롤백 계획 - -1. **Feature Flag 사용** - - API/Mock 모드 전환 가능 - - 화면별 점진적 적용 - -2. **데이터 백업** - - 마이그레이션 전 전체 백업 - - 롤백 스크립트 준비 - -3. **버전 관리** - - 이전 버전 APK/IPA 보관 - - 긴급 패치 프로세스 - ---- - -## 📌 맺음말 - -이 문서는 SuperPort 프로젝트의 API 통합을 위한 상세한 계획서입니다. 각 팀원은 담당 파트의 체크리스트를 활용하여 진행 상황을 추적하고, 주간 회의에서 진행률을 공유하시기 바랍니다. - -성공적인 API 통합을 위해서는 팀원 간의 긴밀한 협업과 지속적인 커뮤니케이션이 필수적입니다. 문제가 발생하면 즉시 공유하고 함께 해결책을 찾아나가겠습니다. - -**문서 업데이트**: 이 문서는 프로젝트 진행에 따라 지속적으로 업데이트됩니다. - ---- - -## 🔄 구현 진행 상황 (2025-07-24) - -### 🎯 완료된 작업 - -#### 1차 작업 (2025-07-24 오전) -1. **Auth 관련 DTO 모델 생성** - - LoginRequest, LoginResponse, TokenResponse, RefreshTokenRequest - - AuthUser, LogoutRequest - - Freezed 패키지 적용 및 코드 생성 완료 - -2. **AuthRemoteDataSource 구현** - - login, logout, refreshToken 메서드 구현 - - 에러 처리 및 응답 변환 로직 완료 - -3. **AuthService 구현** - - 토큰 저장/관리 (SecureStorage 사용) - - 로그인 상태 관리 및 스트림 - - 자동 토큰 갱신 준비 - -4. **로그인 화면 API 연동** - - LoginController 수정 (API 호출 로직 추가) - - 이메일 형식 검증 및 에러 메시지 표시 - - 로딩 상태 관리 - -5. **의존성 주입 설정** - - AuthRemoteDataSource, AuthService DI 등록 - - GetIt을 통한 의존성 관리 - -#### 2차 작업 (2025-07-24 오후) -6. **자동 로그인 구현 ✅** - - main.dart에 FutureBuilder를 사용하여 토큰 확인 - - 유효한 토큰이 있으면 홈 화면, 없으면 로그인 화면으로 라우팅 - - LoginScreen에서 로그인 성공 시 pushNamedAndRemoveUntil 사용 - -7. **AuthInterceptor 개선 ✅** - - AuthService를 DI로 주입받도록 변경 - - 토큰 가져오기, 갱신, 삭제 로직을 AuthService로 일원화 - - 401 에러 시 자동 토큰 갱신 및 재시도 로직 개선 - -8. **로그아웃 기능 개선 ✅** - - AppLayoutRedesign에 AuthService import 추가 - - 로그아웃 버튼 클릭 시 AuthService.logout() 호출 - - 로딩 다이얼로그 및 에러 처리 추가 - -9. **대시보드 API 연동 ✅** - - **DTO 모델 생성**: OverviewStats, RecentActivity, EquipmentStatusDistribution, ExpiringLicense - - **DashboardRemoteDataSource 구현**: 모든 API 엔드포인트 연동 - - **DashboardService 구현**: 비즈니스 로직 처리 - - **OverviewController 개선**: ChangeNotifier 패턴으로 변경, API 사용 - - **OverviewScreenRedesign 수정**: Provider 패턴 적용, 로딩/에러 상태 처리 - - **DI 등록**: DashboardRemoteDataSource, DashboardService 등록 - -10. **API 서버 설정 ✅** - - .env 파일 생성 및 환경 변수 설정 - - JWT 비밀키 및 데이터베이스 연결 정보 설정 - -### 📦 다음 작업 -1. **API 서버 실행 및 테스트** - - Docker Compose로 PostgreSQL, Redis 실행 - - cargo run으로 API 서버 실행 - - Flutter 앱과 연동 테스트 - -2. **장비 관리 API 연동** ✅ - - EquipmentDTO 모델 생성 ✅ - - EquipmentRemoteDataSource 구현 ✅ - - EquipmentService 생성 ✅ - - 장비 목록/상세/입고/출고/수정/삭제/이력 화면 API 연동 ✅ - -3. **회사/사용자 관리 API 연동** - - CompanyService, UserService 구현 - - 각 화면 API 연동 - -4. **성능 최적화** - - 캐싱 전략 구현 - - 페이지네이션 및 무한 스크롤 - - 이미지 로딩 최적화 - -#### 3차 작업 (2025-07-24 저녁) -11. **장비 관리 API 연동 ✅** - - **DTO 모델 생성**: equipment 관련 모든 DTO 모델 생성 및 Freezed 코드 생성 완료 - - **EquipmentRemoteDataSource 구현**: 10개의 API 엔드포인트 메서드 구현 - - **EquipmentService 구현**: 비즈니스 로직 및 모델 변환 처리 - - **Controller 개선**: ChangeNotifier 패턴 적용, API/Mock 전환 가능 - - **화면 연동**: 장비 목록, 장비 입고 화면 Provider 패턴 적용 - - **DI 등록**: EquipmentRemoteDataSource, EquipmentService 등록 - -12. **무한 스크롤 구현 ✅** - - 장비 목록 화면에 무한 스크롤 지원 추가 - - ScrollController 리스너를 통한 페이지네이션 - -### 📈 진행률 -- **전체 API 통합**: 100% 완료 ✅ -- **인증 시스템**: 100% 완료 -- **대시보드**: 100% 완료 -- **장비 관리**: 100% 완료 (목록, 입고, 출고, 수정, 삭제, 이력 조회 모두 완료) -- **회사 관리**: 100% 완료 ✅ -- **사용자 관리**: 100% 완료 ✅ -- **라이선스 관리**: 100% 완료 ✅ -- **창고 관리**: 100% 완료 ✅ - -### 📋 주요 특징 -- **한글 입력**: 모든 API 요청/응답에서 UTF-8 인코딩 적용 -- **사이드 이펙트 방지**: MockDataService와 API 서비스 공존 가능 (Feature Flag) -- **에러 처리**: 네트워크 오류, 서버 오류, 인증 오류 분리 처리 -- **무한 스크롤**: 대용량 데이터 처리를 위한 페이지네이션 -- **로딩/에러 상태**: 사용자 친화적인 UI 피드백 - -#### 4차 작업 (2025-07-24 밤) -13. **회사 관리 API 연동 (진행중)** - - **DTO 모델 생성**: CompanyDto, CompanyListDto, BranchDto 모델 생성 및 Freezed 코드 생성 완료 - - **CompanyRemoteDataSource 구현**: 회사 CRUD 및 지점 관련 API 엔드포인트 메서드 구현 - - **CompanyService 구현**: 비즈니스 로직 및 DTO-Model 변환 처리 - - **DI 등록**: CompanyRemoteDataSource, CompanyService 등록 완료 - - **Controller 준비**: CompanyFormController에 API 사용을 위한 준비 완료 (실제 구현 대기) - - **미완료**: Controller에서 실제 API 호출 구현, 로딩/에러 상태 관리 - -#### 5차 작업 (2025-07-24 새벽) -14. **회사 관리 API 연동 완료** ✅ - - **CompanyListController 생성**: ChangeNotifier 패턴으로 회사 목록 관리 - - **CompanyListRedesign 화면 개선**: Provider 패턴 적용, API 연동 완료 - - **무한 스크롤 구현**: 페이지네이션 및 스크롤 기반 데이터 로딩 - - **검색 기능 구현**: 실시간 검색 (디바운싱 적용) - - **중복 회사명 체크**: API를 통한 실시간 중복 확인 - - **지점 저장 로직**: CompanyFormController에 saveBranch 메서드 추가 - - **에러 처리 및 로딩 상태**: 사용자 친화적인 UI 피드백 구현 - -#### 6차 작업 (2025-07-24) -15. **회사/지점 관리 API 완전 통합** ✅ - - **DTO 모델 완성**: company_dto.dart, branch_dto.dart, company_list_dto.dart - - **CompanyRemoteDataSource 완성**: - - 기본 CRUD + getCompaniesWithBranches, checkDuplicateCompany, searchCompanies, updateCompanyStatus - - 지점 관리 전체 API 메서드 구현 - - **CompanyService 개선**: - - @lazySingleton 적용으로 DI 패턴 개선 - - 페이지네이션 응답 처리 - - ApiException 사용으로 일관된 에러 처리 - - **기존 Controller 확인**: CompanyListController, CompanyFormController 모두 API 사용 가능 상태 - - **DI 설정 업데이트**: injection_container.dart에서 의존성 주입 완료 - -#### 7차 작업 (2025-07-24) -16. **사용자 관리 API 연동 완료** ✅ - - **DTO 모델 생성**: UserDto, UserListDto, CreateUserDto, UpdateUserDto 모델 생성 및 Freezed 코드 생성 - - **UserRemoteDataSource 구현**: - - 기본 CRUD + changeUserStatus, changePassword, checkDuplicateUsername, searchUsers - - 페이지네이션, 필터링, 검색 기능 포함 - - **UserService 구현**: - - @lazySingleton 적용으로 DI 패턴 구현 - - DTO-Model 변환 로직 (role 매핑 처리) - - 전화번호 변환 로직 (배열 → 단일 문자열) - - **UserListController 개선**: - - ChangeNotifier 패턴으로 변경 - - API/Mock 전환 가능한 Feature Flag - - 무한 스크롤 및 페이지네이션 구현 - - 검색, 필터링 (역할별, 상태별, 회사별) 기능 - - 사용자 상태 변경 기능 구현 - - **user_list_redesign.dart 개선**: - - Provider 패턴 적용 - - 무한 스크롤 구현 (ScrollController) - - 실시간 검색 (디바운싱 적용) - - 상태별 색상 표시 및 상태 변경 다이얼로그 - - **UserFormController 개선**: - - ChangeNotifier 패턴으로 변경 - - 사용자명 중복 확인 (디바운싱 적용) - - API를 통한 사용자 생성/수정 - - 비밀번호 처리 (신규: 필수, 수정: 선택) - - **user_form.dart 개선**: - - Provider 패턴 적용 - - 사용자명 필드 추가 (실시간 중복 확인) - - 비밀번호 필드 추가 (신규/수정 모드 구분) - - 비밀번호 보기/숨기기 토글 기능 - - **DI 설정 완료**: UserRemoteDataSource, UserService 등록 - -#### 8차 작업 (2025-07-24) -17. **라이선스 관리 API 연동 완료** ✅ - - **DTO 모델 수정**: API 서버 구조에 맞춰 LicenseDto, CreateLicenseRequest, UpdateLicenseRequest 재구성 - - Flutter 모델의 visitCycle을 remark 필드에 저장 - - durationMonths를 purchaseDate/expiryDate로 변환 - - **LicenseRemoteDataSource 구현**: - - 8개 엔드포인트 구현 (목록, 상세, 생성, 수정, 삭제, 할당, 할당해제, 만료예정) - - 페이지네이션 및 필터링 파라미터 지원 - - **LicenseService 구현**: - - @lazySingleton 적용으로 DI 패턴 구현 - - DTO-Model 변환 로직 (방문주기 추출, 기간 계산) - - 라이선스 할당/해제 기능 구현 - - **LicenseListController 개선**: - - ChangeNotifier 패턴으로 변경 - - API/Mock 전환 가능한 Feature Flag - - 무한 스크롤 및 페이지네이션 구현 - - 검색, 필터링 (활성상태, 회사, 타입) 기능 - - 만료 예정 라이선스 조회 기능 - - **LicenseFormController 개선**: - - ChangeNotifier 패턴으로 변경 - - API를 통한 라이선스 생성/수정 - - 유효성 검사 추가 - - **DI 설정 완료**: LicenseRemoteDataSource, LicenseService 등록 - -18. **창고 관리 API 연동 완료** ✅ - - **DTO 모델 생성**: WarehouseLocationDto, CreateWarehouseLocationRequest, UpdateWarehouseLocationRequest 등 - - 창고 용량 정보 (WarehouseCapacityInfo) - - 창고별 장비 목록 (WarehouseEquipmentDto) - - **WarehouseRemoteDataSource 구현**: - - 8개 엔드포인트 구현 (목록, 상세, 생성, 수정, 삭제, 장비목록, 용량조회, 사용중목록) - - 페이지네이션 지원 - - **WarehouseService 구현**: - - @lazySingleton 적용으로 DI 패턴 구현 - - DTO-Model 변환 로직 (주소 정보 매핑) - - 창고별 장비 및 용량 조회 기능 - - **WarehouseLocationListController 개선**: - - ChangeNotifier 패턴으로 변경 - - API/Mock 전환 가능한 Feature Flag - - 무한 스크롤 및 페이지네이션 구현 - - 검색 기능 (이름, 주소) - - 사용 중인 창고 조회 기능 - - **WarehouseLocationFormController 개선**: - - ChangeNotifier 패턴으로 변경 - - API를 통한 창고 위치 생성/수정 - - 주소 정보 관리 개선 - - **DI 설정 완료**: WarehouseRemoteDataSource, WarehouseService 등록 - -#### 9차 작업 (2025-07-25) -19. **라이선스 관리 API 추가 개선** - - **License 모델 확장**: - - 기존 단순 모델에서 API 호환 모델로 전면 개편 - - licenseKey, productName, vendor, userCount 등 필드 추가 - - 계산 필드 추가 (daysUntilExpiry, isExpired, status) - - 기존 코드 호환을 위한 getter 추가 (name, durationMonths, visitCycle) - - **LicenseService 개선**: - - 새로운 License 모델에 맞춰 DTO 변환 로직 수정 - - createLicense, updateLicense 메서드 간소화 - - **LicenseListController 추가 개선**: - - Environment.useApi 사용으로 Feature Flag 개선 - - 검색 디바운싱 추가 (300ms) - - 정렬 기능 추가 (sortBy, sortOrder) - - 상태별 라이선스 개수 조회 메서드 추가 - - dispose 메서드 추가 (타이머 정리) - - **작업 현황**: - - DTO 모델 생성 ✅ - - RemoteDataSource 구현 ✅ - - Service 구현 ✅ - - DI 설정 ✅ - - Controller 개선 ✅ - - 화면 Provider 패턴 적용 🔲 (다음 작업) - ---- - -_마지막 업데이트: 2025-07-25_ (라이선스 관리 API 개선 완료. 다음 목표: 화면 Provider 패턴 적용) \ No newline at end of file diff --git a/doc/API_Test_Guide.md b/doc/API_Test_Guide.md deleted file mode 100644 index 989209b..0000000 --- a/doc/API_Test_Guide.md +++ /dev/null @@ -1,90 +0,0 @@ -# API 연동 테스트 가이드 - -## 테스트 방법 - -### 1. 테스트 화면 접속 -```bash -# Flutter 웹 서버 실행 -flutter run -d chrome - -# 앱이 실행되면 다음 경로로 이동 -/test -``` - -### 2. 테스트 화면 사용법 - -테스트 화면에서는 다음과 같은 버튼들을 제공합니다: - -1. **초기 상태 확인**: 서비스 주입과 토큰 상태 확인 -2. **헬스체크 테스트**: API 서버 연결 확인 -3. **보호된 엔드포인트 테스트**: 인증이 필요한 API 테스트 -4. **로그인 테스트**: admin@superport.kr 계정으로 로그인 -5. **대시보드 테스트**: 대시보드 데이터 조회 및 장비 상태 코드 확인 -6. **장비 목록 테스트**: 장비 목록 조회 및 상태 코드 변환 확인 -7. **입고지 목록 테스트**: 입고지 목록 조회 -8. **회사 목록 테스트**: 회사 목록 조회 -9. **모든 테스트 실행**: 위 테스트들을 순차적으로 실행 -10. **토큰 삭제**: 저장된 인증 토큰 삭제 - -### 3. 주요 확인 사항 - -#### 장비 상태 코드 변환 -- 서버에서 반환하는 상태 코드: `available`, `inuse`, `maintenance`, `disposed` -- 클라이언트 표시 코드: `I`(입고), `T`(대여), `R`(수리), `D`(손상), `E`(기타) - -#### API 응답 형식 -- 모든 API 응답은 다음 형식으로 정규화됨: -```json -{ - "success": true, - "data": { ... } -} -``` - -### 4. 문제 해결 - -#### CORS 에러 발생 시 -```bash -# 프록시 서버를 통해 실행 -./run_web_with_proxy.sh -``` - -#### 인증 토큰 문제 -1. "토큰 삭제" 버튼 클릭 -2. "로그인 테스트" 재실행 -3. 다른 API 테스트 진행 - -#### 장비 상태 코드 불일치 -- `EquipmentStatusConverter` 클래스에서 매핑 확인 -- 서버 응답 로그에서 실제 반환되는 코드 확인 - -### 5. 디버그 로그 확인 - -터미널에서 다음 로그들을 확인: -- `[ApiClient]`: API 요청/응답 로그 -- `[ResponseInterceptor]`: 응답 정규화 로그 -- `[AuthInterceptor]`: 인증 처리 로그 -- `[ApiTest]`: 테스트 실행 로그 - -### 6. 예상 결과 - -정상 작동 시: -1. 로그인: 성공 (토큰 발급) -2. 대시보드: 장비 상태 분포에 `I`, `T`, `R`, `D` 등 표시 -3. 장비 목록: 상태 코드가 올바르게 변환되어 표시 -4. 입고지/회사: 정상 조회 - -## 현재 구현 상태 - -### 완료된 기능 -- ✅ 로그인 API 연동 -- ✅ 토큰 기반 인증 -- ✅ 응답 정규화 인터셉터 -- ✅ 장비 상태 코드 변환기 -- ✅ 에러 처리 인터셉터 - -### 테스트 필요 항목 -- 장비 상태 코드 변환 정확성 -- 대시보드 데이터 표시 -- 각 페이지별 API 호출 성공 여부 -- 에러 처리 적절성 \ No newline at end of file diff --git a/doc/Refactoring_Plan.md b/doc/Refactoring_Plan.md deleted file mode 100644 index ca8c291..0000000 --- a/doc/Refactoring_Plan.md +++ /dev/null @@ -1,279 +0,0 @@ -# SuperPort 프로젝트 리팩토링 계획 - -## 📋 개요 - -현재 SuperPort 프로젝트의 일부 파일들이 너무 커서 코드 가독성과 유지보수성이 떨어지는 문제가 있습니다. 이 문서는 대규모 파일들을 작은 단위로 분리하고, 중복 코드를 제거하여 코드베이스를 개선하기 위한 상세한 리팩토링 계획입니다. - -## 🎯 리팩토링 목표 - -1. **코드 가독성 향상**: 파일당 300줄 이하 유지 -2. **중복 코드 제거**: 반복되는 패턴을 재사용 가능한 컴포넌트로 추출 -3. **관심사 분리**: 각 파일이 단일 책임을 갖도록 분리 -4. **유지보수성 향상**: 기능별로 모듈화하여 수정 용이성 증대 -5. **재사용성 증대**: 공통 컴포넌트 및 유틸리티 함수 추출 - -## 📊 현재 상태 분석 - -### 문제가 되는 대형 파일들: - -1. **`lib/screens/equipment/equipment_in_form.dart`** (2,315줄) - - 7개의 드롭다운 필드에 대해 거의 동일한 코드 패턴 반복 - - 각 필드마다 별도의 오버레이, 컨트롤러, 포커스 노드 관리 - -2. **`lib/screens/equipment/equipment_out_form.dart`** (852줄) - - equipment_in_form과 유사한 구조와 문제점 - -3. **`lib/screens/equipment/equipment_list_redesign.dart`** (1,151줄) - - 리스트 화면 로직과 UI가 한 파일에 혼재 - -4. **`lib/services/mock_data_service.dart`** (1,157줄) - - 모든 엔티티의 초기 데이터와 CRUD 메서드가 한 파일에 집중 - - 싱글톤 패턴으로 구현되어 있어 분리 시 주의 필요 - -## 📂 새로운 디렉토리 구조 - -``` -lib/ -├── screens/ -│ ├── equipment/ -│ │ ├── equipment_in_form.dart (메인 화면 - 150줄) -│ │ ├── equipment_out_form.dart (메인 화면 - 150줄) -│ │ ├── equipment_list_redesign.dart (메인 화면 - 200줄) -│ │ ├── controllers/ -│ │ │ └── (기존 유지) -│ │ └── widgets/ -│ │ ├── (기존 위젯들) -│ │ ├── equipment_in/ -│ │ │ ├── equipment_in_form_body.dart -│ │ │ ├── equipment_in_form_fields.dart -│ │ │ ├── equipment_in_summary_section.dart -│ │ │ └── equipment_in_action_buttons.dart -│ │ ├── equipment_out/ -│ │ │ ├── equipment_out_form_body.dart -│ │ │ ├── equipment_out_form_fields.dart -│ │ │ └── equipment_out_action_buttons.dart -│ │ └── equipment_list/ -│ │ ├── equipment_list_header.dart -│ │ ├── equipment_list_filters.dart -│ │ ├── equipment_list_table.dart -│ │ └── equipment_list_item.dart -│ │ -│ └── common/ -│ ├── custom_widgets/ -│ │ ├── (기존 위젯들) -│ │ └── overlay_dropdown/ -│ │ ├── overlay_dropdown_field.dart -│ │ ├── overlay_dropdown_controller.dart -│ │ └── overlay_dropdown_config.dart -│ └── mixins/ -│ ├── form_validation_mixin.dart -│ └── dropdown_handler_mixin.dart -│ -├── services/ -│ ├── mock_data_service.dart (메인 서비스 - 100줄) -│ └── mock_data/ -│ ├── mock_data_interface.dart -│ ├── equipment_mock_data.dart -│ ├── company_mock_data.dart -│ ├── user_mock_data.dart -│ ├── license_mock_data.dart -│ └── warehouse_mock_data.dart -│ -└── utils/ - └── dropdown/ - ├── dropdown_utils.dart - └── autocomplete_utils.dart -``` - -## 🔧 상세 리팩토링 계획 - -### 1. Equipment Form 리팩토링 - -#### 1.1 공통 드롭다운 컴포넌트 추출 - -**새 파일: `lib/screens/common/custom_widgets/overlay_dropdown/overlay_dropdown_field.dart`** -```dart -class OverlayDropdownField extends StatefulWidget { - final String label; - final TextEditingController controller; - final List items; - final Function(String) onSelected; - final String? Function(String)? getAutocompleteSuggestion; - final bool isRequired; - // ... 기타 필요한 속성들 -} -``` - -**장점:** -- 7개의 반복되는 드롭다운 코드를 하나의 재사용 가능한 컴포넌트로 통합 -- 오버레이 관리 로직 캡슐화 -- 포커스 관리 자동화 - -#### 1.2 Equipment In Form 분리 - -**`equipment_in_form.dart`** (150줄) -- 메인 스캐폴드와 레이아웃만 포함 -- 하위 위젯들을 조합하는 역할 - -**`equipment_in_form_body.dart`** (200줄) -- 폼의 전체 구조 정의 -- 섹션별 위젯 배치 - -**`equipment_in_form_fields.dart`** (300줄) -- 모든 입력 필드 정의 -- OverlayDropdownField 활용 - -**`equipment_in_summary_section.dart`** (150줄) -- 요약 정보 표시 섹션 - -**`equipment_in_action_buttons.dart`** (100줄) -- 저장, 취소 등 액션 버튼 - -#### 1.3 Mixin을 통한 공통 로직 추출 - -**`form_validation_mixin.dart`** -```dart -mixin FormValidationMixin { - bool validateRequiredField(String? value, String fieldName); - bool validateEmail(String? value); - bool validatePhone(String? value); - // ... 기타 검증 메서드 -} -``` - -### 2. Mock Data Service 리팩토링 - -#### 2.1 인터페이스 정의 - -**`mock_data_interface.dart`** -```dart -abstract class MockDataProvider { - List getAll(); - T? getById(int id); - void add(T item); - void update(T item); - void delete(int id); - void initializeData(); -} -``` - -#### 2.2 엔티티별 Mock Data 분리 - -**`equipment_mock_data.dart`** (200줄) -```dart -class EquipmentMockData implements MockDataProvider { - final List _equipmentIns = []; - final List _equipmentOuts = []; - - void initializeData() { - // 장비 초기 데이터 - } - - // CRUD 메서드들 -} -``` - -**유사하게 구현:** -- `company_mock_data.dart` -- `user_mock_data.dart` -- `license_mock_data.dart` -- `warehouse_mock_data.dart` - -#### 2.3 메인 서비스 리팩토링 - -**`mock_data_service.dart`** (100줄) -```dart -class MockDataService { - static final MockDataService _instance = MockDataService._internal(); - - late final EquipmentMockData equipmentData; - late final CompanyMockData companyData; - late final UserMockData userData; - late final LicenseMockData licenseData; - late final WarehouseMockData warehouseData; - - void initialize() { - equipmentData = EquipmentMockData()..initializeData(); - companyData = CompanyMockData()..initializeData(); - // ... - } -} -``` - -### 3. Equipment List 리팩토링 - -#### 3.1 컴포넌트 분리 - -**`equipment_list_header.dart`** (100줄) -- 제목, 추가 버튼, 필터 토글 - -**`equipment_list_filters.dart`** (150줄) -- 검색 및 필터 UI - -**`equipment_list_table.dart`** (200줄) -- 테이블 헤더와 바디 - -**`equipment_list_item.dart`** (100줄) -- 개별 리스트 아이템 렌더링 - -## 🚀 구현 순서 - -### Phase 1: 공통 컴포넌트 구축 (우선순위: 높음) -1. OverlayDropdownField 컴포넌트 개발 -2. FormValidationMixin 구현 -3. 공통 유틸리티 함수 추출 - -### Phase 2: Equipment Forms 리팩토링 (우선순위: 높음) -1. equipment_in_form.dart 분리 -2. equipment_out_form.dart 분리 -3. 기존 기능 테스트 및 검증 - -### Phase 3: Mock Data Service 분리 (우선순위: 중간) -1. MockDataInterface 정의 -2. 엔티티별 mock data 클래스 생성 -3. 메인 서비스 리팩토링 -4. 의존성 주입 패턴 적용 - -### Phase 4: Equipment List 리팩토링 (우선순위: 중간) -1. 리스트 컴포넌트 분리 -2. 상태 관리 최적화 - -### Phase 5: 기타 대형 파일 검토 (우선순위: 낮음) -1. 600줄 이상 파일들 추가 분석 -2. 필요시 추가 리팩토링 - -## ⚠️ 주의사항 - -1. **기능 보존**: 모든 리팩토링은 기존 기능을 100% 유지해야 함 -2. **점진적 적용**: 한 번에 하나의 컴포넌트씩 리팩토링 -3. **테스트**: 각 단계별로 충분한 테스트 수행 -4. **버전 관리**: 각 리팩토링 단계별로 커밋 -5. **의존성**: MockDataService는 싱글톤 패턴이므로 분리 시 주의 -6. **성능**: 파일 분리로 인한 import 증가가 성능에 미치는 영향 최소화 - -## 📈 예상 효과 - -1. **가독성**: 파일당 평균 200줄로 감소 (90% 개선) -2. **중복 제거**: 드롭다운 관련 코드 85% 감소 -3. **유지보수**: 기능별 파일 분리로 수정 범위 명확화 -4. **재사용성**: 공통 컴포넌트로 신규 폼 개발 시간 50% 단축 -5. **테스트**: 단위 테스트 작성 용이성 향상 - -## 🔄 롤백 계획 - -각 단계별로 git 브랜치를 생성하여 문제 발생 시 즉시 롤백 가능하도록 함: -- `refactor/phase-1-common-components` -- `refactor/phase-2-equipment-forms` -- `refactor/phase-3-mock-data` -- `refactor/phase-4-equipment-list` - -## 📝 추가 고려사항 - -1. **국제화(i18n)**: 리팩토링 시 다국어 지원 구조 개선 -2. **접근성**: WCAG 가이드라인 준수 여부 확인 -3. **성능 최적화**: 불필요한 리빌드 방지를 위한 const 생성자 활용 -4. **문서화**: 각 컴포넌트별 JSDoc 스타일 주석 추가 - ---- - -이 계획은 코드베이스의 품질을 크게 향상시키면서도 기존 기능을 그대로 유지하는 것을 목표로 합니다. 각 단계는 독립적으로 수행 가능하며, 프로젝트 일정에 따라 우선순위를 조정할 수 있습니다. \ No newline at end of file diff --git a/doc/api_integration_fixes_summary.md b/doc/api_integration_fixes_summary.md deleted file mode 100644 index db3eb74..0000000 --- a/doc/api_integration_fixes_summary.md +++ /dev/null @@ -1,108 +0,0 @@ -# API Integration Fixes Summary - -## 개요 -superport_api 백엔드와 Flutter 프론트엔드 간의 API 통합 문제를 해결한 내역입니다. - -## 주요 수정 사항 - -### 1. Equipment Status 타입 불일치 해결 -**문제**: 서버는 status를 String 타입으로 변경했지만 다른 코드를 사용 -- 서버: "available", "inuse", "maintenance", "disposed" -- 클라이언트: "I", "O", "T", "R", "D", "L", "E" - -**해결**: -- `equipment_status_converter.dart` 유틸리티 생성 -- 양방향 변환 함수 구현 (serverToClient, clientToServer) -- Freezed JsonConverter 어노테이션 적용 - -### 2. Equipment 모델 수정 -- EquipmentResponse 모델에 @EquipmentStatusJsonConverter() 어노테이션 추가 -- EquipmentRequest 모델에도 동일한 변환기 적용 - -### 3. EquipmentService 개선 -- `getEquipmentsWithStatus()` 메서드 추가 - DTO 형태로 반환하여 status 정보 유지 -- 기존 `getEquipments()` 메서드는 하위 호환성을 위해 유지 - -### 4. EquipmentListController 수정 -- DTO를 직접 사용하여 status 정보 유지 -- 서버 status를 클라이언트 status로 변환 -- UnifiedEquipment 생성 시 올바른 status 할당 - -### 5. Health Test Service 구현 -- 모든 주요 API 엔드포인트 테스트 -- 로그인 후 자동 실행 -- 상세한 로그 출력 - -### 6. 디버깅 및 로깅 개선 -- DebugLogger 추가 -- 각 서비스와 컨트롤러에 로그 추가 -- API 요청/응답 인터셉터에 상세 로깅 - -## 현재 상태 - -### ✅ 정상 작동 -1. **인증 (Authentication)** - - 로그인: admin@superport.kr / admin123! - - 토큰 갱신 - - 로그아웃 - -2. **대시보드 API** - - Recent Activities API - - Expiring Licenses API - - Equipment Status Distribution API (별도 엔드포인트) - -3. **장비 관리** - - 장비 목록 조회 (status 변환 적용) - - 장비 상세 조회 - - 장비 생성/수정/삭제 - -4. **입고지 관리** - - 입고지 목록 조회 - - 입고지 CRUD 작업 - -5. **회사 관리** - - 회사 목록 조회 - - 회사 CRUD 작업 - - 지점 관리 - -### ❌ 서버 측 문제 (백엔드 수정 필요) -1. **Overview Stats API (/api/dashboard/overview/stats)** - - 500 Error: "operator does not exist: character varying = equipment_status" - - 원인: PostgreSQL 데이터베이스가 여전히 ENUM 타입으로 쿼리 실행 - - 필요한 조치: 백엔드에서 SQL 쿼리를 String 비교로 변경 - -## 테스트 결과 -``` -- Authentication: ✅ -- Token Refresh: ✅ -- Recent Activities: ✅ -- Expiring Licenses: ✅ -- Overview Stats: ❌ (서버 DB 쿼리 오류) -- Equipment Status Distribution: ✅ -- Equipment List: ✅ -- Warehouse List: ✅ -- Company List: ✅ -``` - -## 추가 권장 사항 -1. 백엔드 팀에 overview/stats API 수정 요청 -2. 모든 페이지에서 실제 사용자 테스트 수행 -3. flutter test 실행하여 유닛 테스트 통과 확인 -4. 프로덕션 배포 전 통합 테스트 수행 - -## 코드 품질 -- flutter analyze: 650개 이슈 (대부분 print 문 관련 경고) -- 컴파일 에러: 0개 -- 런타임 에러: 0개 (서버 측 DB 오류 제외) - -## 변경된 파일 목록 -1. `/lib/core/utils/equipment_status_converter.dart` (생성) -2. `/lib/data/models/equipment/equipment_response.dart` (수정) -3. `/lib/data/models/equipment/equipment_request.dart` (수정) -4. `/lib/services/equipment_service.dart` (수정) -5. `/lib/screens/equipment/controllers/equipment_list_controller.dart` (수정) -6. `/lib/services/health_test_service.dart` (생성) -7. `/lib/screens/login/controllers/login_controller.dart` (수정) -8. `/lib/screens/overview/controllers/overview_controller.dart` (로그 추가) -9. `/doc/server_side_database_error.md` (생성) -10. `/doc/api_integration_fixes_summary.md` (생성) \ No newline at end of file diff --git a/doc/api_response_parsing_fix_summary.md b/doc/api_response_parsing_fix_summary.md deleted file mode 100644 index 097a869..0000000 --- a/doc/api_response_parsing_fix_summary.md +++ /dev/null @@ -1,70 +0,0 @@ -# API 응답 파싱 오류 수정 요약 - -## 문제 상황 -- API 응답은 정상적으로 수신됨 (로그에서 확인) -- 화면에는 에러 메시지 표시 (ServerFailure 또는 TypeError) -- 창고 관리와 회사 관리 페이지 모두 동일한 문제 발생 - -## 근본 원인 -1. **창고 관리 (Warehouse)**: - - `WarehouseLocationListDto`가 `items` 필드를 기대하나, API는 `data` 배열 직접 반환 - - DTO 필드와 API 응답 필드 불일치 (code, manager_phone 등) - -2. **회사 관리 (Company)**: - - `ApiResponse`가 필수 필드 `message`를 기대하나 API 응답에 없음 - - `PaginatedResponse` 구조와 API 응답 구조 불일치 - -## 수정 사항 - -### 1. WarehouseLocationDto 수정 -```dart -// 실제 API 응답에 맞게 필드 수정 -- code 필드 추가 -- manager_phone 필드 추가 -- 없는 필드들을 nullable로 변경 (updated_at 등) -``` - -### 2. WarehouseRemoteDataSource 수정 -```dart -// API 응답을 DTO 구조에 맞게 변환 -final listData = { - 'items': dataList, // data → items로 매핑 - 'total': pagination['total'] ?? 0, - // ... pagination 데이터 매핑 -}; -``` - -### 3. CompanyResponse DTO 수정 -```dart -// API 응답에 없는 필수 필드를 nullable로 변경 -- contact_position: String? (nullable) -- updated_at: DateTime? (nullable) -``` - -### 4. CompanyRemoteDataSource 수정 -```dart -// ApiResponse/PaginatedResponse 대신 직접 파싱 -// API 응답 구조를 PaginatedResponse 구조로 변환 -return PaginatedResponse( - items: items, - page: pagination['page'] ?? page, - size: pagination['per_page'] ?? perPage, - // ... 나머지 필드 매핑 -); -``` - -### 5. 에러 처리 개선 -- Service Layer에 상세 로깅 추가 -- Controller에서 에러 타입별 처리 -- Stack trace 로깅으로 디버깅 개선 - -## 테스트 방법 -1. 웹 애플리케이션을 새로고침 -2. 창고 관리 페이지 접속 → 데이터 정상 표시 확인 -3. 회사 관리 페이지 접속 → 데이터 정상 표시 확인 -4. 콘솔 로그에서 에러 없음 확인 - -## 향후 개선 사항 -- API 응답 구조 문서화 -- DTO와 API 스펙 일치성 검증 테스트 추가 -- ResponseInterceptor에서 더 강력한 응답 정규화 \ No newline at end of file diff --git a/doc/api_schema_mismatch_analysis.md b/doc/api_schema_mismatch_analysis.md deleted file mode 100644 index f7662ea..0000000 --- a/doc/api_schema_mismatch_analysis.md +++ /dev/null @@ -1,143 +0,0 @@ -# API 스키마 불일치 문제 종합 분석 보고서 - -## 📋 요약 - -서버측 API 스키마 변경으로 인한 로그인 실패 문제를 분석한 결과, 다음과 같은 주요 원인들을 발견했습니다: - -1. **패스워드 해시 알고리즘 변경**: bcrypt → argon2 -2. **이메일 도메인 불일치**: 일부 계정에서 .com → .kr로 변경 -3. **실제 서버 데이터베이스와 샘플 데이터의 불일치** - -## 🔍 상세 분석 - -### 1. 서버측 스키마 분석 - -#### API 응답 형식 -```rust -// src/dto/auth_dto.rs -pub struct LoginResponse { - pub access_token: String, - pub refresh_token: String, - pub token_type: String, - pub expires_in: i64, - pub user: UserInfo, -} - -pub struct UserInfo { - pub id: i32, - pub username: String, - pub email: String, - pub name: String, - pub role: String, -} -``` - -- ✅ **snake_case 사용**: 클라이언트가 기대하는 형식과 일치 -- ✅ **응답 래핑**: `ApiResponse::success(response)` 형식으로 `{success: true, data: {...}}` 구조 사용 - -### 2. 인증 방식 변경 사항 - -#### v0.2.1 업데이트 (2025년 7월 30일) -- username 또는 email로 로그인 가능하도록 개선 -- 기존: email만 사용 -- 변경: username 또는 email 중 하나 사용 가능 - -#### 패스워드 해시 변경 -- **이전**: bcrypt (`$2b$12$...`) -- **현재**: argon2 (`$argon2id$v=19$...`) -- **영향**: 기존 bcrypt 해시로는 로그인 불가 - -### 3. 테스트 계정 정보 불일치 - -#### sample_data.sql의 계정 -```sql --- 관리자 계정 -username: 'admin' -email: 'admin@superport.com' -- .com 도메인 -password: 'password123' -- bcrypt 해시 -``` - -#### update_passwords_to_argon2.sql의 계정 -```sql --- 관리자 계정 -email: 'admin@superport.kr' -- .kr 도메인으로 변경됨 -password: argon2 해시 (원본 패스워드 불명) -``` - -#### RELEASE_NOTES의 예시 -```bash -# 패스워드가 'admin123!'로 표시됨 -{"username": "admin", "password": "admin123!"} -``` - -## 📊 문제점 요약 - -### 클라이언트측 문제 - -**없음** - Flutter 클라이언트는 올바르게 구현되어 있습니다: -- ✅ snake_case 필드 매핑 (`@JsonKey` 사용) -- ✅ 다양한 응답 형식 처리 (ResponseInterceptor) -- ✅ username/email 모두 지원 -- ✅ 적절한 에러 처리 - -### 서버측 문제 - -1. **테스트 계정 정보 불명확** - - 실제 프로덕션 서버의 테스트 계정 정보가 문서화되지 않음 - - 이메일 도메인 변경 (.com → .kr) - - 패스워드 변경 가능성 (password123 → admin123!) - -2. **패스워드 해시 알고리즘 마이그레이션** - - bcrypt에서 argon2로 변경 - - 기존 테스트 계정들의 패스워드가 무엇인지 불명확 - -## 💡 해결 방안 - -### 즉시 가능한 해결책 - -#### 1. Mock 모드 사용 (권장) -```dart -// lib/core/config/environment.dart -Environment.useApi = false; // Mock 모드 활성화 -``` -- 테스트 계정: `admin@superport.com` / `admin123` - -#### 2. 로그 활성화하여 디버깅 -```dart -Environment.enableLogging = true; // 상세 로그 출력 -``` - -### 서버 관리자에게 요청할 사항 - -1. **실제 테스트 계정 정보 제공** - - 정확한 username/email - - 현재 사용 가능한 패스워드 - - 계정의 role 및 권한 - -2. **API 문서 업데이트** - - 현재 프로덕션 서버의 정확한 스펙 - - 테스트 환경 접속 정보 - - 인증 방식 상세 설명 - -3. **개발/스테이징 서버 제공** - - 프로덕션과 동일한 환경의 테스트 서버 - - 자유롭게 테스트 가능한 계정 - -## 🔧 권장 개발 프로세스 - -1. **당장은 Mock 모드로 개발 진행** - - 모든 기능을 Mock 데이터로 구현 및 테스트 - - UI/UX 개발에 집중 - -2. **서버 팀과 협업** - - 정확한 API 스펙 확인 - - 테스트 계정 정보 획득 - - 개발 서버 접근 권한 요청 - -3. **점진적 통합** - - 기능별로 실제 API 연동 테스트 - - 문제 발생시 즉시 피드백 - -## 📝 결론 - -Flutter 클라이언트의 구현은 정상이며, 서버측의 인증 정보 불일치가 주요 원인입니다. Mock 모드를 활용하여 개발을 계속 진행하면서, 서버 팀과 협력하여 실제 API 연동을 준비하는 것이 최선의 방법입니다. \ No newline at end of file diff --git a/doc/error_analysis_report.md b/doc/error_analysis_report.md deleted file mode 100644 index 36ef705..0000000 --- a/doc/error_analysis_report.md +++ /dev/null @@ -1,120 +0,0 @@ -# Flutter 프로젝트 오류 분석 보고서 - -## 요약 - -Flutter 프로젝트의 전체 오류 분석을 완료했습니다. 총 7개의 주요 컴파일 오류가 발견되었으며, 모두 성공적으로 해결되었습니다. - -## 오류 분석 결과 - -### 1. 전체 오류 현황 - -- **초기 상태**: 566개의 이슈 (에러 + 경고 + 정보) -- **주요 컴파일 에러**: 7개 -- **최종 상태**: 0개의 컴파일 에러 (547개의 경고/정보는 남아있음) - -### 2. 주요 오류 및 해결 내역 - -#### 2.1 DebugLogger 상수 표현식 오류 -- **파일**: `lib/core/utils/debug_logger.dart:7` -- **원인**: Dart에서 const 문자열에 `*` 연산자 사용 불가 -- **해결**: `'=' * 50` → `'=================================================='` - -#### 2.2 Environment baseUrl 속성 오류 -- **파일**: - - `lib/core/utils/login_diagnostics.dart` (4곳) - - `lib/screens/test/test_login.dart` (1곳) -- **원인**: Environment 클래스의 속성명이 `baseUrl`에서 `apiBaseUrl`로 변경됨 -- **해결**: 모든 참조를 `Environment.apiBaseUrl`로 수정 - -#### 2.3 AuthInterceptor dio 인스턴스 접근 오류 -- **파일**: `lib/data/datasources/remote/interceptors/auth_interceptor.dart:99` -- **원인**: ErrorInterceptorHandler에 dio 속성이 없음 -- **해결**: - - AuthInterceptor 생성자에 Dio 인스턴스 주입 - - ApiClient에서 인터셉터 생성 시 dio 인스턴스 전달 - -#### 2.4 타입 캐스팅 오류 -- **파일**: `lib/data/datasources/remote/auth_remote_datasource.dart:83` -- **원인**: Map을 Map으로 암시적 변환 불가 -- **해결**: 명시적 타입 캐스팅 추가 - -#### 2.5 Dio OPTIONS 메서드 오류 -- **파일**: `lib/core/utils/login_diagnostics.dart:103` -- **원인**: `dio.options()` 메서드가 존재하지 않음 -- **해결**: `dio.request()` 메서드 사용하여 OPTIONS 요청 구현 - -#### 2.6 LoginViewRedesign 필수 매개변수 누락 -- **파일**: `test/widget/login_widget_test.dart` (8곳) -- **원인**: LoginViewRedesign 위젯에 onLoginSuccess 콜백이 필수 매개변수로 추가됨 -- **해결**: 모든 테스트에서 `onLoginSuccess: () {}` 추가 - -#### 2.7 사용하지 않는 변수 -- **파일**: `lib/core/utils/login_diagnostics.dart:156` -- **원인**: loginRequest 변수 선언 후 사용하지 않음 -- **해결**: 불필요한 변수 선언 제거 - -## 3. 오류 우선순위 및 영향도 - -### 심각도 높음 (빌드 차단) -1. DebugLogger 상수 표현식 오류 -2. Environment baseUrl 속성 오류 -3. AuthInterceptor dio 접근 오류 -4. LoginViewRedesign 필수 매개변수 오류 - -### 중간 (런타임 오류 가능) -5. 타입 캐스팅 오류 -6. Dio OPTIONS 메서드 오류 - -### 낮음 (코드 품질) -7. 사용하지 않는 변수 - -## 4. 추가 개선 사항 - -### 경고 및 정보성 이슈 (547개) -- **print 문 사용**: 프로덕션 코드에서 print 사용 (약 200개) - - 권장: DebugLogger로 교체 -- **JsonKey 어노테이션 경고**: 잘못된 위치에 사용 (약 100개) - - 권장: Freezed 모델 재생성 -- **사용하지 않는 import**: 불필요한 import 문 (약 10개) - - 권장: 제거 -- **코드 스타일**: dangling_library_doc_comments 등 - - 권장: 문서 주석 위치 조정 - -## 5. 검증 계획 - -### 단위 테스트 -```bash -flutter test test/unit/ -``` - -### 위젯 테스트 -```bash -flutter test test/widget/ -``` - -### 통합 테스트 -```bash -flutter test test/integration/ -``` - -### 빌드 검증 -```bash -flutter build web -flutter build apk -flutter build ios -``` - -## 6. 결론 - -모든 컴파일 오류가 성공적으로 해결되어 프로젝트가 정상적으로 빌드 가능한 상태입니다. -남아있는 경고와 정보성 이슈들은 기능에 영향을 주지 않으나, 코드 품질 향상을 위해 점진적으로 개선할 것을 권장합니다. - -### 다음 단계 -1. 테스트 실행하여 기능 정상 동작 확인 -2. print 문을 DebugLogger로 교체 -3. Freezed 모델 재생성으로 JsonKey 경고 해결 -4. 사용하지 않는 import 제거 - ---- -생성일: 2025-07-30 -작성자: Flutter QA Engineer \ No newline at end of file diff --git a/doc/server_side_database_error.md b/doc/server_side_database_error.md deleted file mode 100644 index 276b40d..0000000 --- a/doc/server_side_database_error.md +++ /dev/null @@ -1,43 +0,0 @@ -# Server-Side Database Error Report - -## Issue -The `/api/dashboard/overview/stats` endpoint is returning a 500 error due to a database query issue. - -## Error Details -```json -{ - "success": false, - "error": { - "code": "DATABASE_ERROR", - "message": "Database error: Query Error: error returned from database: operator does not exist: character varying = equipment_status" - } -} -``` - -## Root Cause -The PostgreSQL database is still using the `equipment_status` ENUM type in SQL queries, but the API is now sending string values. This causes a type mismatch error when the database tries to compare `varchar` (string) with `equipment_status` (enum). - -## Required Backend Fix -The backend team needs to: -1. Update all SQL queries that reference `equipment_status` to use string comparisons instead of enum comparisons -2. Or complete the database migration to convert the `equipment_status` column from ENUM to VARCHAR - -## Affected Endpoints -- `/api/dashboard/overview/stats` - Currently failing with 500 error - -## Frontend Status -The frontend has been updated to handle the new string-based status codes: -- Created `equipment_status_converter.dart` to convert between server codes (available, inuse, maintenance, disposed) and client codes (I, O, T, R, D, L, E) -- Updated all models to use the converter -- Other API endpoints are being tested for similar issues - -## Test Results -- Authentication: ✅ Working -- Token Refresh: ✅ Working -- Recent Activities: ✅ Working -- Expiring Licenses: ✅ Working -- Overview Stats: ❌ Server-side database error -- Equipment Status Distribution: 🔄 To be tested -- Equipment List: 🔄 To be tested -- Warehouse List: 🔄 To be tested -- Company List: 🔄 To be tested \ No newline at end of file diff --git a/lib.zip b/lib.zip deleted file mode 100644 index 16e3833..0000000 Binary files a/lib.zip and /dev/null differ diff --git a/lib/data/models/dashboard/license_expiry_summary.dart b/lib/data/models/dashboard/license_expiry_summary.dart index 68bcb17..fa15e08 100644 --- a/lib/data/models/dashboard/license_expiry_summary.dart +++ b/lib/data/models/dashboard/license_expiry_summary.dart @@ -6,12 +6,13 @@ part 'license_expiry_summary.g.dart'; @freezed class LicenseExpirySummary with _$LicenseExpirySummary { const factory LicenseExpirySummary({ - @JsonKey(name: 'within_30_days') required int within30Days, - @JsonKey(name: 'within_60_days') required int within60Days, - @JsonKey(name: 'within_90_days') required int within90Days, - @JsonKey(name: 'expired') required int expired, - @JsonKey(name: 'total_active') required int totalActive, - @JsonKey(name: 'licenses') required List licenses, + @JsonKey(name: 'expiring_30_days', defaultValue: 0) required int within30Days, + @JsonKey(name: 'expiring_60_days', defaultValue: 0) required int within60Days, + @JsonKey(name: 'expiring_90_days', defaultValue: 0) required int within90Days, + @JsonKey(name: 'expired', defaultValue: 0) required int expired, + @JsonKey(name: 'active', defaultValue: 0) required int totalActive, + @JsonKey(name: 'licenses', defaultValue: []) required List licenses, + @JsonKey(name: 'expiring_7_days', defaultValue: 0) int? expiring7Days, }) = _LicenseExpirySummary; factory LicenseExpirySummary.fromJson(Map json) => diff --git a/lib/data/models/dashboard/license_expiry_summary.freezed.dart b/lib/data/models/dashboard/license_expiry_summary.freezed.dart index c77b123..b81f3a4 100644 --- a/lib/data/models/dashboard/license_expiry_summary.freezed.dart +++ b/lib/data/models/dashboard/license_expiry_summary.freezed.dart @@ -20,18 +20,20 @@ LicenseExpirySummary _$LicenseExpirySummaryFromJson(Map json) { /// @nodoc mixin _$LicenseExpirySummary { - @JsonKey(name: 'within_30_days') + @JsonKey(name: 'expiring_30_days', defaultValue: 0) int get within30Days => throw _privateConstructorUsedError; - @JsonKey(name: 'within_60_days') + @JsonKey(name: 'expiring_60_days', defaultValue: 0) int get within60Days => throw _privateConstructorUsedError; - @JsonKey(name: 'within_90_days') + @JsonKey(name: 'expiring_90_days', defaultValue: 0) int get within90Days => throw _privateConstructorUsedError; - @JsonKey(name: 'expired') + @JsonKey(name: 'expired', defaultValue: 0) int get expired => throw _privateConstructorUsedError; - @JsonKey(name: 'total_active') + @JsonKey(name: 'active', defaultValue: 0) int get totalActive => throw _privateConstructorUsedError; - @JsonKey(name: 'licenses') + @JsonKey(name: 'licenses', defaultValue: []) List get licenses => throw _privateConstructorUsedError; + @JsonKey(name: 'expiring_7_days', defaultValue: 0) + int? get expiring7Days => throw _privateConstructorUsedError; /// Serializes this LicenseExpirySummary to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -50,12 +52,14 @@ abstract class $LicenseExpirySummaryCopyWith<$Res> { _$LicenseExpirySummaryCopyWithImpl<$Res, LicenseExpirySummary>; @useResult $Res call( - {@JsonKey(name: 'within_30_days') int within30Days, - @JsonKey(name: 'within_60_days') int within60Days, - @JsonKey(name: 'within_90_days') int within90Days, - @JsonKey(name: 'expired') int expired, - @JsonKey(name: 'total_active') int totalActive, - @JsonKey(name: 'licenses') List licenses}); + {@JsonKey(name: 'expiring_30_days', defaultValue: 0) int within30Days, + @JsonKey(name: 'expiring_60_days', defaultValue: 0) int within60Days, + @JsonKey(name: 'expiring_90_days', defaultValue: 0) int within90Days, + @JsonKey(name: 'expired', defaultValue: 0) int expired, + @JsonKey(name: 'active', defaultValue: 0) int totalActive, + @JsonKey(name: 'licenses', defaultValue: []) + List licenses, + @JsonKey(name: 'expiring_7_days', defaultValue: 0) int? expiring7Days}); } /// @nodoc @@ -80,6 +84,7 @@ class _$LicenseExpirySummaryCopyWithImpl<$Res, Object? expired = null, Object? totalActive = null, Object? licenses = null, + Object? expiring7Days = freezed, }) { return _then(_value.copyWith( within30Days: null == within30Days @@ -106,6 +111,10 @@ class _$LicenseExpirySummaryCopyWithImpl<$Res, ? _value.licenses : licenses // ignore: cast_nullable_to_non_nullable as List, + expiring7Days: freezed == expiring7Days + ? _value.expiring7Days + : expiring7Days // ignore: cast_nullable_to_non_nullable + as int?, ) as $Val); } } @@ -119,12 +128,14 @@ abstract class _$$LicenseExpirySummaryImplCopyWith<$Res> @override @useResult $Res call( - {@JsonKey(name: 'within_30_days') int within30Days, - @JsonKey(name: 'within_60_days') int within60Days, - @JsonKey(name: 'within_90_days') int within90Days, - @JsonKey(name: 'expired') int expired, - @JsonKey(name: 'total_active') int totalActive, - @JsonKey(name: 'licenses') List licenses}); + {@JsonKey(name: 'expiring_30_days', defaultValue: 0) int within30Days, + @JsonKey(name: 'expiring_60_days', defaultValue: 0) int within60Days, + @JsonKey(name: 'expiring_90_days', defaultValue: 0) int within90Days, + @JsonKey(name: 'expired', defaultValue: 0) int expired, + @JsonKey(name: 'active', defaultValue: 0) int totalActive, + @JsonKey(name: 'licenses', defaultValue: []) + List licenses, + @JsonKey(name: 'expiring_7_days', defaultValue: 0) int? expiring7Days}); } /// @nodoc @@ -146,6 +157,7 @@ class __$$LicenseExpirySummaryImplCopyWithImpl<$Res> Object? expired = null, Object? totalActive = null, Object? licenses = null, + Object? expiring7Days = freezed, }) { return _then(_$LicenseExpirySummaryImpl( within30Days: null == within30Days @@ -172,6 +184,10 @@ class __$$LicenseExpirySummaryImplCopyWithImpl<$Res> ? _value._licenses : licenses // ignore: cast_nullable_to_non_nullable as List, + expiring7Days: freezed == expiring7Days + ? _value.expiring7Days + : expiring7Days // ignore: cast_nullable_to_non_nullable + as int?, )); } } @@ -180,45 +196,53 @@ class __$$LicenseExpirySummaryImplCopyWithImpl<$Res> @JsonSerializable() class _$LicenseExpirySummaryImpl implements _LicenseExpirySummary { const _$LicenseExpirySummaryImpl( - {@JsonKey(name: 'within_30_days') required this.within30Days, - @JsonKey(name: 'within_60_days') required this.within60Days, - @JsonKey(name: 'within_90_days') required this.within90Days, - @JsonKey(name: 'expired') required this.expired, - @JsonKey(name: 'total_active') required this.totalActive, - @JsonKey(name: 'licenses') - required final List licenses}) + {@JsonKey(name: 'expiring_30_days', defaultValue: 0) + required this.within30Days, + @JsonKey(name: 'expiring_60_days', defaultValue: 0) + required this.within60Days, + @JsonKey(name: 'expiring_90_days', defaultValue: 0) + required this.within90Days, + @JsonKey(name: 'expired', defaultValue: 0) required this.expired, + @JsonKey(name: 'active', defaultValue: 0) required this.totalActive, + @JsonKey(name: 'licenses', defaultValue: []) + required final List licenses, + @JsonKey(name: 'expiring_7_days', defaultValue: 0) this.expiring7Days}) : _licenses = licenses; factory _$LicenseExpirySummaryImpl.fromJson(Map json) => _$$LicenseExpirySummaryImplFromJson(json); @override - @JsonKey(name: 'within_30_days') + @JsonKey(name: 'expiring_30_days', defaultValue: 0) final int within30Days; @override - @JsonKey(name: 'within_60_days') + @JsonKey(name: 'expiring_60_days', defaultValue: 0) final int within60Days; @override - @JsonKey(name: 'within_90_days') + @JsonKey(name: 'expiring_90_days', defaultValue: 0) final int within90Days; @override - @JsonKey(name: 'expired') + @JsonKey(name: 'expired', defaultValue: 0) final int expired; @override - @JsonKey(name: 'total_active') + @JsonKey(name: 'active', defaultValue: 0) final int totalActive; final List _licenses; @override - @JsonKey(name: 'licenses') + @JsonKey(name: 'licenses', defaultValue: []) List get licenses { if (_licenses is EqualUnmodifiableListView) return _licenses; // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_licenses); } + @override + @JsonKey(name: 'expiring_7_days', defaultValue: 0) + final int? expiring7Days; + @override String toString() { - return 'LicenseExpirySummary(within30Days: $within30Days, within60Days: $within60Days, within90Days: $within90Days, expired: $expired, totalActive: $totalActive, licenses: $licenses)'; + return 'LicenseExpirySummary(within30Days: $within30Days, within60Days: $within60Days, within90Days: $within90Days, expired: $expired, totalActive: $totalActive, licenses: $licenses, expiring7Days: $expiring7Days)'; } @override @@ -235,7 +259,9 @@ class _$LicenseExpirySummaryImpl implements _LicenseExpirySummary { (identical(other.expired, expired) || other.expired == expired) && (identical(other.totalActive, totalActive) || other.totalActive == totalActive) && - const DeepCollectionEquality().equals(other._licenses, _licenses)); + const DeepCollectionEquality().equals(other._licenses, _licenses) && + (identical(other.expiring7Days, expiring7Days) || + other.expiring7Days == expiring7Days)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -247,7 +273,8 @@ class _$LicenseExpirySummaryImpl implements _LicenseExpirySummary { within90Days, expired, totalActive, - const DeepCollectionEquality().hash(_licenses)); + const DeepCollectionEquality().hash(_licenses), + expiring7Days); /// Create a copy of LicenseExpirySummary /// with the given fields replaced by the non-null parameter values. @@ -269,36 +296,43 @@ class _$LicenseExpirySummaryImpl implements _LicenseExpirySummary { abstract class _LicenseExpirySummary implements LicenseExpirySummary { const factory _LicenseExpirySummary( - {@JsonKey(name: 'within_30_days') required final int within30Days, - @JsonKey(name: 'within_60_days') required final int within60Days, - @JsonKey(name: 'within_90_days') required final int within90Days, - @JsonKey(name: 'expired') required final int expired, - @JsonKey(name: 'total_active') required final int totalActive, - @JsonKey(name: 'licenses') - required final List licenses}) = - _$LicenseExpirySummaryImpl; + {@JsonKey(name: 'expiring_30_days', defaultValue: 0) + required final int within30Days, + @JsonKey(name: 'expiring_60_days', defaultValue: 0) + required final int within60Days, + @JsonKey(name: 'expiring_90_days', defaultValue: 0) + required final int within90Days, + @JsonKey(name: 'expired', defaultValue: 0) required final int expired, + @JsonKey(name: 'active', defaultValue: 0) required final int totalActive, + @JsonKey(name: 'licenses', defaultValue: []) + required final List licenses, + @JsonKey(name: 'expiring_7_days', defaultValue: 0) + final int? expiring7Days}) = _$LicenseExpirySummaryImpl; factory _LicenseExpirySummary.fromJson(Map json) = _$LicenseExpirySummaryImpl.fromJson; @override - @JsonKey(name: 'within_30_days') + @JsonKey(name: 'expiring_30_days', defaultValue: 0) int get within30Days; @override - @JsonKey(name: 'within_60_days') + @JsonKey(name: 'expiring_60_days', defaultValue: 0) int get within60Days; @override - @JsonKey(name: 'within_90_days') + @JsonKey(name: 'expiring_90_days', defaultValue: 0) int get within90Days; @override - @JsonKey(name: 'expired') + @JsonKey(name: 'expired', defaultValue: 0) int get expired; @override - @JsonKey(name: 'total_active') + @JsonKey(name: 'active', defaultValue: 0) int get totalActive; @override - @JsonKey(name: 'licenses') + @JsonKey(name: 'licenses', defaultValue: []) List get licenses; + @override + @JsonKey(name: 'expiring_7_days', defaultValue: 0) + int? get expiring7Days; /// Create a copy of LicenseExpirySummary /// with the given fields replaced by the non-null parameter values. diff --git a/lib/data/models/dashboard/license_expiry_summary.g.dart b/lib/data/models/dashboard/license_expiry_summary.g.dart index 24e0dcc..154016d 100644 --- a/lib/data/models/dashboard/license_expiry_summary.g.dart +++ b/lib/data/models/dashboard/license_expiry_summary.g.dart @@ -9,25 +9,29 @@ part of 'license_expiry_summary.dart'; _$LicenseExpirySummaryImpl _$$LicenseExpirySummaryImplFromJson( Map json) => _$LicenseExpirySummaryImpl( - within30Days: (json['within_30_days'] as num).toInt(), - within60Days: (json['within_60_days'] as num).toInt(), - within90Days: (json['within_90_days'] as num).toInt(), - expired: (json['expired'] as num).toInt(), - totalActive: (json['total_active'] as num).toInt(), - licenses: (json['licenses'] as List) - .map((e) => LicenseExpiryDetail.fromJson(e as Map)) - .toList(), + within30Days: (json['expiring_30_days'] as num?)?.toInt() ?? 0, + within60Days: (json['expiring_60_days'] as num?)?.toInt() ?? 0, + within90Days: (json['expiring_90_days'] as num?)?.toInt() ?? 0, + expired: (json['expired'] as num?)?.toInt() ?? 0, + totalActive: (json['active'] as num?)?.toInt() ?? 0, + licenses: (json['licenses'] as List?) + ?.map((e) => + LicenseExpiryDetail.fromJson(e as Map)) + .toList() ?? + [], + expiring7Days: (json['expiring_7_days'] as num?)?.toInt() ?? 0, ); Map _$$LicenseExpirySummaryImplToJson( _$LicenseExpirySummaryImpl instance) => { - 'within_30_days': instance.within30Days, - 'within_60_days': instance.within60Days, - 'within_90_days': instance.within90Days, + 'expiring_30_days': instance.within30Days, + 'expiring_60_days': instance.within60Days, + 'expiring_90_days': instance.within90Days, 'expired': instance.expired, - 'total_active': instance.totalActive, + 'active': instance.totalActive, 'licenses': instance.licenses, + 'expiring_7_days': instance.expiring7Days, }; _$LicenseExpiryDetailImpl _$$LicenseExpiryDetailImplFromJson( diff --git a/lib/screens/common/app_layout_redesign.dart b/lib/screens/common/app_layout_redesign.dart index b5c262c..157d8ae 100644 --- a/lib/screens/common/app_layout_redesign.dart +++ b/lib/screens/common/app_layout_redesign.dart @@ -9,6 +9,7 @@ import 'package:superport/screens/user/user_list_redesign.dart'; import 'package:superport/screens/license/license_list_redesign.dart'; import 'package:superport/screens/warehouse_location/warehouse_location_list_redesign.dart'; import 'package:superport/services/auth_service.dart'; +import 'package:superport/services/dashboard_service.dart'; import 'package:superport/utils/constants.dart'; import 'package:superport/data/models/auth/auth_user.dart'; @@ -32,7 +33,9 @@ class _AppLayoutRedesignState extends State late AnimationController _sidebarAnimationController; AuthUser? _currentUser; late final AuthService _authService; + late final DashboardService _dashboardService; late Animation _sidebarAnimation; + int _expiringLicenseCount = 0; // 30일 내 만료 예정 라이선스 수 // 레이아웃 상수 (1920x1080 최적화) static const double _sidebarExpandedWidth = 260.0; @@ -46,7 +49,9 @@ class _AppLayoutRedesignState extends State _currentRoute = widget.initialRoute; _setupAnimations(); _authService = GetIt.instance(); + _dashboardService = GetIt.instance(); _loadCurrentUser(); + _loadLicenseExpirySummary(); } Future _loadCurrentUser() async { @@ -57,6 +62,36 @@ class _AppLayoutRedesignState extends State }); } } + + Future _loadLicenseExpirySummary() async { + try { + print('[DEBUG] 라이선스 만료 정보 로드 시작...'); + final result = await _dashboardService.getLicenseExpirySummary(); + result.fold( + (failure) { + // 실패 시 0으로 유지 + print('[ERROR] 라이선스 만료 정보 로드 실패: $failure'); + }, + (summary) { + print('[DEBUG] 라이선스 만료 정보 로드 성공!'); + print('[DEBUG] 30일 내 만료: ${summary.within30Days}개'); + print('[DEBUG] 60일 내 만료: ${summary.within60Days}개'); + print('[DEBUG] 90일 내 만료: ${summary.within90Days}개'); + print('[DEBUG] 이미 만료: ${summary.expired}개'); + + if (mounted) { + setState(() { + _expiringLicenseCount = summary.within30Days; + print('[DEBUG] 상태 업데이트 완료: $_expiringLicenseCount'); + }); + } + }, + ); + } catch (e) { + print('[ERROR] 라이선스 만료 정보 로드 중 예외 발생: $e'); + print('[ERROR] 스택 트레이스: ${StackTrace.current}'); + } + } void _setupAnimations() { _sidebarAnimationController = AnimationController( @@ -114,6 +149,10 @@ class _AppLayoutRedesignState extends State setState(() { _currentRoute = route; }); + // 라이선스 화면으로 이동할 때 만료 정보 새로고침 + if (route == Routes.license) { + _loadLicenseExpirySummary(); + } } /// 사이드바 토글 @@ -479,6 +518,7 @@ class _AppLayoutRedesignState extends State currentRoute: _currentRoute, onRouteChanged: _navigateTo, collapsed: _sidebarCollapsed, + expiringLicenseCount: _expiringLicenseCount, ); } @@ -551,6 +591,7 @@ class _AppLayoutRedesignState extends State onPressed: () { // 페이지 새로고침 setState(() {}); + _loadLicenseExpirySummary(); // 라이선스 만료 정보도 새로고침 }, variant: ShadcnButtonVariant.ghost, size: ShadcnButtonSize.small, @@ -804,12 +845,14 @@ class SidebarMenuRedesign extends StatelessWidget { final String currentRoute; final Function(String) onRouteChanged; final bool collapsed; + final int expiringLicenseCount; const SidebarMenuRedesign({ Key? key, required this.currentRoute, required this.onRouteChanged, required this.collapsed, + required this.expiringLicenseCount, }) : super(key: key); @override @@ -884,7 +927,7 @@ class SidebarMenuRedesign extends StatelessWidget { title: '유지보수 관리', route: Routes.license, isActive: currentRoute == Routes.license, - badge: '3', // 만료 임박 라이선스 수 + badge: expiringLicenseCount > 0 ? expiringLicenseCount.toString() : null, ), if (!collapsed) ...[ @@ -1003,13 +1046,13 @@ class SidebarMenuRedesign extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: ShadcnTheme.error, + color: Colors.orange, borderRadius: BorderRadius.circular(10), ), child: Text( badge, style: ShadcnTheme.caption.copyWith( - color: ShadcnTheme.errorForeground, + color: Colors.white, fontWeight: FontWeight.w600, ), ), @@ -1024,7 +1067,7 @@ class SidebarMenuRedesign extends StatelessWidget { width: 8, height: 8, decoration: BoxDecoration( - color: ShadcnTheme.error, + color: Colors.orange, shape: BoxShape.circle, ), ), diff --git a/lib/screens/company/company_list_redesign.dart b/lib/screens/company/company_list_redesign.dart index f702f5a..332f11c 100644 --- a/lib/screens/company/company_list_redesign.dart +++ b/lib/screens/company/company_list_redesign.dart @@ -140,37 +140,27 @@ class _CompanyListRedesignState extends State { spacing: 4, runSpacing: 2, children: types.map((type) { - Color bgColor; - Color textColor; + ShadcnBadgeVariant variant; + String displayText; switch (type) { case CompanyType.customer: - bgColor = ShadcnTheme.green.withValues(alpha: 0.9); - textColor = Colors.white; + variant = ShadcnBadgeVariant.companyCustomer; // Orange + displayText = '고객사'; break; case CompanyType.partner: - bgColor = ShadcnTheme.purple.withValues(alpha: 0.9); - textColor = Colors.white; + variant = ShadcnBadgeVariant.companyPartner; // Green + displayText = '파트너사'; break; default: - bgColor = ShadcnTheme.muted.withValues(alpha: 0.9); - textColor = ShadcnTheme.foreground; + variant = ShadcnBadgeVariant.secondary; + displayText = companyTypeToString(type); } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(3), - ), - child: Text( - companyTypeToString(type), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: textColor, - ), - ), + return ShadcnBadge( + text: displayText, + variant: variant, + size: ShadcnBadgeSize.small, ); }).toList(), ), @@ -181,23 +171,12 @@ class _CompanyListRedesignState extends State { /// 본사/지점 구분 배지 생성 Widget _buildCompanyTypeLabel(bool isBranch) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: - isBranch - ? ShadcnTheme.blue.withValues(alpha: 0.9) - : ShadcnTheme.primary.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - isBranch ? '지점' : '본사', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), + return ShadcnBadge( + text: isBranch ? '지점' : '본사', + variant: isBranch + ? ShadcnBadgeVariant.companyBranch // Purple (#7C3AED) - 차별화 + : ShadcnBadgeVariant.companyHeadquarters, // Blue (#2563EB) + size: ShadcnBadgeSize.small, ); } diff --git a/lib/screens/equipment/equipment_list_redesign.dart b/lib/screens/equipment/equipment_list_redesign.dart index 251cb42..5ce0bc5 100644 --- a/lib/screens/equipment/equipment_list_redesign.dart +++ b/lib/screens/equipment/equipment_list_redesign.dart @@ -7,6 +7,7 @@ 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_data_table.dart' as std_table; 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/services/mock_data_service.dart'; import 'package:superport/models/equipment_unified_model.dart'; @@ -30,11 +31,13 @@ class _EquipmentListRedesignState extends State { bool _showDetailedColumns = true; final TextEditingController _searchController = TextEditingController(); final ScrollController _horizontalScrollController = ScrollController(); + final ScrollController _scrollController = ScrollController(); String _selectedStatus = 'all'; // String _searchKeyword = ''; // Removed - unused field String _appliedSearchKeyword = ''; int _currentPage = 1; final int _pageSize = 10; + final Set _selectedItems = {}; @override void initState() { @@ -386,163 +389,138 @@ class _EquipmentListRedesignState extends State { final int selectedInCount = controller.getSelectedInStockCount(); final int selectedOutCount = controller.getSelectedEquipmentCountByStatus(EquipmentStatus.out); final int selectedRentCount = controller.getSelectedEquipmentCountByStatus(EquipmentStatus.rent); + + final filteredEquipments = _getFilteredEquipments(); + final totalCount = filteredEquipments.length; - return Container( - color: ShadcnTheme.background, - child: Column( - children: [ - // 필터 및 액션 바 - _buildFilterBar(selectedCount, selectedInCount, selectedOutCount, selectedRentCount), + return BaseListScreen( + isLoading: controller.isLoading && controller.equipments.isEmpty, + error: controller.error, + onRefresh: () => controller.loadData(isRefresh: true), + emptyMessage: + _appliedSearchKeyword.isNotEmpty + ? '검색 결과가 없습니다' + : '등록된 장비가 없습니다', + emptyIcon: Icons.inventory_2_outlined, - // 장비 테이블 - Expanded( - child: controller.isLoading - ? _buildLoadingState() - : controller.error != null - ? _buildErrorState() - : _buildEquipmentTable(), - ), - ], - ), + // 검색바 + searchBar: _buildSearchBar(), + + // 액션바 + actionBar: _buildActionBar(selectedCount, selectedInCount, selectedOutCount, selectedRentCount, totalCount), + + // 데이터 테이블 + dataTable: _buildDataTable(filteredEquipments), + + // 페이지네이션 + pagination: totalCount > _pageSize ? Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ) : null, ); }, ), ); } - /// 필터 바 - Widget _buildFilterBar(int selectedCount, int selectedInCount, int selectedOutCount, int selectedRentCount) { - return Container( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - // 검색 및 필터 섹션 - Row( - children: [ - // 검색 입력 - Expanded( - flex: 2, - child: Container( - height: 40, - decoration: BoxDecoration( - color: ShadcnTheme.card, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - border: Border.all(color: Colors.black), - ), - child: TextField( - controller: _searchController, - onSubmitted: (_) => _onSearch(), - decoration: InputDecoration( - hintText: '장비명, 제조사, 카테고리, 시리얼번호 등...', - hintStyle: TextStyle(color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), fontSize: 14), - prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - style: ShadcnTheme.bodyMedium, - ), - ), + /// 검색 바 + Widget _buildSearchBar() { + return Row( + children: [ + // 검색 입력 + Expanded( + flex: 2, + child: Container( + height: 40, + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all(color: Colors.black), + ), + child: TextField( + controller: _searchController, + onSubmitted: (_) => _onSearch(), + decoration: InputDecoration( + hintText: '장비명, 제조사, 카테고리, 시리얼번호 등...', + hintStyle: TextStyle(color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), fontSize: 14), + prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), - - const SizedBox(width: 16), - - // 검색 버튼 - SizedBox( - height: 40, - child: ShadcnButton( - text: '검색', - onPressed: _onSearch, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: const Icon(Icons.search, size: 16), - ), - ), - - const SizedBox(width: 16), - - // 상태 필터 드롭다운 - Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: ShadcnTheme.card, - border: Border.all(color: Colors.black), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedStatus, - onChanged: (value) => _onStatusFilterChanged(value!), - style: TextStyle(fontSize: 14, color: ShadcnTheme.foreground), - icon: const Icon(Icons.arrow_drop_down, size: 20), - items: const [ - DropdownMenuItem(value: 'all', child: Text('전체')), - DropdownMenuItem(value: 'in', child: Text('입고')), - DropdownMenuItem(value: 'out', child: Text('출고')), - DropdownMenuItem(value: 'rent', child: Text('대여')), - ], - ), - ), - ), - ], + style: ShadcnTheme.bodyMedium, + ), ), + ), - const SizedBox(height: 16), + const SizedBox(width: 16), - // 액션 버튼들 및 상태 표시 - Row( - children: [ - // 라우트별 액션 버튼 - _buildRouteSpecificActions(selectedInCount, selectedOutCount, selectedRentCount), - - const Spacer(), - - // 선택 및 총 개수 표시 - if (selectedCount > 0) - Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - decoration: BoxDecoration( - color: ShadcnTheme.muted.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm), - ), - child: Text( - '$selectedCount개 선택됨', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - if (selectedCount > 0) const SizedBox(width: 12), - Text( - '총 ${_getFilteredEquipments().length}개', - style: ShadcnTheme.bodyMuted, - ), - const SizedBox(width: 12), - // 새로고침 버튼 - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - setState(() { - _controller.loadData(); - _currentPage = 1; - }); - }, - ), - // 뷰 모드 전환 버튼 - IconButton( - icon: Icon(_showDetailedColumns ? Icons.view_column : Icons.view_compact), - tooltip: _showDetailedColumns ? '간소화된 보기' : '상세 보기', - onPressed: () { - setState(() { - _showDetailedColumns = !_showDetailedColumns; - }); - }, - ), - ], + // 검색 버튼 + SizedBox( + height: 40, + child: ShadcnButton( + text: '검색', + onPressed: _onSearch, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: const Icon(Icons.search, size: 16), ), - ], - ), + ), + + const SizedBox(width: 16), + + // 상태 필터 드롭다운 + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: ShadcnTheme.card, + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedStatus, + onChanged: (value) => _onStatusFilterChanged(value!), + style: TextStyle(fontSize: 14, color: ShadcnTheme.foreground), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: const [ + DropdownMenuItem(value: 'all', child: Text('전체')), + DropdownMenuItem(value: 'in', child: Text('입고')), + DropdownMenuItem(value: 'out', child: Text('출고')), + DropdownMenuItem(value: 'rent', child: Text('대여')), + ], + ), + ), + ), + ], + ); + } + + /// 액션바 + Widget _buildActionBar(int selectedCount, int selectedInCount, int selectedOutCount, int selectedRentCount, int totalCount) { + return StandardActionBar( + leftActions: [ + // 라우트별 액션 버튼 + _buildRouteSpecificActions(selectedInCount, selectedOutCount, selectedRentCount), + ], + totalCount: totalCount, + selectedCount: selectedCount, + onRefresh: () { + setState(() { + _controller.loadData(); + _currentPage = 1; + }); + }, + statusMessage: + _appliedSearchKeyword.isNotEmpty + ? '"$_appliedSearchKeyword" 검색 결과' + : null, ); } @@ -679,49 +657,12 @@ class _EquipmentListRedesignState extends State { } } - /// 로딩 상태 - Widget _buildLoadingState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(color: ShadcnTheme.primary), - const SizedBox(height: 16), - Text('데이터를 불러오는 중...', style: ShadcnTheme.bodyMuted), - ], - ), - ); - } - /// 에러 상태 위젯 - Widget _buildErrorState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: ShadcnTheme.destructive), - const SizedBox(height: 16), - Text('데이터를 불러오는 중 오류가 발생했습니다.', style: ShadcnTheme.bodyMuted), - const SizedBox(height: 8), - Text(_controller.error ?? '', style: ShadcnTheme.bodySmall), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => _controller.loadData(isRefresh: true), - style: ElevatedButton.styleFrom( - backgroundColor: ShadcnTheme.primary, - ), - child: const Text('다시 시도'), - ), - ], - ), - ); - } - - /// 테이블 너비 계산 - double _calculateTableWidth(List pagedEquipments) { + /// 최소 테이블 너비 계산 + double _getMinimumTableWidth(List pagedEquipments) { double totalWidth = 0; - // 기본 컬럼들 (너비 최적화) + // 기본 컬럼들 (최소 너비) totalWidth += 40; // 체크박스 totalWidth += 50; // 번호 totalWidth += 120; // 제조사 @@ -729,8 +670,8 @@ class _EquipmentListRedesignState extends State { totalWidth += 100; // 카테고리 totalWidth += 50; // 수량 totalWidth += 70; // 상태 - totalWidth += 80; // 날짜 - totalWidth += 90; // 관리 + totalWidth += 80; // 날짜 + totalWidth += 90; // 관리 // 상세 컬럼들 (조건부) if (_showDetailedColumns) { @@ -740,379 +681,512 @@ class _EquipmentListRedesignState extends State { // 출고 정보 (조건부) if (pagedEquipments.any((e) => e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent)) { totalWidth += 120; // 회사 - totalWidth += 80; // 담당자 + totalWidth += 80; // 담당자 } } + // padding 추가 (좌우 각 16px) + totalWidth += 32; + return totalWidth; } - /// 장비 테이블 - Widget _buildEquipmentTable() { - final filteredEquipments = _getFilteredEquipments(); - final totalCount = filteredEquipments.length; + /// 헤더 셀 빌더 + Widget _buildHeaderCell( + String text, { + required int flex, + required bool useExpanded, + required double minWidth, + }) { + final child = Container( + alignment: Alignment.centerLeft, + child: Text( + text, + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + ); + + if (useExpanded) { + return Expanded(flex: flex, child: child); + } else { + return SizedBox(width: minWidth, child: child); + } + } + + /// 데이터 셀 빌더 + Widget _buildDataCell( + Widget child, { + required int flex, + required bool useExpanded, + required double minWidth, + }) { + final container = Container( + alignment: Alignment.centerLeft, + child: child, + ); + + if (useExpanded) { + return Expanded(flex: flex, child: container); + } else { + return SizedBox(width: minWidth, child: container); + } + } + + /// 유연한 테이블 빌더 + Widget _buildFlexibleTable(List pagedEquipments, {required bool useExpanded}) { + final hasOutOrRent = pagedEquipments.any((e) => + e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent + ); + + return Column( + children: [ + // 테이블 헤더 + Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 10, + ), + decoration: BoxDecoration( + color: ShadcnTheme.muted.withValues(alpha: 0.3), + border: Border( + bottom: BorderSide(color: Colors.black), + ), + ), + child: Row( + children: [ + // 체크박스 + _buildDataCell( + Checkbox( + value: _isAllSelected(), + onChanged: _onSelectAll, + ), + flex: 1, + useExpanded: useExpanded, + minWidth: 40, + ), + // 번호 + _buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 50), + // 제조사 + _buildHeaderCell('제조사', flex: 3, useExpanded: useExpanded, minWidth: 120), + // 장비명 + _buildHeaderCell('장비명', flex: 3, useExpanded: useExpanded, minWidth: 120), + // 카테고리 + _buildHeaderCell('카테고리', flex: 2, useExpanded: useExpanded, minWidth: 100), + // 상세 정보 (조건부) + if (_showDetailedColumns) ...[ + _buildHeaderCell('시리얼번호', flex: 3, useExpanded: useExpanded, minWidth: 120), + _buildHeaderCell('바코드', flex: 3, useExpanded: useExpanded, minWidth: 120), + ], + // 수량 + _buildHeaderCell('수량', flex: 1, useExpanded: useExpanded, minWidth: 50), + // 상태 + _buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 70), + // 날짜 + _buildHeaderCell('날짜', flex: 2, useExpanded: useExpanded, minWidth: 80), + // 출고 정보 (조건부) + if (_showDetailedColumns && hasOutOrRent) ...[ + _buildHeaderCell('회사', flex: 3, useExpanded: useExpanded, minWidth: 120), + _buildHeaderCell('담당자', flex: 2, useExpanded: useExpanded, minWidth: 80), + ], + // 관리 + _buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 90), + ], + ), + ), + + // 테이블 데이터 + ...pagedEquipments.asMap().entries.map((entry) { + final int index = entry.key; + final UnifiedEquipment equipment = entry.value; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.black), + ), + ), + child: Row( + children: [ + // 체크박스 + _buildDataCell( + Checkbox( + value: _selectedItems.contains(equipment.equipment.id ?? 0), + onChanged: (bool? value) { + if (equipment.equipment.id != null) { + _onItemSelected(equipment.equipment.id!, value ?? false); + } + }, + ), + flex: 1, + useExpanded: useExpanded, + minWidth: 40, + ), + // 번호 + _buildDataCell( + Text( + '${((_currentPage - 1) * _pageSize) + index + 1}', + style: ShadcnTheme.bodySmall, + ), + flex: 1, + useExpanded: useExpanded, + minWidth: 50, + ), + // 제조사 + _buildDataCell( + _buildTextWithTooltip( + equipment.equipment.manufacturer, + equipment.equipment.manufacturer, + ), + flex: 3, + useExpanded: useExpanded, + minWidth: 120, + ), + // 장비명 + _buildDataCell( + _buildTextWithTooltip( + equipment.equipment.name, + equipment.equipment.name, + ), + flex: 3, + useExpanded: useExpanded, + minWidth: 120, + ), + // 카테고리 + _buildDataCell( + _buildCategoryWithTooltip(equipment), + flex: 2, + useExpanded: useExpanded, + minWidth: 100, + ), + // 상세 정보 (조건부) + if (_showDetailedColumns) ...[ + _buildDataCell( + _buildTextWithTooltip( + equipment.equipment.serialNumber ?? '-', + equipment.equipment.serialNumber ?? '-', + ), + flex: 3, + useExpanded: useExpanded, + minWidth: 120, + ), + _buildDataCell( + _buildTextWithTooltip( + equipment.equipment.barcode ?? '-', + equipment.equipment.barcode ?? '-', + ), + flex: 3, + useExpanded: useExpanded, + minWidth: 120, + ), + ], + // 수량 + _buildDataCell( + Text( + equipment.equipment.quantity.toString(), + style: ShadcnTheme.bodySmall, + ), + flex: 1, + useExpanded: useExpanded, + minWidth: 50, + ), + // 상태 + _buildDataCell( + _buildStatusBadge(equipment.status), + flex: 2, + useExpanded: useExpanded, + minWidth: 70, + ), + // 날짜 + _buildDataCell( + _buildDateWidget(equipment), + flex: 2, + useExpanded: useExpanded, + minWidth: 80, + ), + // 출고 정보 (조건부) + if (_showDetailedColumns && hasOutOrRent) ...[ + _buildDataCell( + _buildTextWithTooltip( + '-', // TODO: 출고 정보 추가 필요 + '-', + ), + flex: 3, + useExpanded: useExpanded, + minWidth: 120, + ), + _buildDataCell( + Text( + '-', // TODO: 담당자 정보 추가 필요 + style: ShadcnTheme.bodySmall, + ), + flex: 2, + useExpanded: useExpanded, + minWidth: 80, + ), + ], + // 관리 + _buildDataCell( + _buildActionButtons(equipment.equipment.id ?? 0), + flex: 2, + useExpanded: useExpanded, + minWidth: 90, + ), + ], + ), + ); + }).toList(), + ], + ); + } + + /// 데이터 테이블 + Widget _buildDataTable(List filteredEquipments) { final int startIndex = (_currentPage - 1) * _pageSize; final int endIndex = - (startIndex + _pageSize) > totalCount - ? totalCount + (startIndex + _pageSize) > filteredEquipments.length + ? filteredEquipments.length : (startIndex + _pageSize); final List pagedEquipments = filteredEquipments.sublist( startIndex, endIndex, ); - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - // 테이블 컨테이너 (가로 스크롤 지원) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _horizontalScrollController, - child: Container( - constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width - 48, // padding 고려 + if (pagedEquipments.isEmpty) { + return StandardEmptyState( + title: + _appliedSearchKeyword.isNotEmpty + ? '검색 결과가 없습니다' + : '등록된 장비가 없습니다', + icon: Icons.inventory_2_outlined, + action: + _appliedSearchKeyword.isEmpty + ? StandardActionButtons.addButton( + text: '첫 장비 추가하기', + onPressed: () async { + final result = await Navigator.pushNamed( + context, + Routes.equipmentInAdd, + ); + if (result == true) { + setState(() { + _controller.loadData(); + _currentPage = 1; + }); + } + }, + ) + : null, + ); + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final minimumWidth = _getMinimumTableWidth(pagedEquipments); + final needsHorizontalScroll = minimumWidth > availableWidth; + + if (needsHorizontalScroll) { + // 최소 너비보다 작을 때만 스크롤 활성화 + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _horizontalScrollController, + child: SizedBox( + width: minimumWidth, + child: _buildFlexibleTable(pagedEquipments, useExpanded: false), ), - decoration: BoxDecoration( - border: Border.all(color: Colors.black), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: - pagedEquipments.isEmpty - ? Container( - padding: const EdgeInsets.all(64), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.inventory_2_outlined, - size: 48, - color: ShadcnTheme.mutedForeground, - ), - const SizedBox(height: 16), - Text('장비가 없습니다', style: ShadcnTheme.bodyMuted), - ], - ), - ), - ) - : SizedBox( - width: _calculateTableWidth(pagedEquipments), - child: Column( - children: [ - // 테이블 헤더 - Container( - padding: const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing4, - vertical: 10, - ), - decoration: BoxDecoration( - color: ShadcnTheme.muted.withValues(alpha: 0.3), - border: Border( - bottom: BorderSide(color: Colors.black), - ), - ), - child: Row( - children: [ - // 체크박스 - SizedBox( - width: 40, - child: Checkbox( - value: _isAllSelected(), - onChanged: _onSelectAll, - ), - ), - // 번호 - SizedBox( - width: 50, - child: Text('번호', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - // 제조사 - SizedBox( - width: 120, - child: Text('제조사', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - // 장비명 - SizedBox( - width: 120, - child: Text('장비명', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - // 카테고리 - SizedBox( - width: 100, - child: Text('카테고리', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - // 상세 정보 (조건부) - if (_showDetailedColumns) ...[ - SizedBox( - width: 120, - child: Text('시리얼번호', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - SizedBox( - width: 120, - child: Text('바코드', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - ], - // 수량 - SizedBox( - width: 50, - child: Text('수량', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - // 상태 - SizedBox( - width: 70, - child: Text('상태', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - // 날짜 - SizedBox( - width: 80, - child: Text('날짜', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - // 출고 정보 (조건부 - 테이블에 출고/대여 항목이 있을 때만) - if (_showDetailedColumns && pagedEquipments.any((e) => e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent)) ...[ - SizedBox( - width: 120, - child: Text('회사', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - SizedBox( - width: 80, - child: Text('담당자', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - ], - // 관리 - SizedBox( - width: 90, - child: Text('관리', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - ), - ], - ), - ), - - // 테이블 데이터 - ...pagedEquipments.asMap().entries.map((entry) { - final int index = entry.key; - final UnifiedEquipment equipment = entry.value; - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing4, - vertical: 4, - ), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Colors.black), - ), - ), - child: Row( - children: [ - // 체크박스 - SizedBox( - width: 40, - child: Checkbox( - value: _controller.selectedEquipmentIds.contains('${equipment.id}:${equipment.status}'), - onChanged: (value) => _onEquipmentSelected(equipment.id, equipment.status, value), - ), - ), - // 번호 - SizedBox( - width: 50, - child: Text( - '${startIndex + index + 1}', - style: ShadcnTheme.bodySmall, - ), - ), - // 제조사 - SizedBox( - width: 120, - child: Text( - equipment.equipment.manufacturer, - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - // 장비명 - SizedBox( - width: 120, - child: Text( - equipment.equipment.name, - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - // 카테고리 - SizedBox( - width: 100, - child: _buildCategoryWithTooltip(equipment), - ), - // 상세 정보 (조건부) - if (_showDetailedColumns) ...[ - SizedBox( - width: 120, - child: Text( - equipment.equipment.serialNumber ?? '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - SizedBox( - width: 120, - child: Text( - equipment.equipment.barcode ?? '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - ], - // 수량 - SizedBox( - width: 50, - child: Text( - '${equipment.equipment.quantity}', - style: ShadcnTheme.bodySmall, - ), - ), - // 상태 - SizedBox( - width: 70, - child: ShadcnBadge( - text: _getStatusDisplayText( - equipment.status, - ), - variant: _getStatusBadgeVariant( - equipment.status, - ), - size: ShadcnBadgeSize.small, - ), - ), - // 날짜 - SizedBox( - width: 80, - child: Text( - equipment.date.toString().substring(0, 10), - style: ShadcnTheme.bodySmall, - ), - ), - // 출고 정보 (조건부) - if (_showDetailedColumns && pagedEquipments.any((e) => e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent)) ...[ - SizedBox( - width: 120, - child: Text( - equipment.status == EquipmentStatus.out || equipment.status == EquipmentStatus.rent - ? _controller.getOutEquipmentInfo(equipment.id!, 'company') - : '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - SizedBox( - width: 80, - child: Text( - equipment.status == EquipmentStatus.out || equipment.status == EquipmentStatus.rent - ? _controller.getOutEquipmentInfo(equipment.id!, 'manager') - : '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - ], - // 관리 버튼 - SizedBox( - width: 90, - child: 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: () => _handleHistory(equipment), - tooltip: '이력', - ), - ), - Flexible( - child: IconButton( - constraints: const BoxConstraints( - minWidth: 30, - minHeight: 30, - ), - padding: const EdgeInsets.all(4), - icon: const Icon(Icons.edit_outlined, size: 16), - onPressed: () => _handleEdit(equipment), - tooltip: '편집', - ), - ), - Flexible( - child: IconButton( - constraints: const BoxConstraints( - minWidth: 30, - minHeight: 30, - ), - padding: const EdgeInsets.all(4), - icon: const Icon(Icons.delete_outline, size: 16), - onPressed: () => _handleDelete(equipment), - tooltip: '삭제', - ), - ), - ], - ), - ), - ], - ), - ); - }), - ], - ), - ), - ), - ), - - // 페이지네이션 컴포넌트 - if (totalCount > _pageSize) - Pagination( - totalCount: totalCount, - currentPage: _currentPage, - pageSize: _pageSize, - onPageChanged: (page) { - setState(() { - _currentPage = page; - }); - }, - ), - ], + ); + } else { + // 충분한 공간이 있을 때는 Expanded 사용 + return _buildFlexibleTable(pagedEquipments, useExpanded: true); + } + }, ), ); } - /// 상태 표시 텍스트 반환 - String _getStatusDisplayText(String status) { + /// 텍스트와 툴팁 위젯 빌더 + Widget _buildTextWithTooltip(String text, String tooltip) { + return Tooltip( + message: tooltip, + child: Text( + text, + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ); + } + + /// 상태 배지 빌더 + Widget _buildStatusBadge(String status) { + String displayText; + ShadcnBadgeVariant variant; + switch (status) { - case 'I': // EquipmentStatus.in_ - return '입고'; - case 'O': // EquipmentStatus.out - return '출고'; - case 'T': // EquipmentStatus.rent - return '대여'; + case EquipmentStatus.in_: + displayText = '입고'; + variant = ShadcnBadgeVariant.success; + break; + case EquipmentStatus.out: + displayText = '출고'; + variant = ShadcnBadgeVariant.destructive; + break; + case EquipmentStatus.rent: + displayText = '대여'; + variant = ShadcnBadgeVariant.warning; + break; default: - return '알수없음'; + displayText = '알수없음'; + variant = ShadcnBadgeVariant.secondary; + } + + return ShadcnBadge( + text: displayText, + variant: variant, + size: ShadcnBadgeSize.small, + ); + } + + /// 날짜 위젯 빌더 + Widget _buildDateWidget(UnifiedEquipment equipment) { + String dateStr = equipment.date.toString().substring(0, 10); + return Text( + dateStr, + style: ShadcnTheme.bodySmall, + ); + } + + + /// 액션 버튼 빌더 + Widget _buildActionButtons(int equipmentId) { + 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: '이력', + ), + ), + 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: '편집', + ), + ), + 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: '삭제', + ), + ), + ], + ); + } + + // 장비 이력 다이얼로그 표시 + void _showEquipmentHistoryDialog(int equipmentId) async { + // 해당 장비 찾기 + final equipment = _controller.equipments.firstWhere( + (e) => e.equipment.id == equipmentId, + orElse: () => throw Exception('Equipment not found'), + ); + + // 팝업 다이얼로그로 이력 표시 + final result = await EquipmentHistoryDialog.show( + context: context, + equipmentId: equipmentId, + equipmentName: '${equipment.equipment.manufacturer} ${equipment.equipment.name}', + ); + + if (result == true) { + _controller.loadData(isRefresh: true); } } - /// 상태에 따른 배지 변형 반환 - ShadcnBadgeVariant _getStatusBadgeVariant(String status) { - switch (status) { - case 'I': // EquipmentStatus.in_ - return ShadcnBadgeVariant.success; - case 'O': // EquipmentStatus.out - return ShadcnBadgeVariant.destructive; - case 'T': // EquipmentStatus.rent - return ShadcnBadgeVariant.warning; - default: - return ShadcnBadgeVariant.secondary; + // 편집 다이얼로그 표시 + void _showEditDialog(UnifiedEquipment equipment) { + _handleEdit(equipment); + } + + // 삭제 다이얼로그 표시 + void _showDeleteDialog(UnifiedEquipment equipment) { + _handleDelete(equipment); + } + + // 편집 핸들러 (액션 버튼에서 호출) - 장비 ID로 처리 + void _handleEditById(int equipmentId) { + // 해당 장비 찾기 + final equipment = _controller.equipments.firstWhere( + (e) => e.equipment.id == equipmentId, + orElse: () => throw Exception('Equipment not found'), + ); + _handleEdit(equipment); + } + + // 삭제 핸들러 (액션 버튼에서 호출) - 장비 ID로 처리 + void _handleDeleteById(int equipmentId) { + // 해당 장비 찾기 + final equipment = _controller.equipments.firstWhere( + (e) => e.equipment.id == equipmentId, + orElse: () => throw Exception('Equipment not found'), + ); + _handleDelete(equipment); + } + + /// 체크박스 선택 관련 함수들 + void _onItemSelected(int id, bool selected) { + setState(() { + if (selected) { + _selectedItems.add(id); + } else { + _selectedItems.remove(id); + } + }); + } + + /// 페이지 데이터 가져오기 + List _getPagedEquipments() { + final filteredEquipments = _getFilteredEquipments(); + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = startIndex + _pageSize; + + if (startIndex >= filteredEquipments.length) { + return []; } + + final actualEndIndex = endIndex > filteredEquipments.length + ? filteredEquipments.length + : endIndex; + + return filteredEquipments.sublist(startIndex, actualEndIndex); } /// 카테고리 축약 표기 함수 diff --git a/lib/screens/license/license_list_redesign.dart b/lib/screens/license/license_list_redesign.dart index 5b170c9..5b7da52 100644 --- a/lib/screens/license/license_list_redesign.dart +++ b/lib/screens/license/license_list_redesign.dart @@ -4,6 +4,11 @@ import 'package:superport/models/license_model.dart'; import 'package:superport/screens/common/theme_shadcn.dart'; import 'package:superport/screens/common/components/shadcn_components.dart'; import 'package:superport/screens/common/widgets/pagination.dart'; +import 'package:superport/screens/common/widgets/unified_search_bar.dart'; +import 'package:superport/screens/common/widgets/standard_data_table.dart' as std_table; +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/license/controllers/license_list_controller.dart'; import 'package:superport/utils/constants.dart'; import 'package:superport/services/mock_data_service.dart'; @@ -244,26 +249,31 @@ class _LicenseListRedesignState extends State { value: _controller, child: Consumer( builder: (context, controller, child) { - return Container( - color: ShadcnTheme.background, - child: Column( - children: [ - // 상단 통계 카드 - _buildStatisticsCards(), - - // 필터 및 액션 바 - _buildFilterBar(), - - // 라이선스 테이블 - Expanded( - child: controller.isLoading && controller.licenses.isEmpty - ? _buildLoadingState() - : controller.error != null - ? _buildErrorState() - : _buildLicenseTable(), - ), - ], - ), + final licenses = controller.licenses; + final totalCount = licenses.length; + + return BaseListScreen( + headerSection: _buildStatisticsCards(), + searchBar: _buildSearchBar(), + actionBar: _buildActionBar(), + dataTable: _buildDataTable(), + pagination: totalCount > _pageSize + ? Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ) + : null, + isLoading: controller.isLoading && controller.licenses.isEmpty, + error: controller.error, + onRefresh: () => _controller.loadData(), + emptyMessage: '등록된 라이선스가 없습니다', + emptyIcon: Icons.description_outlined, ); }, ), @@ -351,267 +361,168 @@ class _LicenseListRedesignState extends State { ); } - /// 필터 바 - Widget _buildFilterBar() { - return Container( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - // 검색 및 필터 섹션 - Row( - children: [ - // 검색 입력 - Expanded( - flex: 2, - child: Container( - height: 40, - decoration: BoxDecoration( - color: ShadcnTheme.card, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - border: Border.all(color: Colors.black), - ), - child: TextField( - controller: _searchController, - onSubmitted: (_) => _onSearch(), - decoration: InputDecoration( - hintText: '제품명, 라이선스 키, 벤더명, 현위치 검색...', - hintStyle: TextStyle(color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), fontSize: 14), - prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - style: ShadcnTheme.bodyMedium, - ), - ), + /// 검색바 + Widget _buildSearchBar() { + return Row( + children: [ + // 검색 입력 + Expanded( + flex: 2, + child: Container( + height: 40, + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all(color: Colors.black), + ), + child: TextField( + controller: _searchController, + onSubmitted: (_) => _onSearch(), + decoration: InputDecoration( + hintText: '제품명, 라이선스 키, 벤더명, 현위치 검색...', + hintStyle: TextStyle(color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), fontSize: 14), + prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), - const SizedBox(width: 16), - - // 검색 버튼 - SizedBox( - height: 40, - child: ShadcnButton( - text: '검색', - onPressed: _onSearch, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: const Icon(Icons.search, size: 16), - ), - ), - const SizedBox(width: 16), - - // 상태 필터 드롭다운 - Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: ShadcnTheme.card, - border: Border.all(color: Colors.black), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _controller.statusFilter, - onChanged: (value) { - if (value != null) { - _controller.changeStatusFilter(value); - } - }, - style: TextStyle(fontSize: 14, color: ShadcnTheme.foreground), - icon: const Icon(Icons.arrow_drop_down, size: 20), - items: const [ - DropdownMenuItem( - value: LicenseStatusFilter.all, - child: Text('전체'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.active, - child: Text('활성'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.inactive, - child: Text('비활성'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.expiringSoon, - child: Text('만료예정'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.expired, - child: Text('만료됨'), - ), - ], - ), - ), - ), - ], + style: ShadcnTheme.bodyMedium, + ), ), - - const SizedBox(height: 16), - - // 액션 버튼들 및 상태 표시 - Row( - children: [ - // 액션 버튼들 - ShadcnButton( - text: '유지보수 연장', - onPressed: _navigateToAdd, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: const Icon(Icons.add, size: 16), - ), - const SizedBox(width: 8), - - ShadcnButton( - text: '삭제', - onPressed: _controller.selectedCount > 0 ? _showBulkDeleteDialog : null, - variant: _controller.selectedCount > 0 - ? ShadcnButtonVariant.destructive - : ShadcnButtonVariant.secondary, - icon: const Icon(Icons.delete, size: 16), - ), - const SizedBox(width: 8), - - ShadcnButton( - text: '엑셀 내보내기', - onPressed: _showExportInfo, - variant: ShadcnButtonVariant.secondary, - icon: const Icon(Icons.download, size: 16), - ), - const SizedBox(width: 8), - - ShadcnButton( - text: '엑셀 가져오기', - onPressed: _showImportInfo, - variant: ShadcnButtonVariant.secondary, - icon: const Icon(Icons.upload, size: 16), - ), - - const Spacer(), - - // 선택 및 총 개수 표시 - if (_controller.selectedCount > 0) - Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - decoration: BoxDecoration( - color: ShadcnTheme.muted.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm), - ), - child: Text( - '${_controller.selectedCount}개 선택됨', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - if (_controller.selectedCount > 0) const SizedBox(width: 12), - - Text( - '총 ${_controller.licenses.length}개', - style: ShadcnTheme.bodyMuted, - ), - const SizedBox(width: 12), - - // 새로고침 버튼 - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => _controller.refresh(), - tooltip: '새로고침', - ), - ], - ), - ], - ), - ); - } - - /// 로딩 상태 - Widget _buildLoadingState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(color: ShadcnTheme.primary), - const SizedBox(height: 16), - Text('라이선스 데이터를 불러오는 중...', style: ShadcnTheme.bodyMuted), - ], - ), - ); - } - - /// 에러 상태 - Widget _buildErrorState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: ShadcnTheme.destructive), - const SizedBox(height: 16), - Text('데이터를 불러오는 중 오류가 발생했습니다.', style: ShadcnTheme.bodyMuted), - const SizedBox(height: 8), - Text(_controller.error ?? '', style: ShadcnTheme.bodySmall), - const SizedBox(height: 16), - ShadcnButton( - text: '다시 시도', - onPressed: () => _controller.refresh(), + ), + const SizedBox(width: 16), + + // 검색 버튼 + SizedBox( + height: 40, + child: ShadcnButton( + text: '검색', + onPressed: _onSearch, variant: ShadcnButtonVariant.primary, textColor: Colors.white, + icon: const Icon(Icons.search, size: 16), ), - ], - ), + ), + const SizedBox(width: 16), + + // 상태 필터 드롭다운 + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: ShadcnTheme.card, + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _controller.statusFilter, + onChanged: (value) { + if (value != null) { + _controller.changeStatusFilter(value); + } + }, + style: TextStyle(fontSize: 14, color: ShadcnTheme.foreground), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: const [ + DropdownMenuItem( + value: LicenseStatusFilter.all, + child: Text('전체'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.active, + child: Text('활성'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.inactive, + child: Text('비활성'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.expiringSoon, + child: Text('만료예정'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.expired, + child: Text('만료됨'), + ), + ], + ), + ), + ), + ], ); } - /// 라이선스 테이블 - Widget _buildLicenseTable() { + /// 액션 바 + Widget _buildActionBar() { + return StandardActionBar( + leftActions: [ + ShadcnButton( + text: '유지보수 연장', + onPressed: _navigateToAdd, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: const Icon(Icons.add, size: 16), + ), + + ShadcnButton( + text: '삭제', + onPressed: _controller.selectedCount > 0 ? _showBulkDeleteDialog : null, + variant: _controller.selectedCount > 0 + ? ShadcnButtonVariant.destructive + : ShadcnButtonVariant.secondary, + icon: const Icon(Icons.delete, size: 16), + ), + + ShadcnButton( + text: '엑셀 내보내기', + onPressed: _showExportInfo, + variant: ShadcnButtonVariant.secondary, + icon: const Icon(Icons.download, size: 16), + ), + + ShadcnButton( + text: '엑셀 가져오기', + onPressed: _showImportInfo, + variant: ShadcnButtonVariant.secondary, + icon: const Icon(Icons.upload, size: 16), + ), + ], + selectedCount: _controller.selectedCount, + totalCount: _controller.licenses.length, + onRefresh: () => _controller.refresh(), + ); + } + + + /// 데이터 테이블 + Widget _buildDataTable() { final licenses = _controller.licenses; if (licenses.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.description_outlined, - size: 48, - color: ShadcnTheme.mutedForeground, - ), - const SizedBox(height: 16), - Text('등록된 라이선스가 없습니다.', style: ShadcnTheme.bodyMuted), - const SizedBox(height: 16), - ShadcnButton( - text: '라이선스 추가', - onPressed: _navigateToAdd, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: const Icon(Icons.add, size: 16), - ), - ], + return StandardEmptyState( + title: '등록된 라이선스가 없습니다', + icon: Icons.description_outlined, + action: StandardActionButtons.addButton( + text: '첫 라이선스 추가하기', + onPressed: _navigateToAdd, ), ); } final pagedLicenses = _getPagedLicenses(); - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - // 테이블 컨테이너 (가로 스크롤 지원) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _horizontalScrollController, - child: Container( - constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width - 48, // padding 고려 - ), - decoration: BoxDecoration( - border: Border.all(color: Colors.black), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: SizedBox( - width: 1360, // 모든 컬럼 너비의 합 - child: Column( + return Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _horizontalScrollController, + child: SizedBox( + width: 1360, // 모든 컬럼 너비의 합 + child: Column( children: [ // 테이블 헤더 Container( @@ -858,24 +769,8 @@ class _LicenseListRedesignState extends State { ], ), ), - ), - ), - - // 페이지네이션 컴포넌트 (항상 표시) - if (licenses.length > _pageSize) - Pagination( - totalCount: licenses.length, - currentPage: _currentPage, - pageSize: _pageSize, - onPageChanged: (page) { - setState(() { - _currentPage = page; - }); - }, - ), - ], - ), - ); + ), + ); } /// 라이선스 상태 표시 배지 diff --git a/lib/screens/warehouse_location/warehouse_location_list_redesign.dart b/lib/screens/warehouse_location/warehouse_location_list_redesign.dart index dbf7c50..134dedf 100644 --- a/lib/screens/warehouse_location/warehouse_location_list_redesign.dart +++ b/lib/screens/warehouse_location/warehouse_location_list_redesign.dart @@ -3,6 +3,12 @@ import 'package:provider/provider.dart'; import 'package:superport/models/warehouse_location_model.dart'; import 'package:superport/screens/common/theme_shadcn.dart'; import 'package:superport/screens/common/components/shadcn_components.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; +import 'package:superport/screens/common/widgets/unified_search_bar.dart'; +import 'package:superport/screens/common/widgets/standard_data_table.dart' as std_table; +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/utils/constants.dart'; @@ -97,37 +103,6 @@ class _WarehouseLocationListRedesignState value: _controller, child: Consumer( builder: (context, controller, child) { - // 로딩 중일 때 - if (controller.isLoading && controller.warehouseLocations.isEmpty) { - return Center( - child: CircularProgressIndicator(), - ); - } - - // 에러가 있을 때 - if (controller.error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: Colors.red), - SizedBox(height: 16), - Text( - '오류가 발생했습니다', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Text(controller.error!), - SizedBox(height: 16), - ElevatedButton( - onPressed: _reload, - child: Text('다시 시도'), - ), - ], - ), - ); - } - final int totalCount = controller.warehouseLocations.length; final int startIndex = (_currentPage - 1) * _pageSize; final int endIndex = @@ -138,258 +113,223 @@ class _WarehouseLocationListRedesignState ? controller.warehouseLocations.sublist(startIndex, endIndex) : []; - return SingleChildScrollView( - padding: const EdgeInsets.all(ShadcnTheme.spacing6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 헤더 액션 바 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('총 ${totalCount}개 입고지', style: ShadcnTheme.bodyMuted), - if (controller.searchQuery.isNotEmpty) - Text( - '"${controller.searchQuery}" 검색 결과', - style: ShadcnTheme.bodyMuted.copyWith(fontSize: 12), - ), - ], - ), - ShadcnButton( - text: '입고지 추가', - onPressed: _navigateToAdd, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: Icon(Icons.add), - ), - ], - ), + return BaseListScreen( + isLoading: controller.isLoading && controller.warehouseLocations.isEmpty, + error: controller.error, + onRefresh: _reload, + emptyMessage: + controller.searchQuery.isNotEmpty + ? '검색 결과가 없습니다' + : '등록된 입고지가 없습니다', + emptyIcon: Icons.warehouse_outlined, - const SizedBox(height: ShadcnTheme.spacing4), + // 검색바 (기본 비어있음) + searchBar: Container(), - // 테이블 컨테이너 - Container( - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: Colors.black), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + // 액션바 + actionBar: StandardActionBar( + leftActions: [ + ShadcnButton( + text: '입고지 추가', + onPressed: _navigateToAdd, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: Icon(Icons.add), + ), + ], + totalCount: totalCount, + onRefresh: _reload, + statusMessage: + controller.searchQuery.isNotEmpty + ? '"${controller.searchQuery}" 검색 결과' + : null, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 테이블 헤더 - Container( - padding: const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing4, - vertical: 10, - ), - decoration: BoxDecoration( - color: ShadcnTheme.muted.withValues(alpha: 0.3), - border: Border( - bottom: BorderSide(color: Colors.black), - ), - ), - child: Row( - children: [ - Expanded( - flex: 1, - child: Text('번호', style: ShadcnTheme.bodyMedium), - ), - Expanded( - flex: 3, - child: Text('입고지명', style: ShadcnTheme.bodyMedium), - ), - Expanded( - flex: 4, - child: Text('주소', style: ShadcnTheme.bodyMedium), - ), - Expanded( - flex: 2, - child: Text('비고', style: ShadcnTheme.bodyMedium), - ), - Expanded( - flex: 1, - child: Text('관리', style: ShadcnTheme.bodyMedium), - ), - ], - ), - ), - // 테이블 데이터 - if (controller.isLoading) - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing8), - child: Center( - child: Column( - children: [ - CircularProgressIndicator(), - SizedBox(height: 8), - Text('데이터를 불러오는 중...', style: ShadcnTheme.bodyMuted), - ], - ), - ), - ) - else if (pagedLocations.isEmpty) - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing8), - child: Center( - child: Text( - controller.searchQuery.isNotEmpty - ? '검색 결과가 없습니다.' - : '등록된 입고지가 없습니다.', - style: ShadcnTheme.bodyMuted, - ), - ), - ) - else - ...pagedLocations.asMap().entries.map((entry) { - final int index = entry.key; - final WarehouseLocation location = entry.value; + // 데이터 테이블 + dataTable: _buildDataTable(pagedLocations, startIndex), - return Container( - padding: const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing4, - vertical: 4, - ), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Colors.black), - ), - ), - child: Row( - children: [ - // 번호 - Expanded( - flex: 1, - child: Text( - '${startIndex + index + 1}', - style: ShadcnTheme.bodySmall, - ), - ), - // 입고지명 - Expanded( - flex: 3, - child: Text( - location.name, - style: ShadcnTheme.bodyMedium, - ), - ), - // 주소 - Expanded( - flex: 4, - child: Text( - '${location.address.region} ${location.address.detailAddress}', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - // 비고 - Expanded( - flex: 2, - child: Text( - location.remark ?? '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), - ), - // 관리 - Expanded( - flex: 1, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: IconButton( - constraints: const BoxConstraints( - minWidth: 30, - minHeight: 30, - ), - padding: const EdgeInsets.all(4), - icon: Icon( - Icons.edit, - size: 16, - color: ShadcnTheme.primary, - ), - onPressed: () => _navigateToEdit(location), - tooltip: '수정', - ), - ), - Flexible( - child: IconButton( - constraints: const BoxConstraints( - minWidth: 30, - minHeight: 30, - ), - padding: const EdgeInsets.all(4), - icon: Icon( - Icons.delete, - size: 16, - color: ShadcnTheme.destructive, - ), - onPressed: () => - _showDeleteDialog(location.id), - tooltip: '삭제', - ), - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ], - ), - ), - // 페이지네이션 (항상 표시) - const SizedBox(height: ShadcnTheme.spacing6), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ShadcnButton( - text: '이전', - onPressed: - _currentPage > 1 - ? () { - setState(() { - _currentPage--; - }); - } - : null, - variant: ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - const SizedBox(width: ShadcnTheme.spacing2), - Text( - totalCount > 0 - ? '$_currentPage / ${(totalCount / _pageSize).ceil()}' - : '1 / 1', - style: ShadcnTheme.bodyMuted, - ), - const SizedBox(width: ShadcnTheme.spacing2), - ShadcnButton( - text: '다음', - onPressed: - _currentPage < (totalCount / _pageSize).ceil() && totalCount > _pageSize - ? () { - setState(() { - _currentPage++; - }); - } - : null, - variant: ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - ], - ), - ], - ), - ); + // 페이지네이션 + pagination: totalCount > _pageSize ? Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ) : null, + ); }, ), ); } + + /// 데이터 테이블 + Widget _buildDataTable(List pagedLocations, int startIndex) { + if (pagedLocations.isEmpty) { + return StandardEmptyState( + title: + _controller.searchQuery.isNotEmpty + ? '검색 결과가 없습니다' + : '등록된 입고지가 없습니다', + icon: Icons.warehouse_outlined, + action: + _controller.searchQuery.isEmpty + ? StandardActionButtons.addButton( + text: '첫 입고지 추가하기', + onPressed: _navigateToAdd, + ) + : null, + ); + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 테이블 헤더 + Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 10, + ), + decoration: BoxDecoration( + color: ShadcnTheme.muted.withValues(alpha: 0.3), + border: Border( + bottom: BorderSide(color: Colors.black), + ), + ), + child: Row( + children: [ + Expanded( + flex: 1, + child: Text('번호', style: ShadcnTheme.bodyMedium), + ), + Expanded( + flex: 3, + child: Text('입고지명', style: ShadcnTheme.bodyMedium), + ), + Expanded( + flex: 4, + child: Text('주소', style: ShadcnTheme.bodyMedium), + ), + Expanded( + flex: 2, + child: Text('비고', style: ShadcnTheme.bodyMedium), + ), + Expanded( + flex: 1, + child: Text('관리', style: ShadcnTheme.bodyMedium), + ), + ], + ), + ), + + // 테이블 데이터 + ...pagedLocations.asMap().entries.map((entry) { + final int index = entry.key; + final WarehouseLocation location = entry.value; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.black), + ), + ), + child: Row( + children: [ + // 번호 + Expanded( + flex: 1, + child: Text( + '${startIndex + index + 1}', + style: ShadcnTheme.bodySmall, + ), + ), + // 입고지명 + Expanded( + flex: 3, + child: Text( + location.name, + style: ShadcnTheme.bodyMedium, + ), + ), + // 주소 + Expanded( + flex: 4, + child: Text( + '${location.address.region} ${location.address.detailAddress}', + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + // 비고 + Expanded( + flex: 2, + child: Text( + location.remark ?? '-', + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + // 관리 + Expanded( + flex: 1, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: IconButton( + constraints: const BoxConstraints( + minWidth: 30, + minHeight: 30, + ), + padding: const EdgeInsets.all(4), + icon: Icon( + Icons.edit, + size: 16, + color: ShadcnTheme.primary, + ), + onPressed: () => _navigateToEdit(location), + tooltip: '수정', + ), + ), + Flexible( + child: IconButton( + constraints: const BoxConstraints( + minWidth: 30, + minHeight: 30, + ), + padding: const EdgeInsets.all(4), + icon: Icon( + Icons.delete, + size: 16, + color: ShadcnTheme.destructive, + ), + onPressed: () => + _showDeleteDialog(location.id), + tooltip: '삭제', + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ); + } } \ No newline at end of file diff --git a/superport_20250524.zip b/superport_20250524.zip deleted file mode 100644 index 50ecaed..0000000 Binary files a/superport_20250524.zip and /dev/null differ diff --git a/task.md b/task.md new file mode 100644 index 0000000..afaf70a --- /dev/null +++ b/task.md @@ -0,0 +1,381 @@ +# Equipment Management RenderFlex Overflow 수정 작업 + +## 📋 작업 개요 + +**목적**: Equipment Management 화면의 RenderFlex overflow 오류를 근본적으로 해결하기 위한 구조적 개선 + +**문제 상황**: +- 장비 관리 화면 우측에 32 픽셀의 RenderFlex overflow 발생 +- 노란색/검은색 줄무늬 패턴으로 렌더링 오류 시각화 +- 고정 너비 계산 방식이 padding과 border를 제대로 고려하지 못함 + +**해결 방안**: Option B - Expanded 위젯 기반 유연한 레이아웃 구조로 전환 + +## 🔍 현재 문제 분석 + +### 1. 오류 발생 위치 +``` +파일: /Users/maximilian.j.sul/Documents/flutter/superport/lib/screens/equipment/equipment_list_redesign.dart +라인: 755번 줄의 Row 위젯 +``` + +### 2. 현재 구조의 문제점 +```dart +// 현재 문제가 있는 구조 +return Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.black), // 2px border + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _horizontalScrollController, + child: SizedBox( + width: _calculateTableWidth(pagedEquipments), // 고정 너비 계산 + child: Column( + children: [ + // 테이블 헤더 + Container( + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 16), // 32px padding + child: Row(...) // ← 여기서 오버플로우 발생 + ), + // 테이블 바디 + ... + ] + ) + ) + ) +) +``` + +### 3. 근본 원인 +1. `_calculateTableWidth()` 함수가 Container의 padding (좌우 각 16px = 총 32px)을 고려하지 않음 +2. Border 두께 (2px)도 계산에서 누락 +3. 고정 너비 방식으로 인한 유연성 부족 +4. 각 컬럼의 고정 너비 합계가 실제 사용 가능한 공간을 초과 + +## 🛠️ Option B 구현 상세 + +### 1. 핵심 변경 사항 + +#### A. 고정 너비 제거 +```dart +// 변경 전 - 고정 너비 사용 +Container( + width: 60, // 고정 너비 + child: Text('번호') +) + +// 변경 후 - Expanded 사용 +Expanded( + flex: 1, // 비율로 너비 결정 + child: Text('번호') +) +``` + +#### B. 테이블 구조 개선 +```dart +// 새로운 구조 +return Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final needsHorizontalScroll = _getMinimumTableWidth() > availableWidth; + + if (needsHorizontalScroll) { + // 최소 너비보다 작을 때만 스크롤 활성화 + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _horizontalScrollController, + child: SizedBox( + width: _getMinimumTableWidth(), + child: _buildTable(pagedEquipments, useExpanded: false), + ), + ); + } else { + // 충분한 공간이 있을 때는 Expanded 사용 + return _buildTable(pagedEquipments, useExpanded: true); + } + }, + ), +); +``` + +### 2. 구현 단계 + +#### Step 1: 테이블 빌더 함수 분리 +```dart +Widget _buildTable(List equipments, {required bool useExpanded}) { + return Column( + children: [ + // 테이블 헤더 + Container( + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Color(0xFFF8F9FA), + border: Border( + bottom: BorderSide(color: Color(0xFFE5E7EB)), + ), + ), + child: Row( + children: [ + _buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 60), + _buildHeaderCell('장비 타입', flex: 2, useExpanded: useExpanded, minWidth: 120), + _buildHeaderCell('모델명', flex: 2, useExpanded: useExpanded, minWidth: 150), + _buildHeaderCell('제조사', flex: 2, useExpanded: useExpanded, minWidth: 120), + _buildHeaderCell('시리얼 번호', flex: 2, useExpanded: useExpanded, minWidth: 150), + _buildHeaderCell('상태', flex: 1, useExpanded: useExpanded, minWidth: 100), + _buildHeaderCell('입고일', flex: 2, useExpanded: useExpanded, minWidth: 120), + _buildHeaderCell('입고지', flex: 2, useExpanded: useExpanded, minWidth: 150), + _buildHeaderCell('액션', flex: 1, useExpanded: useExpanded, minWidth: 100), + ], + ), + ), + // 테이블 바디 + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: equipments.length, + itemBuilder: (context, index) { + return _buildDataRow(equipments[index], index, useExpanded); + }, + ), + ), + ], + ); +} +``` + +#### Step 2: 헤더 셀 빌더 +```dart +Widget _buildHeaderCell( + String text, { + required int flex, + required bool useExpanded, + required double minWidth, +}) { + final child = Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + text, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF1F2937), + ), + ), + ); + + if (useExpanded) { + return Expanded(flex: flex, child: child); + } else { + return SizedBox(width: minWidth, child: child); + } +} +``` + +#### Step 3: 데이터 행 빌더 +```dart +Widget _buildDataRow(Equipment equipment, int index, bool useExpanded) { + return Container( + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Color(0xFFE5E7EB)), + ), + ), + child: Row( + children: [ + _buildDataCell( + '${index + 1}', + flex: 1, + useExpanded: useExpanded, + minWidth: 60, + ), + _buildDataCell( + equipment.equipmentType ?? '-', + flex: 2, + useExpanded: useExpanded, + minWidth: 120, + ), + _buildDataCell( + equipment.model ?? '-', + flex: 2, + useExpanded: useExpanded, + minWidth: 150, + ), + _buildDataCell( + equipment.manufacturer ?? '-', + flex: 2, + useExpanded: useExpanded, + minWidth: 120, + ), + _buildDataCell( + equipment.serialNumber ?? '-', + flex: 2, + useExpanded: useExpanded, + minWidth: 150, + ), + _buildStatusCell( + equipment.status ?? '-', + flex: 1, + useExpanded: useExpanded, + minWidth: 100, + ), + _buildDataCell( + equipment.warehousingDate != null + ? DateFormat('yyyy-MM-dd').format(equipment.warehousingDate!) + : '-', + flex: 2, + useExpanded: useExpanded, + minWidth: 120, + ), + _buildDataCell( + equipment.warehouseLocationName ?? '-', + flex: 2, + useExpanded: useExpanded, + minWidth: 150, + ), + _buildActionCell( + equipment, + flex: 1, + useExpanded: useExpanded, + minWidth: 100, + ), + ], + ), + ); +} +``` + +#### Step 4: 데이터 셀 빌더 +```dart +Widget _buildDataCell( + String text, { + required int flex, + required bool useExpanded, + required double minWidth, +}) { + final child = Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + text, + style: TextStyle(color: Color(0xFF6B7280)), + overflow: TextOverflow.ellipsis, + ), + ); + + if (useExpanded) { + return Expanded(flex: flex, child: child); + } else { + return SizedBox(width: minWidth, child: child); + } +} +``` + +#### Step 5: 최소 테이블 너비 계산 +```dart +double _getMinimumTableWidth() { + // 각 컬럼의 최소 너비 합계 + const columnWidths = [ + 60, // 번호 + 120, // 장비 타입 + 150, // 모델명 + 120, // 제조사 + 150, // 시리얼 번호 + 100, // 상태 + 120, // 입고일 + 150, // 입고지 + 100, // 액션 + ]; + + final totalWidth = columnWidths.reduce((a, b) => a + b); + const padding = 32; // 좌우 padding + + return totalWidth + padding; +} +``` + +### 3. 제거해야 할 코드 + +1. `_calculateTableWidth()` 함수 전체 제거 +2. 모든 고정 너비 Container 제거 +3. 중첩된 SingleChildScrollView 구조 제거 + +## ✅ 테스트 체크리스트 + +### 1. 기능 테스트 +- [ ] 페이지네이션이 정상 작동하는가? +- [ ] 검색 기능이 정상 작동하는가? +- [ ] 정렬 기능이 정상 작동하는가? +- [ ] 장비 추가/수정/삭제가 정상 작동하는가? +- [ ] 장비 이력 조회가 정상 작동하는가? + +### 2. 레이아웃 테스트 +- [ ] RenderFlex overflow 오류가 해결되었는가? +- [ ] 다양한 화면 크기에서 정상 표시되는가? +- [ ] 스크롤이 필요한 경우에만 표시되는가? +- [ ] 테이블 컬럼이 적절한 비율로 표시되는가? +- [ ] 텍스트가 잘리지 않고 적절히 표시되는가? + +### 3. 일관성 테스트 +- [ ] 다른 관리 화면과 동일한 위치에 페이지네이션이 표시되는가? +- [ ] BaseListScreen을 통한 레이아웃이 일관되게 적용되는가? +- [ ] 스타일과 여백이 일관되게 적용되는가? + +## 📝 추가 고려사항 + +### 1. 반응형 디자인 +- 1200px 이상: Expanded 위젯 사용 (유연한 레이아웃) +- 1200px 미만: 고정 최소 너비 + 수평 스크롤 + +### 2. 성능 최적화 +- ListView.builder 사용으로 가상 스크롤링 유지 +- 불필요한 rebuild 방지를 위한 const 생성자 활용 + +### 3. 접근성 +- 적절한 최소 터치 영역 유지 (48x48 dp) +- 텍스트 가독성을 위한 적절한 padding 유지 + +## 🚀 구현 순서 + +1. **백업**: 현재 equipment_list_redesign.dart 파일 백업 +2. **테이블 구조 분리**: _buildTable 함수 생성 +3. **셀 빌더 구현**: 헤더와 데이터 셀 빌더 함수 구현 +4. **LayoutBuilder 적용**: 반응형 레이아웃 구현 +5. **테스트**: 모든 체크리스트 항목 확인 +6. **동일 패턴 적용**: license_list_redesign.dart에도 동일하게 적용 + +## 📌 예상 결과 + +- RenderFlex overflow 오류 완전 해결 +- 화면 크기에 따른 유연한 레이아웃 +- 필요한 경우에만 수평 스크롤 표시 +- 모든 관리 화면에서 일관된 페이지네이션 위치 +- 향후 유지보수가 용이한 구조 + +## 🔗 관련 파일 + +1. **수정 대상 파일**: + - `/Users/maximilian.j.sul/Documents/flutter/superport/lib/screens/equipment/equipment_list_redesign.dart` + - `/Users/maximilian.j.sul/Documents/flutter/superport/lib/screens/license/license_list_redesign.dart` (동일 패턴 적용) + +2. **참조 파일**: + - `/Users/maximilian.j.sul/Documents/flutter/superport/lib/screens/common/layouts/base_list_screen.dart` + - `/Users/maximilian.j.sul/Documents/flutter/superport/lib/screens/company/company_list_redesign.dart` (정상 작동 예시) + +--- + +**작성일**: 2025-01-09 +**작성자**: Claude Code Assistant +**버전**: 1.0 \ No newline at end of file diff --git a/test_20250806.md b/test_20250806.md deleted file mode 100644 index 8ae18b0..0000000 --- a/test_20250806.md +++ /dev/null @@ -1,268 +0,0 @@ -# Superport 인터랙티브 기능 테스트 진행 보고서 -**작성일: 2025년 8월 6일** -**작성자: Claude Code (AI Assistant)** -**프로젝트: Superport ERP System** - -**API 소스 코드 및 상태확인** -/Users/maximilian.j.sul/Documents/flutter/superport_api - - -## 📊 진행 상황 요약 - -### 전체 진행률: 100% ✅ -- ✅ 완료: 18개 작업 (추가 7개 완료) -- 🔄 진행중: 0개 작업 -- ⏳ 대기중: 0개 작업 - -## ✅ 완료된 작업 - -### Phase 1: 검색 기능 테스트 및 수정 -**완료 시간: 14:45** -- **테스트 파일:** `test/integration/automated/interactive_search_test.dart` -- **결과:** - - Company 검색: ✅ PASS - - Equipment 검색: ✅ PASS (구현 후) - - User 검색: ✅ PASS (수정 후) - - License 검색: ✅ PASS (수정 후) - -**주요 수정사항:** -- Equipment 검색 기능 전체 구현 (datasource → service → controller → UI) -- User/License 동적 API 응답 처리 추가 - -### Phase 2: 체크박스 → 장비출고 테스트 -**완료 시간: 15:43** -- **테스트 파일:** `test/integration/automated/checkbox_equipment_out_test.dart` -- **결과:** - - 단일 선택: ✅ PASS - - 다중 선택: ✅ PASS - - 전체 선택: ✅ PASS - - 필터링 후 선택: ✅ PASS - - 장비출고 프로세스: ✅ PASS (기존 장비 활용으로 해결) - -### Phase 3: 폼 입력 → 제출 테스트 -**완료 시간: 15:05** -- **테스트 파일:** `test/integration/automated/form_submission_test.dart` -- **결과:** - - Company 폼: ✅ PASS (3/3) - - Equipment 폼: ✅ PASS (2/2) - - User 폼: ✅ PASS (3/3) - - 필수 필드 검증: ✅ PASS - - 중복 체크: ✅ PASS - -### Phase 4: 필터링/정렬 기능 테스트 -**완료 시간: 15:12** -- **테스트 파일:** `test/integration/automated/filter_sort_test.dart` -- **결과:** - - Company 필터링: ✅ PASS - - Equipment 필터링: ✅ PASS - - User 필터링: ✅ PASS - - 정렬 기능: ✅ PASS - - 복합 필터: ✅ PASS - -**주요 수정사항:** -- Equipment status 값 수정 ('I'/'O' → 'available'/'inuse'/'disposed') -- User role null 처리 추가 - -### Phase 5: 페이지네이션 테스트 -**완료 시간: 15:17** -- **테스트 파일:** `test/integration/automated/pagination_test.dart` -- **결과:** - - Company 페이지네이션: ✅ PASS - - Equipment 페이지네이션: ✅ PASS - - User 페이지네이션: ✅ PASS - - 페이지 크기 변경: ✅ PASS - - 경계값 테스트: ✅ PASS - -### Phase 6: 심각한 버그 수정 (추가 세션) -**완료 시간: 17:30** -- **수정된 버그 목록:** - 1. **Overview 화면 시작 에러**: null safety 처리 추가 - 2. **Equipment 상태 드롭다운 에러**: 상태 변환 로직 수정 - 3. **Equipment In 입고지 연동**: 실제 서버 API 연동 - 4. **Equipment In 제출 에러**: 에러 핸들링 강화 - 5. **Warehouse Location 추가 기능**: Navigation 및 피드백 수정 - 6. **Company 전화번호 포맷팅**: 0x0 번호 처리 로직 추가 - 7. **Company 등록 실패**: 회사-지점 분리 생성 구현 - -### Phase 7: 전체 사용자 액션 테스트 구현 -**완료 시간: 18:00** -- **테스트 파일:** `test/integration/automated/user_actions_test.dart` -- **테스트 카테고리:** - - Button Click Tests: ✅ PASS - - Dropdown Selection Tests: ✅ PASS - - Form Submission Tests: ✅ PASS - - Search Functionality Tests: ✅ PASS - - Pagination Tests: ✅ PASS - - Delete Functionality Tests: ✅ PASS - - Edit Functionality Tests: ✅ PASS - - Complex User Flow Tests: ✅ PASS - -## 📁 생성/수정된 파일 목록 - -### 새로 생성된 테스트 파일 (6개) -``` -test/integration/automated/interactive_search_test.dart -test/integration/automated/checkbox_equipment_out_test.dart -test/integration/automated/form_submission_test.dart -test/integration/automated/filter_sort_test.dart -test/integration/automated/pagination_test.dart -test/integration/automated/user_actions_test.dart -``` - -### 수정된 서비스 파일 (15개) -``` -lib/data/datasources/remote/equipment_remote_datasource.dart -lib/data/datasources/remote/user_remote_datasource.dart -lib/data/datasources/remote/license_remote_datasource.dart -lib/data/datasources/remote/company_remote_datasource.dart -lib/services/equipment_service.dart -lib/services/user_service.dart -lib/services/company_service.dart -lib/screens/equipment/controllers/equipment_list_controller.dart -lib/screens/equipment/controllers/equipment_in_form_controller.dart -lib/screens/equipment/equipment_list_redesign.dart -lib/screens/overview/controllers/overview_controller.dart -lib/screens/overview/overview_screen_redesign.dart -lib/screens/company/controllers/company_form_controller.dart -lib/screens/company/company_form.dart -lib/screens/warehouse_location/warehouse_location_form.dart -lib/utils/phone_utils.dart -lib/core/utils/equipment_status_converter.dart -``` - -## 🐛 발견 및 수정된 주요 버그 - -### Phase 1-5 버그 (오전 세션) -1. **Equipment 검색 기능 누락**: search 파라미터 구현 -2. **API 응답 형식 불일치**: 동적 타입 처리 로직 추가 -3. **Equipment Status 값 오류**: 'available', 'inuse', 'disposed' 사용 -4. **User Role Null 처리**: 기본값 'staff' 설정 -5. **Model 필드 불일치**: 올바른 필드명 사용 -6. **장비출고 테스트 실패**: 기존 available 상태 장비 활용 - -### Phase 6-7 버그 (오후 세션) -7. **Overview 화면 시작 에러**: null safety 및 에러 핸들링 추가 -8. **Equipment 상태 드롭다운 에러**: EquipmentStatusConverter 수정 -9. **Equipment In 입고지 Mock 사용**: 실제 API 연동 구현 -10. **Equipment In 제출 실패**: warehouseLocationId 매핑 추가 -11. **Warehouse Location 추가 미작동**: Navigator.pop 및 피드백 추가 -12. **Company 전화번호 포맷팅 오류**: formatPhoneNumberByPrefix 메서드 구현 -13. **Company 등록 실패**: 회사 생성 후 지점 별도 생성 - -## 📊 최종 테스트 결과 - -| 테스트 스위트 | 테스트 수 | 성공 | 실패 | 성공률 | -|-------------|----------|------|------|--------| -| 검색 기능 | 4 | 4 | 0 | 100% | -| 체크박스 | 5 | 5 | 0 | 100% | -| 폼 제출 | 5 | 5 | 0 | 100% | -| 필터링/정렬 | 5 | 5 | 0 | 100% | -| 페이지네이션 | 5 | 5 | 0 | 100% | -| 사용자 액션 | 8 | 8 | 0 | 100% | -| **전체** | **32** | **32** | **0** | **100%** | - -## 💡 API 개선 권장사항 - -### 높은 우선순위 -1. **필터 파라미터 확장** - - Equipment: manufacturer, category, dateRange - - 모든 서비스: sortBy, sortOrder - -2. **응답 형식 표준화** - - 일관된 List/Object 래핑 - - 페이지네이션 메타데이터 (total, totalPages) - -3. **에러 응답 개선** - - 표준 에러 코드 - - 상세한 에러 메시지 - -### 중간 우선순위 -1. **성능 최적화** - - 응답 캐싱 - - 쿼리 최적화 - - 인덱싱 개선 - -2. **보안 강화** - - Rate limiting - - 입력 검증 강화 - -## 📝 테스트 실행 명령어 - -### 개별 테스트 실행 -```bash -# 검색 기능 -flutter test test/integration/automated/interactive_search_test.dart - -# 체크박스 -flutter test test/integration/automated/checkbox_equipment_out_test.dart - -# 폼 제출 -flutter test test/integration/automated/form_submission_test.dart - -# 필터링/정렬 -flutter test test/integration/automated/filter_sort_test.dart - -# 페이지네이션 -flutter test test/integration/automated/pagination_test.dart -``` - -### 전체 테스트 실행 -```bash -# 모든 자동화 테스트 -flutter test test/integration/automated/ - -# Mock 모드 -API_MODE=mock flutter test - -# 병렬 실행 -flutter test --concurrency=3 -``` - -## 🎯 성과 요약 - -### 정량적 성과 -- **테스트 커버리지**: 100% (32/32 성공) -- **발견된 버그**: 13개 -- **수정된 버그**: 13개 -- **추가된 기능**: 4개 (Equipment 검색, 입고지 API 연동, 전화번호 포맷팅, 회사-지점 분리 생성) -- **작성된 테스트**: 32개 시나리오 -- **작업 시간**: 약 4시간 - -### 정성적 성과 -- ✅ 모든 인터랙티브 기능에 대한 자동화 테스트 구축 -- ✅ API 호환성 문제 해결 -- ✅ 코드 품질 개선 -- ✅ 향후 회귀 테스트 기반 마련 - -## 🚀 다음 단계 권장사항 - -### 즉시 실행 -1. CI/CD 파이프라인에 테스트 통합 -2. 테스트 실패 시 자동 알림 설정 -3. 일일 자동 테스트 실행 스케줄링 - -### 단기 (1-2주) -1. Widget 테스트 추가 (UI 상호작용) -2. 성능 테스트 구현 -3. 테스트 데이터 자동 생성기 개선 - -### 중기 (1개월) -1. E2E 테스트 프레임워크 도입 -2. 테스트 커버리지 80% 달성 -3. 비주얼 회귀 테스트 도입 - -## 📌 참고사항 - -- **API 엔드포인트**: http://43.201.34.104:8080/api/v1 -- **테스트 계정**: admin@superport.kr / admin123! -- **JWT 만료**: 1시간 -- **병렬 실행**: 최대 3개 동시 실행 가능 -- **환경 변수**: .env.development 파일 사용 - ---- - -**작성자**: Claude Code Assistant -**최종 업데이트**: 2025-08-06 18:00 KST -**프로젝트 버전**: Superport v1.0.0 -**Flutter 버전**: 3.22.2 -**Dart 버전**: 3.4.3 \ No newline at end of file