chore: 프로젝트 정리 및 문서 업데이트
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

- 창고 위치 폼 UI 개선
- 테스트 리포트 업데이트
- API 이슈 문서 추가
- 폼 레이아웃 템플릿 추가
- main.dart 정리
- 상수 업데이트

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-08-09 02:17:47 +09:00
parent ef059d50ea
commit a220449671
13 changed files with 565 additions and 104 deletions

92
API_ISSUES.md Normal file
View File

@@ -0,0 +1,92 @@
# Superport API 개선 사항
## 1. 회사 목록 API에 company_types 필드 누락
### 현재 상황
- **문제 엔드포인트**:
- `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
**우선순위**: 높음 (회사 유형 표시는 핵심 기능)

View File

@@ -7,6 +7,7 @@ import 'package:superport/screens/company/company_form.dart';
import 'package:superport/screens/equipment/equipment_in_form.dart';
import 'package:superport/screens/equipment/equipment_out_form.dart';
import 'package:superport/screens/equipment/equipment_history_screen.dart';
import 'package:superport/screens/equipment/test_history_screen.dart';
import 'package:superport/screens/license/license_form.dart'; // MaintenanceFormScreen으로 사용
import 'package:superport/screens/user/user_form.dart';
import 'package:superport/screens/warehouse_location/warehouse_location_form.dart';
@@ -172,6 +173,12 @@ class SuperportApp extends StatelessWidget {
),
);
// 테스트 이력 화면
case Routes.testHistory:
return MaterialPageRoute(
builder: (context) => const TestHistoryScreen(),
);
// 회사 관련 라우트
case Routes.companyAdd:
return MaterialPageRoute(
@@ -208,8 +215,12 @@ class SuperportApp extends StatelessWidget {
// 라이센스 관련 라우트
case Routes.licenseAdd:
final licenseId = settings.arguments as int?;
return MaterialPageRoute(
builder: (context) => const MaintenanceFormScreen(),
builder: (context) => MaintenanceFormScreen(
maintenanceId: licenseId,
isExtension: licenseId != null, // 라이선스 ID가 있으면 연장 모드
),
);
case Routes.licenseEdit:
final id = settings.arguments as int;

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
/// 폼 화면의 일관된 레이아웃을 제공하는 템플릿 위젯
class FormLayoutTemplate extends StatelessWidget {
final String title;
final Widget child;
final VoidCallback? onSave;
final VoidCallback? onCancel;
final String saveButtonText;
final bool isLoading;
final bool showBottomButtons;
final Widget? customActions;
const FormLayoutTemplate({
Key? key,
required this.title,
required this.child,
this.onSave,
this.onCancel,
this.saveButtonText = '저장',
this.isLoading = false,
this.showBottomButtons = true,
this.customActions,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFFF5F7FA),
appBar: AppBar(
title: Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1F36),
),
),
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios, color: Color(0xFF6B7280), size: 20),
onPressed: onCancel ?? () => Navigator.of(context).pop(),
),
actions: customActions != null ? [customActions!] : null,
bottom: PreferredSize(
preferredSize: Size.fromHeight(1),
child: Container(
color: Color(0xFFE5E7EB),
height: 1,
),
),
),
body: child,
bottomNavigationBar: showBottomButtons ? _buildBottomBar(context) : null,
);
}
Widget _buildBottomBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Color(0xFFE5E7EB), width: 1),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
offset: Offset(0, -2),
blurRadius: 4,
),
],
),
padding: EdgeInsets.fromLTRB(24, 16, 24, 24),
child: Row(
children: [
Expanded(
child: ShadcnButton(
text: '취소',
onPressed: isLoading ? null : (onCancel ?? () => Navigator.of(context).pop()),
variant: ShadcnButtonVariant.secondary,
size: ShadcnButtonSize.large,
),
),
SizedBox(width: 12),
Expanded(
flex: 2,
child: ShadcnButton(
text: saveButtonText,
onPressed: isLoading ? null : onSave,
variant: ShadcnButtonVariant.primary,
size: ShadcnButtonSize.large,
loading: isLoading,
),
),
],
),
);
}
}
/// 폼 필드를 감싸는 일관된 카드 레이아웃
class FormSection extends StatelessWidget {
final String? title;
final String? subtitle;
final List<Widget> children;
final EdgeInsetsGeometry? padding;
const FormSection({
Key? key,
this.title,
this.subtitle,
required this.children,
this.padding,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ShadcnCard(
padding: padding ?? EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1F36),
),
),
if (subtitle != null) ...[
SizedBox(height: 4),
Text(
subtitle!,
style: TextStyle(
fontSize: 14,
color: Color(0xFF6B7280),
),
),
],
SizedBox(height: 20),
Divider(color: Color(0xFFE5E7EB), height: 1),
SizedBox(height: 20),
],
if (children.isNotEmpty)
...children.asMap().entries.map((entry) {
final index = entry.key;
final child = entry.value;
if (index < children.length - 1) {
return Padding(
padding: EdgeInsets.only(bottom: 16),
child: child,
);
} else {
return child;
}
}).toList(),
],
),
);
}
}
/// 일관된 폼 필드 스타일
class FormFieldWrapper extends StatelessWidget {
final String label;
final String? hint;
final bool required;
final Widget child;
const FormFieldWrapper({
Key? key,
required this.label,
this.hint,
this.required = false,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF374151),
),
),
if (required)
Text(
' *',
style: TextStyle(
fontSize: 14,
color: Color(0xFFEF4444),
),
),
],
),
if (hint != null) ...[
SizedBox(height: 4),
Text(
hint!,
style: TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
SizedBox(height: 8),
child,
],
);
}
}
/// UI 상수 정의
class UIConstants {
static const double formPadding = 24.0;
static const double buttonHeight = 48.0;
static const double borderRadius = 8.0;
static const double cardSpacing = 16.0;
// 테이블 컬럼 너비
static const double columnWidthSmall = 100.0; // 구분, 유형
static const double columnWidthMedium = 150.0; // 일반 필드
static const double columnWidthLarge = 200.0; // 긴 텍스트
// 색상
static const Color backgroundColor = Color(0xFFF5F7FA);
static const Color cardBackground = Colors.white;
static const Color borderColor = Color(0xFFE5E7EB);
static const Color textPrimary = Color(0xFF1A1F36);
static const Color textSecondary = Color(0xFF6B7280);
static const Color textMuted = Color(0xFF9CA3AF);
}

View File

@@ -3,6 +3,7 @@ import 'package:superport/models/address_model.dart';
import 'package:superport/screens/common/widgets/address_input.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
import 'package:superport/screens/common/theme_tailwind.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
import 'controllers/warehouse_location_form_controller.dart';
/// 입고지 추가/수정 폼 화면 (SRP 적용, 상태/로직 분리)
@@ -37,30 +38,61 @@ class _WarehouseLocationFormScreenState
super.dispose();
}
// 저장 메소드
Future<void> _onSave() async {
setState(() {}); // 저장 중 상태 갱신
final success = await _controller.save();
setState(() {}); // 저장 완료 후 상태 갱신
if (success) {
// 성공 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.isEditMode ? '입고지가 수정되었습니다' : '입고지가 추가되었습니다'),
backgroundColor: AppThemeTailwind.success,
),
);
// 리스트 화면으로 돌아가기
Navigator.of(context).pop(true);
}
} else {
// 실패 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error ?? '저장에 실패했습니다'),
backgroundColor: AppThemeTailwind.danger,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_controller.isEditMode ? '입고지 수정' : '입고지 추가'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
),
body: SafeArea(
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입고지명 입력
TextFormField(
return FormLayoutTemplate(
title: _controller.isEditMode ? '입고지 수정' : '입고지 추가',
onSave: _controller.isSaving ? null : _onSave,
saveButtonText: '저장',
isLoading: _controller.isSaving,
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(UIConstants.formPadding),
child: FormSection(
title: '입고지 정보',
subtitle: '입고지의 기본 정보를 입력하세요',
children: [
// 입고지명 입력
FormFieldWrapper(
label: '입고지명',
required: true,
child: TextFormField(
controller: _controller.nameController,
decoration: const InputDecoration(
labelText: '입고지명',
hintText: '입고지명을 입력하세요',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
@@ -69,9 +101,12 @@ class _WarehouseLocationFormScreenState
return null;
},
),
const SizedBox(height: 24),
// 주소 입력 (공통 위젯)
AddressInput(
),
// 주소 입력 (공통 위젯)
FormFieldWrapper(
label: '주소',
required: true,
child: AddressInput(
initialZipCode: _controller.address.zipCode,
initialRegion: _controller.address.region,
initialDetailAddress: _controller.address.detailAddress,
@@ -88,74 +123,13 @@ class _WarehouseLocationFormScreenState
});
},
),
const SizedBox(height: 24),
// 비고 입력
RemarkInput(controller: _controller.remarkController),
const SizedBox(height: 80), // 하단 버튼 여백 확보
],
),
),
),
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed:
_controller.isSaving
? null
: () async {
setState(() {}); // 저장 중 상태 갱신
final success = await _controller.save();
setState(() {}); // 저장 완료 후 상태 갱신
if (success) {
// 성공 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.isEditMode ? '입고지가 수정되었습니다' : '입고지가 추가되었습니다'),
backgroundColor: AppThemeTailwind.success,
),
);
// 리스트 화면으로 돌아가기
Navigator.of(context).pop(true);
}
} else {
// 실패 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error ?? '저장에 실패했습니다'),
backgroundColor: AppThemeTailwind.danger,
),
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppThemeTailwind.primary,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child:
_controller.isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'저장',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
// 비고 입력
FormFieldWrapper(
label: '비고',
child: RemarkInput(controller: _controller.remarkController),
),
],
),
),
),

View File

@@ -12,6 +12,7 @@ class Routes {
static const String equipmentOut = '/equipment-out'; // 출고 목록(미사용)
static const String equipmentOutAdd = '/equipment-out/add'; // 장비 출고 폼
static const String equipmentHistory = '/equipment/history'; // 장비 이력 조회
static const String testHistory = '/test/history'; // 테스트 이력 화면
static const String equipmentOutEdit = '/equipment-out/edit'; // 장비 출고 편집
static const String equipmentInList = '/equipment/in'; // 입고 장비 목록
static const String equipmentOutList = '/equipment/out'; // 출고 장비 목록

View File

@@ -0,0 +1,72 @@
{
"testName": "장비 입고 화면 전체 기능 테스트",
"timestamp": "2025-08-08T18:31:01.566734",
"duration": 6507,
"results": {
"totalTests": 10,
"passedTests": 5,
"failedTests": 5,
"tests": [
{
"testName": "장비 목록 조회",
"passed": true,
"error": null,
"retryCount": 0
},
{
"testName": "장비 검색 및 필터링",
"passed": false,
"error": "DioException [bad response]: null\nError: ServerException: 서버 오류가 발생했습니다. (code: 500)",
"retryCount": 3
},
{
"testName": "새 장비 등록",
"passed": true,
"error": null,
"retryCount": 0
},
{
"testName": "장비 정보 수정",
"passed": true,
"error": null,
"retryCount": 0
},
{
"testName": "장비 삭제",
"passed": false,
"error": "Exception: Assertion failed: 삭제된 장비가 여전히 조회됨",
"retryCount": 1
},
{
"testName": "장비 상태 변경",
"passed": false,
"error": "Exception: Assertion failed: 변경된 상태가 일치해야 합니다",
"retryCount": 1
},
{
"testName": "장비 이력 추가",
"passed": false,
"error": "Exception: Assertion failed: 이력 추가 응답 코드가 201이어야 합니다",
"retryCount": 1
},
{
"testName": "이미지 업로드",
"passed": true,
"error": null,
"retryCount": 0
},
{
"testName": "바코드 스캔 시뮬레이션",
"passed": true,
"error": null,
"retryCount": 0
},
{
"testName": "입고 완료 처리",
"passed": false,
"error": "Exception: Assertion failed: 입고 이력 추가 응답 코드가 201이어야 합니다",
"retryCount": 1
}
]
}
}

View File

@@ -0,0 +1,68 @@
# 장비 입고 화면 전체 기능 테스트 리포트
## 테스트 개요
- **실행 일시**: 2025-08-08 18:31:01.570595
- **소요 시간**: 6초
- **환경**: Production API (https://api-dev.beavercompany.co.kr)
## 테스트 결과
| 항목 | 결과 |
|------|------|
| 총 테스트 | 10개 |
| ✅ 성공 | 5개 |
| ❌ 실패 | 5개 |
| 📊 성공률 | 50.0% |
## 개별 테스트 상세
### 1. 장비 목록 조회
- **상태**: ✅ 성공
### 2. 장비 검색 및 필터링
- **상태**: ❌ 실패
- **재시도**: 3회
- **에러**: `DioException [bad response]: null
Error: ServerException: 서버 오류가 발생했습니다. (code: 500)`
### 3. 새 장비 등록
- **상태**: ✅ 성공
### 4. 장비 정보 수정
- **상태**: ✅ 성공
### 5. 장비 삭제
- **상태**: ❌ 실패
- **재시도**: 1회
- **에러**: `Exception: Assertion failed: 삭제된 장비가 여전히 조회됨`
### 6. 장비 상태 변경
- **상태**: ❌ 실패
- **재시도**: 1회
- **에러**: `Exception: Assertion failed: 변경된 상태가 일치해야 합니다`
### 7. 장비 이력 추가
- **상태**: ❌ 실패
- **재시도**: 1회
- **에러**: `Exception: Assertion failed: 이력 추가 응답 코드가 201이어야 합니다`
### 8. 이미지 업로드
- **상태**: ✅ 성공
### 9. 바코드 스캔 시뮬레이션
- **상태**: ✅ 성공
### 10. 입고 완료 처리
- **상태**: ❌ 실패
- **재시도**: 1회
- **에러**: `Exception: Assertion failed: 입고 이력 추가 응답 코드가 201이어야 합니다`
## 자동 수정 내역
이 테스트는 다음과 같은 자동 수정 기능을 포함합니다:
- 인증 토큰 만료 시 자동 재로그인
- 필수 필드 누락 시 기본값 자동 생성
- API 응답 형식 변경 감지 및 대응
- 검증 에러 발생 시 데이터 자동 수정
---
*이 리포트는 자동으로 생성되었습니다.*

View File

@@ -222,7 +222,7 @@
<header class="report-header">
<h1>🚀 Automated Test Suite</h1>
<div class="header-info">
<span class="date">생성 시간: 2025-08-05 18:06:29.842386</span>
<span class="date">생성 시간: 2025-08-08 18:30:49.349597</span>
<span class="duration">소요 시간: 0초</span>
</div>
</header>
@@ -272,7 +272,7 @@
</section>
<footer class="report-footer">
<p>이 리포트는 SUPERPORT 자동화 테스트 시스템에 의해 생성되었습니다.</p>
<p>생성 시간: 2025-08-05 18:06:29.844246</p>
<p>생성 시간: 2025-08-08 18:30:49.351431</p>
</footer>
</div>
</body>

View File

@@ -222,7 +222,7 @@
<header class="report-header">
<h1>🚀 Automated Test Suite</h1>
<div class="header-info">
<span class="date">생성 시간: 2025-08-05 18:06:30.183513</span>
<span class="date">생성 시간: 2025-08-08 18:30:44.508951</span>
<span class="duration">소요 시간: 0초</span>
</div>
</header>
@@ -272,7 +272,7 @@
</section>
<footer class="report-footer">
<p>이 리포트는 SUPERPORT 자동화 테스트 시스템에 의해 생성되었습니다.</p>
<p>생성 시간: 2025-08-05 18:06:30.185789</p>
<p>생성 시간: 2025-08-08 18:30:44.510771</p>
</footer>
</div>
</body>

View File

@@ -1,8 +1,8 @@
{
"reportId": "TEST-1754384789851",
"reportId": "TEST-1754645449358",
"testName": "Automated Test Suite",
"timestamp": "2025-08-05T18:06:29.851718",
"duration": 23,
"timestamp": "2025-08-08T18:30:49.359223",
"duration": 22,
"environment": {
"platform": "Flutter",
"dartVersion": "3.0",

View File

@@ -1,8 +1,8 @@
{
"reportId": "TEST-1754384790191",
"reportId": "TEST-1754645444520",
"testName": "Automated Test Suite",
"timestamp": "2025-08-05T18:06:30.192105",
"duration": 19,
"timestamp": "2025-08-08T18:30:44.520887",
"duration": 23,
"environment": {
"platform": "Flutter",
"dartVersion": "3.0",

View File

@@ -2,7 +2,7 @@
## 📊 테스트 실행 결과
- **실행 시간**: 2025-08-05 18:06:29.828327 ~ 2025-08-05 18:06:29.850204
- **실행 시간**: 2025-08-08 18:30:49.336922 ~ 2025-08-08 18:30:49.357706
- **소요 시간**: 0초
- **환경**: Flutter (null)
@@ -17,4 +17,4 @@
| 성공률 | 0.0% |
---
*이 리포트는 2025-08-05 18:06:29.850411에 자동 생성되었습니다.*
*이 리포트는 2025-08-08 18:30:49.357945에 자동 생성되었습니다.*

View File

@@ -2,7 +2,7 @@
## 📊 테스트 실행 결과
- **실행 시간**: 2025-08-05 18:06:30.172786 ~ 2025-08-05 18:06:30.191012
- **실행 시간**: 2025-08-08 18:30:44.497438 ~ 2025-08-08 18:30:44.518672
- **소요 시간**: 0초
- **환경**: Flutter (null)
@@ -17,4 +17,4 @@
| 성공률 | 0.0% |
---
*이 리포트는 2025-08-05 18:06:30.191191에 자동 생성되었습니다.*
*이 리포트는 2025-08-08 18:30:44.518887에 자동 생성되었습니다.*