From 2d27d1bb5c79124c7b304e8ed5656c67c189fe37 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 22 Sep 2025 20:30:08 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EA=B3=A0?= =?UTF-8?q?=EA=B0=9D/=EC=A0=9C=ED=92=88/=EC=B0=BD=EA=B3=A0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/IMPLEMENTATION_TASKS.md | 10 +- lib/core/common/models/paginated_result.dart | 31 + .../customer/data/dtos/customer_dto.dart | 160 +++ .../customer_repository_remote.dart | 76 ++ .../customer/domain/entities/customer.dart | 122 +++ .../repositories/customer_repository.dart | 22 + .../controllers/customer_controller.dart | 162 +++ .../presentation/pages/customer_page.dart | 960 +++++++++++++++++- .../product/data/dtos/product_dto.dart | 171 ++++ .../product_repository_remote.dart | 77 ++ .../product/domain/entities/product.dart | 99 ++ .../repositories/product_repository.dart | 22 + .../controllers/product_controller.dart | 183 ++++ .../presentation/pages/product_page.dart | 957 ++++++++++++++++- .../masters/uom/data/dtos/uom_dto.dart | 70 ++ .../repositories/uom_repository_remote.dart | 35 + .../masters/uom/domain/entities/uom.dart | 43 + .../domain/repositories/uom_repository.dart | 13 + .../masters/vendor/data/dtos/vendor_dto.dart | 57 +- .../vendor_repository_remote.dart | 51 +- .../vendor/domain/entities/vendor.dart | 25 + .../repositories/vendor_repository.dart | 14 +- .../controllers/vendor_controller.dart | 142 +++ .../presentation/pages/vendor_page.dart | 682 +++++++++++-- .../warehouse/data/dtos/warehouse_dto.dart | 142 +++ .../warehouse_repository_remote.dart | 72 ++ .../warehouse/domain/entities/warehouse.dart | 94 ++ .../repositories/warehouse_repository.dart | 20 + .../controllers/warehouse_controller.dart | 133 +++ .../presentation/pages/warehouse_page.dart | 786 +++++++++++++- lib/injection_container.dart | 24 + pubspec.lock | 8 + pubspec.yaml | 1 + .../controllers/customer_controller_test.dart | 163 +++ .../pages/customer_page_test.dart | 215 ++++ .../controllers/product_controller_test.dart | 223 ++++ .../presentation/pages/product_page_test.dart | 281 +++++ .../controllers/vendor_controller_test.dart | 156 +++ .../presentation/pages/vendor_page_test.dart | 182 ++++ .../warehouse_controller_test.dart | 146 +++ .../pages/warehouse_page_test.dart | 193 ++++ 41 files changed, 6764 insertions(+), 259 deletions(-) create mode 100644 lib/core/common/models/paginated_result.dart create mode 100644 lib/features/masters/customer/data/dtos/customer_dto.dart create mode 100644 lib/features/masters/customer/data/repositories/customer_repository_remote.dart create mode 100644 lib/features/masters/customer/domain/entities/customer.dart create mode 100644 lib/features/masters/customer/domain/repositories/customer_repository.dart create mode 100644 lib/features/masters/customer/presentation/controllers/customer_controller.dart create mode 100644 lib/features/masters/product/data/dtos/product_dto.dart create mode 100644 lib/features/masters/product/data/repositories/product_repository_remote.dart create mode 100644 lib/features/masters/product/domain/entities/product.dart create mode 100644 lib/features/masters/product/domain/repositories/product_repository.dart create mode 100644 lib/features/masters/product/presentation/controllers/product_controller.dart create mode 100644 lib/features/masters/uom/data/dtos/uom_dto.dart create mode 100644 lib/features/masters/uom/data/repositories/uom_repository_remote.dart create mode 100644 lib/features/masters/uom/domain/entities/uom.dart create mode 100644 lib/features/masters/uom/domain/repositories/uom_repository.dart create mode 100644 lib/features/masters/vendor/presentation/controllers/vendor_controller.dart create mode 100644 lib/features/masters/warehouse/data/dtos/warehouse_dto.dart create mode 100644 lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart create mode 100644 lib/features/masters/warehouse/domain/entities/warehouse.dart create mode 100644 lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart create mode 100644 lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart create mode 100644 test/features/masters/customer/presentation/controllers/customer_controller_test.dart create mode 100644 test/features/masters/customer/presentation/pages/customer_page_test.dart create mode 100644 test/features/masters/product/presentation/controllers/product_controller_test.dart create mode 100644 test/features/masters/product/presentation/pages/product_page_test.dart create mode 100644 test/features/masters/vendor/presentation/controllers/vendor_controller_test.dart create mode 100644 test/features/masters/vendor/presentation/pages/vendor_page_test.dart create mode 100644 test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart create mode 100644 test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 5e64bcd..39575b8 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -46,10 +46,10 @@ - [ ] 규칙: 대여구분은 종결 후 변경 불가, 반납예정일은 진행 중 수정 가능 ## 6) 마스터(UI) -- [ ] 벤더: 목록/필터(q/사용여부), 신규/수정(코드RO), 삭제/복구 UI -- [ ] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) +- [x] 벤더: 목록/필터(q/사용여부), 신규/수정(코드RO), 삭제/복구 UI +- [x] 제품: 목록/필터(q/제조사/단위/사용), 신규/수정(코드RO) - [ ] 창고: 목록/필터(q/사용), 신규/수정(우편번호 검색 모달 UI 연동) -- [ ] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) +- [x] 고객사: 목록/필터(q/유형/사용), 신규/수정(유형→is_partner/is_general 매핑 UI) - [ ] 사용자: 목록/필터(q/그룹/사용), 신규/수정(사번RO) - [ ] 그룹: 목록/필터(q/기본/사용), 신규/수정(그룹명RO) - [ ] 메뉴: 목록/필터(q/상위/사용), 신규/수정(메뉴코드RO) @@ -101,8 +101,8 @@ - [ ] 모바일 카드형 요약(핵심 3~4필드) 구성 ## 14) 테스트/품질 -- [ ] `flutter analyze` 경고 0 -- [ ] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 +- [x] `flutter analyze` 경고 0 +- [x] 위젯 테스트: 테이블 렌더/필터/페이지네이션/모달 열기/검증 메시지 - [ ] 내비 통합 테스트(선택): 로그인 → 대시보드 → 입/출/대여 → 결재 → 마스터 - [ ] `dart format .` 적용 diff --git a/lib/core/common/models/paginated_result.dart b/lib/core/common/models/paginated_result.dart new file mode 100644 index 0000000..8dba721 --- /dev/null +++ b/lib/core/common/models/paginated_result.dart @@ -0,0 +1,31 @@ +/// 공통 페이지네이션 결과 모델 +/// +/// - API 응답 형식 `{ "items": [...], "page": 1, "page_size": 20, "total": 40 }`에 대응 +/// - 리스트 화면에서 페이지 정보와 총 건수를 함께 활용하기 위함 +class PaginatedResult { + PaginatedResult({ + required this.items, + required this.page, + required this.pageSize, + required this.total, + }); + + final List items; + final int page; + final int pageSize; + final int total; + + PaginatedResult copyWith({ + List? items, + int? page, + int? pageSize, + int? total, + }) { + return PaginatedResult( + items: items ?? this.items, + page: page ?? this.page, + pageSize: pageSize ?? this.pageSize, + total: total ?? this.total, + ); + } +} diff --git a/lib/features/masters/customer/data/dtos/customer_dto.dart b/lib/features/masters/customer/data/dtos/customer_dto.dart new file mode 100644 index 0000000..1ca7573 --- /dev/null +++ b/lib/features/masters/customer/data/dtos/customer_dto.dart @@ -0,0 +1,160 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/customer.dart'; + +class CustomerDto { + CustomerDto({ + this.id, + required this.customerCode, + required this.customerName, + this.isPartner = false, + this.isGeneral = true, + this.email, + this.mobileNo, + this.zipcode, + this.addressDetail, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String customerCode; + final String customerName; + final bool isPartner; + final bool isGeneral; + final String? email; + final String? mobileNo; + final CustomerZipcodeDto? zipcode; + final String? addressDetail; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory CustomerDto.fromJson(Map json) { + return CustomerDto( + id: json['id'] as int?, + customerCode: json['customer_code'] as String, + customerName: json['customer_name'] as String, + isPartner: (json['is_partner'] as bool?) ?? false, + isGeneral: (json['is_general'] as bool?) ?? true, + email: json['email'] as String?, + mobileNo: json['mobile_no'] as String?, + zipcode: json['zipcode'] is Map + ? CustomerZipcodeDto.fromJson(json['zipcode'] as Map) + : null, + addressDetail: json['address_detail'] as String?, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'customer_code': customerCode, + 'customer_name': customerName, + 'is_partner': isPartner, + 'is_general': isGeneral, + 'email': email, + 'mobile_no': mobileNo, + 'zipcode': zipcode?.toJson(), + 'address_detail': addressDetail, + 'is_active': isActive, + 'is_deleted': isDeleted, + 'note': note, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + Customer toEntity() => Customer( + id: id, + customerCode: customerCode, + customerName: customerName, + isPartner: isPartner, + isGeneral: isGeneral, + email: email, + mobileNo: mobileNo, + zipcode: zipcode?.toEntity(), + addressDetail: addressDetail, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(CustomerDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +class CustomerZipcodeDto { + CustomerZipcodeDto({ + required this.zipcode, + this.sido, + this.sigungu, + this.roadName, + }); + + final String zipcode; + final String? sido; + final String? sigungu; + final String? roadName; + + factory CustomerZipcodeDto.fromJson(Map json) { + return CustomerZipcodeDto( + zipcode: json['zipcode'] as String, + sido: json['sido'] as String?, + sigungu: json['sigungu'] as String?, + roadName: json['road_name'] as String?, + ); + } + + Map toJson() { + return { + 'zipcode': zipcode, + 'sido': sido, + 'sigungu': sigungu, + 'road_name': roadName, + }; + } + + CustomerZipcode toEntity() => CustomerZipcode( + zipcode: zipcode, + sido: sido, + sigungu: sigungu, + roadName: roadName, + ); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} + +Map customerInputToJson(CustomerInput input) { + final map = input.toPayload(); + map.removeWhere((key, value) => value == null); + return map; +} diff --git a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart new file mode 100644 index 0000000..2b8743f --- /dev/null +++ b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart @@ -0,0 +1,76 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/customer.dart'; +import '../../domain/repositories/customer_repository.dart'; +import '../dtos/customer_dto.dart'; + +class CustomerRepositoryRemote implements CustomerRepository { + CustomerRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/customers'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isPartner, + bool? isGeneral, + bool? isActive, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (isPartner != null) 'is_partner': isPartner, + if (isGeneral != null) 'is_general': isGeneral, + if (isActive != null) 'is_active': isActive, + }, + options: Options(responseType: ResponseType.json), + ); + return CustomerDto.parsePaginated(response.data ?? const {}); + } + + @override + Future create(CustomerInput input) async { + final response = await _api.post>( + _basePath, + data: customerInputToJson(input), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return CustomerDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, CustomerInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: customerInputToJson(input), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return CustomerDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return CustomerDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/masters/customer/domain/entities/customer.dart b/lib/features/masters/customer/domain/entities/customer.dart new file mode 100644 index 0000000..98ae15a --- /dev/null +++ b/lib/features/masters/customer/domain/entities/customer.dart @@ -0,0 +1,122 @@ +class Customer { + Customer({ + this.id, + required this.customerCode, + required this.customerName, + this.isPartner = false, + this.isGeneral = true, + this.email, + this.mobileNo, + this.zipcode, + this.addressDetail, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String customerCode; + final String customerName; + final bool isPartner; + final bool isGeneral; + final String? email; + final String? mobileNo; + final CustomerZipcode? zipcode; + final String? addressDetail; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + Customer copyWith({ + int? id, + String? customerCode, + String? customerName, + bool? isPartner, + bool? isGeneral, + String? email, + String? mobileNo, + CustomerZipcode? zipcode, + String? addressDetail, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Customer( + id: id ?? this.id, + customerCode: customerCode ?? this.customerCode, + customerName: customerName ?? this.customerName, + isPartner: isPartner ?? this.isPartner, + isGeneral: isGeneral ?? this.isGeneral, + email: email ?? this.email, + mobileNo: mobileNo ?? this.mobileNo, + zipcode: zipcode ?? this.zipcode, + addressDetail: addressDetail ?? this.addressDetail, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class CustomerZipcode { + CustomerZipcode({ + required this.zipcode, + this.sido, + this.sigungu, + this.roadName, + }); + + final String zipcode; + final String? sido; + final String? sigungu; + final String? roadName; +} + +class CustomerInput { + CustomerInput({ + required this.customerCode, + required this.customerName, + required this.isPartner, + required this.isGeneral, + this.email, + this.mobileNo, + this.zipcode, + this.addressDetail, + this.isActive = true, + this.note, + }); + + final String customerCode; + final String customerName; + final bool isPartner; + final bool isGeneral; + final String? email; + final String? mobileNo; + final String? zipcode; + final String? addressDetail; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'customer_code': customerCode, + 'customer_name': customerName, + 'is_partner': isPartner, + 'is_general': isGeneral, + 'email': email, + 'mobile_no': mobileNo, + 'zipcode': zipcode, + 'address_detail': addressDetail, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/customer/domain/repositories/customer_repository.dart b/lib/features/masters/customer/domain/repositories/customer_repository.dart new file mode 100644 index 0000000..a4bbc7c --- /dev/null +++ b/lib/features/masters/customer/domain/repositories/customer_repository.dart @@ -0,0 +1,22 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/customer.dart'; + +abstract class CustomerRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isPartner, + bool? isGeneral, + bool? isActive, + }); + + Future create(CustomerInput input); + + Future update(int id, CustomerInput input); + + Future delete(int id); + + Future restore(int id); +} diff --git a/lib/features/masters/customer/presentation/controllers/customer_controller.dart b/lib/features/masters/customer/presentation/controllers/customer_controller.dart new file mode 100644 index 0000000..a258f36 --- /dev/null +++ b/lib/features/masters/customer/presentation/controllers/customer_controller.dart @@ -0,0 +1,162 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/customer.dart'; +import '../../domain/repositories/customer_repository.dart'; + +enum CustomerTypeFilter { all, partner, general } + +enum CustomerStatusFilter { all, activeOnly, inactiveOnly } + +class CustomerController extends ChangeNotifier { + CustomerController({required CustomerRepository repository}) + : _repository = repository; + + final CustomerRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + String _query = ''; + CustomerTypeFilter _typeFilter = CustomerTypeFilter.all; + CustomerStatusFilter _statusFilter = CustomerStatusFilter.all; + String? _errorMessage; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + String get query => _query; + CustomerTypeFilter get typeFilter => _typeFilter; + CustomerStatusFilter get statusFilter => _statusFilter; + String? get errorMessage => _errorMessage; + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + bool? isPartner; + bool? isGeneral; + switch (_typeFilter) { + case CustomerTypeFilter.all: + isPartner = null; + isGeneral = null; + break; + case CustomerTypeFilter.partner: + isPartner = true; + isGeneral = null; + break; + case CustomerTypeFilter.general: + isPartner = null; + isGeneral = true; + break; + } + + final isActive = switch (_statusFilter) { + CustomerStatusFilter.all => null, + CustomerStatusFilter.activeOnly => true, + CustomerStatusFilter.inactiveOnly => false, + }; + + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + isPartner: isPartner, + isGeneral: isGeneral, + isActive: isActive, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateTypeFilter(CustomerTypeFilter filter) { + _typeFilter = filter; + notifyListeners(); + } + + void updateStatusFilter(CustomerStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + Future create(CustomerInput input) async { + _setSubmitting(true); + try { + final created = await _repository.create(input); + await fetch(page: 1); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update(int id, CustomerInput input) async { + _setSubmitting(true); + try { + final updated = await _repository.update(id, input); + await fetch(page: _result?.page ?? 1); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _repository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _repository.restore(id); + await fetch(page: _result?.page ?? 1); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index fc9cb05..19acc41 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -1,65 +1,927 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../domain/entities/customer.dart'; +import '../../domain/repositories/customer_repository.dart'; +import '../controllers/customer_controller.dart'; class CustomerPage extends StatelessWidget { const CustomerPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '회사(고객사) 관리', - summary: '고객사 기본 정보와 연락처, 주소를 관리합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '고객사코드 [Text]', - '고객사명 [Text]', - '유형 (파트너/일반) [Dropdown]', - '이메일 [Text]', - '연락처 [Text]', - '우편번호 [검색 연동], 상세주소 [Text]', - '사용여부 [Switch]', - '비고 [Text]', - ], - ), - SpecSection( - title: '수정 폼', - items: ['고객사코드 [ReadOnly]', '생성일시 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: [ - '번호', - '고객사코드', - '고객사명', - '유형', - '이메일', - '연락처', - '우편번호', - '상세주소', - '사용여부', - '비고', + final enabled = Environment.flag('FEATURE_CUSTOMERS_ENABLED'); + if (!enabled) { + return const SpecPage( + title: '회사(고객사) 관리', + summary: '고객사 기본 정보와 연락처, 주소를 관리합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '고객사코드 [Text]', + '고객사명 [Text]', + '유형 (파트너/일반) [Dropdown]', + '이메일 [Text]', + '연락처 [Text]', + '우편번호 [검색 연동], 상세주소 [Text]', + '사용여부 [Switch]', + '비고 [Text]', ], - rows: [ - [ - '1', - 'C-001', - '슈퍼포트 파트너', - '파트너', - 'partner@superport.com', - '02-1234-5678', - '04532', - '서울시 중구 을지로 100', - 'Y', - '-', + ), + SpecSection( + title: '수정 폼', + items: ['고객사코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '고객사코드', + '고객사명', + '유형', + '이메일', + '연락처', + '우편번호', + '상세주소', + '사용여부', + '비고', ], + rows: [ + [ + '1', + 'C-001', + '슈퍼포트 파트너', + '파트너', + 'partner@superport.com', + '02-1234-5678', + '04532', + '서울시 중구 을지로 100', + 'Y', + '-', + ], + ], + ), + ), + ], + ); + } + + return const _CustomerEnabledPage(); + } +} + +class _CustomerEnabledPage extends StatefulWidget { + const _CustomerEnabledPage(); + + @override + State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState(); +} + +class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { + late final CustomerController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = CustomerController(repository: GetIt.I()) + ..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.fetch(); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final customers = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('회사(고객사) 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '고객사 기본 정보와 연락처, 주소를 관리합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + const SizedBox(width: 16), + ShadButton( + onPressed: _controller.isSubmitting + ? null + : () => _openCustomerForm(context), + child: const Text('신규 등록'), + ), + ], + ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 및 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('고객사코드, 고객사명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.typeFilter), + initialValue: _controller.typeFilter, + selectedOptionBuilder: (context, value) => + Text(_typeLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateTypeFilter(value); + }, + options: CustomerTypeFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_typeLabel(filter)), + ), + ) + .toList(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, value) => + Text(_statusLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: CustomerStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading + ? null + : _applyFilters, + child: const Text('검색 적용'), + ), + if (_searchController.text.isNotEmpty || + _controller.typeFilter != CustomerTypeFilter.all || + _controller.statusFilter != + CustomerStatusFilter.all) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateTypeFilter( + CustomerTypeFilter.all, + ); + _controller.updateStatusFilter( + CustomerStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('고객사 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : customers.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 고객사가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _CustomerTable( + customers: customers, + onEdit: _controller.isSubmitting + ? null + : (customer) => _openCustomerForm( + context, + customer: customer, + ), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreCustomer, + ), + ), + ], + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + String _typeLabel(CustomerTypeFilter filter) { + switch (filter) { + case CustomerTypeFilter.all: + return '전체(파트너/일반)'; + case CustomerTypeFilter.partner: + return '파트너'; + case CustomerTypeFilter.general: + return '일반'; + } + } + + String _statusLabel(CustomerStatusFilter filter) { + switch (filter) { + case CustomerStatusFilter.all: + return '전체(사용/미사용)'; + case CustomerStatusFilter.activeOnly: + return '사용중'; + case CustomerStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openCustomerForm( + BuildContext context, { + Customer? customer, + }) async { + final existing = customer; + final isEdit = existing != null; + final customerId = existing?.id; + if (isEdit && customerId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + final parentContext = context; + + final codeController = TextEditingController( + text: existing?.customerCode ?? '', + ); + final nameController = TextEditingController( + text: existing?.customerName ?? '', + ); + final emailController = TextEditingController(text: existing?.email ?? ''); + final mobileController = TextEditingController( + text: existing?.mobileNo ?? '', + ); + final zipcodeController = TextEditingController( + text: existing?.zipcode?.zipcode ?? '', + ); + final addressController = TextEditingController( + text: existing?.addressDetail ?? '', + ); + final noteController = TextEditingController(text: existing?.note ?? ''); + final partnerNotifier = ValueNotifier(existing?.isPartner ?? false); + final generalNotifier = ValueNotifier(existing?.isGeneral ?? true); + final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); + final saving = ValueNotifier(false); + final codeError = ValueNotifier(null); + final nameError = ValueNotifier(null); + final typeError = ValueNotifier(null); + + await showDialog( + context: parentContext, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ShadCard( + title: Text( + isEdit ? '고객사 수정' : '고객사 등록', + style: theme.textTheme.h3, + ), + description: Text( + '고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final email = emailController.text.trim(); + final mobile = mobileController.text.trim(); + final zipcode = zipcodeController.text.trim(); + final address = addressController.text.trim(); + final note = noteController.text.trim(); + final partner = partnerNotifier.value; + var general = generalNotifier.value; + + codeError.value = code.isEmpty + ? '고객사코드를 입력하세요.' + : null; + nameError.value = name.isEmpty + ? '고객사명을 입력하세요.' + : null; + + if (!partner && !general) { + general = true; + generalNotifier.value = true; + } + + typeError.value = (!partner && !general) + ? '파트너/일반 중 하나 이상 선택하세요.' + : null; + + if (codeError.value != null || + nameError.value != null || + typeError.value != null) { + return; + } + + saving.value = true; + final input = CustomerInput( + customerCode: code, + customerName: name, + isPartner: partner, + isGeneral: general, + email: email.isEmpty ? null : email, + mobileNo: mobile.isEmpty ? null : mobile, + zipcode: zipcode.isEmpty ? null : zipcode, + addressDetail: address.isEmpty + ? null + : address, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final response = isEdit + ? await _controller.update( + customerId!, + input, + ) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], + ); + }, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '고객사코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '고객사명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: partnerNotifier, + builder: (_, partner, __) { + return ValueListenableBuilder( + valueListenable: generalNotifier, + builder: (_, general, __) { + return ValueListenableBuilder( + valueListenable: typeError, + builder: (_, errorText, __) { + final onChanged = saving.value + ? null + : (bool? value) { + if (value == null) return; + partnerNotifier.value = value; + if (!value && !generalNotifier.value) { + typeError.value = + '파트너/일반 중 하나 이상 선택하세요.'; + } else { + typeError.value = null; + } + }; + final onChangedGeneral = saving.value + ? null + : (bool? value) { + if (value == null) return; + generalNotifier.value = value; + if (!value && !partnerNotifier.value) { + typeError.value = + '파트너/일반 중 하나 이상 선택하세요.'; + } else { + typeError.value = null; + } + }; + return _FormField( + label: '유형', + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShadCheckbox( + value: partner, + onChanged: onChanged, + ), + const SizedBox(width: 8), + const Text('파트너'), + const SizedBox(width: 24), + ShadCheckbox( + value: general, + onChanged: onChangedGeneral, + ), + const SizedBox(width: 8), + const Text('일반'), + ], + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only( + top: 6, + ), + child: Text( + errorText, + style: theme.textTheme.small + .copyWith( + color: materialTheme + .colorScheme + .error, + ), + ), + ), + ], + ), + ); + }, + ); + }, + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '이메일', + child: ShadInput( + controller: emailController, + keyboardType: TextInputType.emailAddress, + ), + ), + const SizedBox(height: 16), + _FormField( + label: '연락처', + child: ShadInput( + controller: mobileController, + keyboardType: TextInputType.phone, + ), + ), + const SizedBox(height: 16), + _FormField( + label: '우편번호', + child: ShadInput( + controller: zipcodeController, + placeholder: const Text('예: 06000'), + ), + ), + const SizedBox(height: 16), + _FormField( + label: '상세주소', + child: ShadInput( + controller: addressController, + placeholder: const Text('상세주소 입력'), + ), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (existing != null) + ..._buildAuditInfo( + existing, + theme, + ), + ], + ), + ), + ), + ), + ); + }, + ); + + codeController.dispose(); + nameController.dispose(); + emailController.dispose(); + mobileController.dispose(); + zipcodeController.dispose(); + addressController.dispose(); + noteController.dispose(); + partnerNotifier.dispose(); + generalNotifier.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + codeError.dispose(); + nameError.dispose(); + typeError.dispose(); + } + + Future _confirmDelete(Customer customer) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('고객사 삭제'), + content: Text('"${customer.customerName}" 고객사를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && customer.id != null) { + final success = await _controller.delete(customer.id!); + if (success && mounted) { + _showSnack('고객사를 삭제했습니다.'); + } + } + } + + Future _restoreCustomer(Customer customer) async { + if (customer.id == null) return; + final restored = await _controller.restore(customer.id!); + if (restored != null && mounted) { + _showSnack('고객사를 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatDateTime(DateTime? value) { + if (value == null) return '-'; + return value.toLocal().toIso8601String(); + } + + List _buildAuditInfo(Customer customer, ShadThemeData theme) { + return [ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(customer.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(customer.updatedAt)}', + style: theme.textTheme.small, + ), + ]; + } +} + +class _CustomerTable extends StatelessWidget { + const _CustomerTable({ + required this.customers, + required this.onEdit, + required this.onDelete, + required this.onRestore, + }); + + final List customers; + final void Function(Customer customer)? onEdit; + final void Function(Customer customer)? onDelete; + final void Function(Customer customer)? onRestore; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '고객사코드', + '고객사명', + '유형', + '이메일', + '연락처', + '우편번호', + '상세주소', + '사용', + '삭제', + '비고', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + String resolveType(Customer customer) { + if (customer.isPartner && customer.isGeneral) { + return '파트너/일반'; + } + if (customer.isPartner) return '파트너'; + if (customer.isGeneral) return '일반'; + return '-'; + } + + final rows = customers.map((customer) { + return [ + customer.id?.toString() ?? '-', + customer.customerCode, + customer.customerName, + resolveType(customer), + customer.email?.isEmpty ?? true ? '-' : customer.email!, + customer.mobileNo?.isEmpty ?? true ? '-' : customer.mobileNo!, + customer.zipcode?.zipcode ?? '-', + customer.addressDetail?.isEmpty ?? true ? '-' : customer.addressDetail!, + customer.isActive ? 'Y' : 'N', + customer.isDeleted ? 'Y' : '-', + customer.note?.isEmpty ?? true ? '-' : customer.note!, + ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(customer), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + customer.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(customer), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(customer), + child: const Icon(LucideIcons.trash2, size: 16), + ), ], ), ), + ); + }).toList(); + + return SizedBox( + height: 56.0 * (customers.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => index == 11 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), + ), + ); + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, ], ); } diff --git a/lib/features/masters/product/data/dtos/product_dto.dart b/lib/features/masters/product/data/dtos/product_dto.dart new file mode 100644 index 0000000..b7b8ae2 --- /dev/null +++ b/lib/features/masters/product/data/dtos/product_dto.dart @@ -0,0 +1,171 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/product.dart'; + +class ProductDto { + ProductDto({ + this.id, + required this.productCode, + required this.productName, + this.vendor, + this.uom, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String productCode; + final String productName; + final ProductVendorDto? vendor; + final ProductUomDto? uom; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory ProductDto.fromJson(Map json) { + return ProductDto( + id: json['id'] as int?, + productCode: json['product_code'] as String, + productName: json['product_name'] as String, + vendor: json['vendor'] is Map + ? ProductVendorDto.fromJson(json['vendor'] as Map) + : null, + uom: json['uom'] is Map + ? ProductUomDto.fromJson(json['uom'] as Map) + : null, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'product_code': productCode, + 'product_name': productName, + 'vendor': vendor?.toJson(), + 'uom': uom?.toJson(), + 'is_active': isActive, + 'is_deleted': isDeleted, + 'note': note, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + Product toEntity() => Product( + id: id, + productCode: productCode, + productName: productName, + vendor: vendor?.toEntity(), + uom: uom?.toEntity(), + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static ProductDto fromEntity(Product entity) => ProductDto( + id: entity.id, + productCode: entity.productCode, + productName: entity.productName, + vendor: entity.vendor == null + ? null + : ProductVendorDto( + id: entity.vendor!.id, + vendorCode: entity.vendor!.vendorCode, + vendorName: entity.vendor!.vendorName, + ), + uom: entity.uom == null + ? null + : ProductUomDto(id: entity.uom!.id, uomName: entity.uom!.uomName), + isActive: entity.isActive, + isDeleted: entity.isDeleted, + note: entity.note, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(ProductDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +class ProductVendorDto { + ProductVendorDto({ + required this.id, + required this.vendorCode, + required this.vendorName, + }); + + final int id; + final String vendorCode; + final String vendorName; + + factory ProductVendorDto.fromJson(Map json) { + return ProductVendorDto( + id: json['id'] as int, + vendorCode: json['vendor_code'] as String, + vendorName: json['vendor_name'] as String, + ); + } + + Map toJson() { + return {'id': id, 'vendor_code': vendorCode, 'vendor_name': vendorName}; + } + + ProductVendor toEntity() => + ProductVendor(id: id, vendorCode: vendorCode, vendorName: vendorName); +} + +class ProductUomDto { + ProductUomDto({required this.id, required this.uomName}); + + final int id; + final String uomName; + + factory ProductUomDto.fromJson(Map json) { + return ProductUomDto( + id: json['id'] as int, + uomName: json['uom_name'] as String, + ); + } + + Map toJson() { + return {'id': id, 'uom_name': uomName}; + } + + ProductUom toEntity() => ProductUom(id: id, uomName: uomName); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} + +Map productInputToJson(ProductInput input) { + final map = input.toPayload(); + map.removeWhere((key, value) => value == null); + return map; +} diff --git a/lib/features/masters/product/data/repositories/product_repository_remote.dart b/lib/features/masters/product/data/repositories/product_repository_remote.dart new file mode 100644 index 0000000..aef0568 --- /dev/null +++ b/lib/features/masters/product/data/repositories/product_repository_remote.dart @@ -0,0 +1,77 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/product.dart'; +import '../../domain/repositories/product_repository.dart'; +import '../dtos/product_dto.dart'; + +class ProductRepositoryRemote implements ProductRepository { + ProductRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/products'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? vendorId, + int? uomId, + bool? isActive, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (vendorId != null) 'vendor_id': vendorId, + if (uomId != null) 'uom_id': uomId, + if (isActive != null) 'is_active': isActive, + 'include': 'vendor,uom', + }, + options: Options(responseType: ResponseType.json), + ); + return ProductDto.parsePaginated(response.data ?? const {}); + } + + @override + Future create(ProductInput input) async { + final response = await _api.post>( + _basePath, + data: productInputToJson(input), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ProductDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, ProductInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: productInputToJson(input), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ProductDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ProductDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/masters/product/domain/entities/product.dart b/lib/features/masters/product/domain/entities/product.dart new file mode 100644 index 0000000..64fa6fb --- /dev/null +++ b/lib/features/masters/product/domain/entities/product.dart @@ -0,0 +1,99 @@ +class Product { + Product({ + this.id, + required this.productCode, + required this.productName, + this.vendor, + this.uom, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String productCode; + final String productName; + final ProductVendor? vendor; + final ProductUom? uom; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + Product copyWith({ + int? id, + String? productCode, + String? productName, + ProductVendor? vendor, + ProductUom? uom, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Product( + id: id ?? this.id, + productCode: productCode ?? this.productCode, + productName: productName ?? this.productName, + vendor: vendor ?? this.vendor, + uom: uom ?? this.uom, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class ProductVendor { + ProductVendor({ + required this.id, + required this.vendorCode, + required this.vendorName, + }); + + final int id; + final String vendorCode; + final String vendorName; +} + +class ProductUom { + ProductUom({required this.id, required this.uomName}); + + final int id; + final String uomName; +} + +class ProductInput { + ProductInput({ + required this.productCode, + required this.productName, + required this.vendorId, + required this.uomId, + this.isActive = true, + this.note, + }); + + final String productCode; + final String productName; + final int vendorId; + final int uomId; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'product_code': productCode, + 'product_name': productName, + 'vendor_id': vendorId, + 'uom_id': uomId, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/product/domain/repositories/product_repository.dart b/lib/features/masters/product/domain/repositories/product_repository.dart new file mode 100644 index 0000000..1945ea0 --- /dev/null +++ b/lib/features/masters/product/domain/repositories/product_repository.dart @@ -0,0 +1,22 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/product.dart'; + +abstract class ProductRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? vendorId, + int? uomId, + bool? isActive, + }); + + Future create(ProductInput input); + + Future update(int id, ProductInput input); + + Future delete(int id); + + Future restore(int id); +} diff --git a/lib/features/masters/product/presentation/controllers/product_controller.dart b/lib/features/masters/product/presentation/controllers/product_controller.dart new file mode 100644 index 0000000..21f2594 --- /dev/null +++ b/lib/features/masters/product/presentation/controllers/product_controller.dart @@ -0,0 +1,183 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../../vendor/domain/entities/vendor.dart'; +import '../../../vendor/domain/repositories/vendor_repository.dart'; +import '../../../uom/domain/entities/uom.dart'; +import '../../../uom/domain/repositories/uom_repository.dart'; +import '../../domain/entities/product.dart'; +import '../../domain/repositories/product_repository.dart'; + +enum ProductStatusFilter { all, activeOnly, inactiveOnly } + +class ProductController extends ChangeNotifier { + ProductController({ + required ProductRepository productRepository, + required VendorRepository vendorRepository, + required UomRepository uomRepository, + }) : _productRepository = productRepository, + _vendorRepository = vendorRepository, + _uomRepository = uomRepository; + + final ProductRepository _productRepository; + final VendorRepository _vendorRepository; + final UomRepository _uomRepository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + bool _isLoadingLookups = false; + String _query = ''; + int? _vendorFilter; + int? _uomFilter; + ProductStatusFilter _statusFilter = ProductStatusFilter.all; + String? _errorMessage; + + List _vendorOptions = const []; + List _uomOptions = const []; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + bool get isLoadingLookups => _isLoadingLookups; + String get query => _query; + int? get vendorFilter => _vendorFilter; + int? get uomFilter => _uomFilter; + ProductStatusFilter get statusFilter => _statusFilter; + String? get errorMessage => _errorMessage; + List get vendorOptions => _vendorOptions; + List get uomOptions => _uomOptions; + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final isActive = switch (_statusFilter) { + ProductStatusFilter.all => null, + ProductStatusFilter.activeOnly => true, + ProductStatusFilter.inactiveOnly => false, + }; + final response = await _productRepository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + vendorId: _vendorFilter, + uomId: _uomFilter, + isActive: isActive, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future loadLookups() async { + _isLoadingLookups = true; + notifyListeners(); + try { + final vendorResult = await _vendorRepository.list(page: 1, pageSize: 100); + final uomResult = await _uomRepository.list(page: 1, pageSize: 100); + _vendorOptions = vendorResult.items; + _uomOptions = uomResult.items; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoadingLookups = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateVendorFilter(int? vendorId) { + _vendorFilter = vendorId; + notifyListeners(); + } + + void updateUomFilter(int? uomId) { + _uomFilter = uomId; + notifyListeners(); + } + + void updateStatusFilter(ProductStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + Future create(ProductInput input) async { + _setSubmitting(true); + try { + final created = await _productRepository.create(input); + await fetch(page: 1); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update(int id, ProductInput input) async { + _setSubmitting(true); + try { + final updated = await _productRepository.update(id, input); + await fetch(page: _result?.page ?? 1); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _productRepository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _productRepository.restore(id); + await fetch(page: _result?.page ?? 1); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 439c6ec..1224b19 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -1,50 +1,937 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../../uom/domain/entities/uom.dart'; +import '../../../uom/domain/repositories/uom_repository.dart'; +import '../../../vendor/domain/entities/vendor.dart'; +import '../../../vendor/domain/repositories/vendor_repository.dart'; +import '../../domain/entities/product.dart'; +import '../../domain/repositories/product_repository.dart'; +import '../controllers/product_controller.dart'; class ProductPage extends StatelessWidget { const ProductPage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '장비 모델(제품) 관리', - summary: '제품 코드, 제조사, 단위 정보를 유지하여 재고 라인과 연계합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '제품코드 [Text]', - '제품명 [Text]', - '제조사 [Dropdown]', - '단위 [Dropdown]', - '사용여부 [Switch]', - '비고 [Text]', - ], - ), - SpecSection( - title: '수정 폼', - items: ['제품코드 [ReadOnly]', '생성일시 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: ['번호', '제품코드', '제품명', '제조사', '단위', '사용여부', '비고', '변경일시'], - rows: [ - [ - '1', - 'P-100', - 'XR-5000', - '슈퍼벤더', - 'EA', - 'Y', - '-', - '2024-03-01 10:00', + final enabled = Environment.flag('FEATURE_PRODUCTS_ENABLED'); + if (!enabled) { + return const SpecPage( + title: '장비 모델(제품) 관리', + summary: '제품 코드, 제조사, 단위 정보를 유지하여 재고 라인과 연계합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '제품코드 [Text]', + '제품명 [Text]', + '제조사 [Dropdown]', + '단위 [Dropdown]', + '사용여부 [Switch]', + '비고 [Text]', + ], + ), + SpecSection( + title: '수정 폼', + items: ['제품코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: ['번호', '제품코드', '제품명', '제조사', '단위', '사용여부', '비고', '변경일시'], + rows: [ + [ + '1', + 'P-100', + 'XR-5000', + '슈퍼벤더', + 'EA', + 'Y', + '-', + '2024-03-01 10:00', + ], ], + ), + ), + ], + ); + } + + return const _ProductEnabledPage(); + } +} + +class _ProductEnabledPage extends StatefulWidget { + const _ProductEnabledPage(); + + @override + State<_ProductEnabledPage> createState() => _ProductEnabledPageState(); +} + +class _ProductEnabledPageState extends State<_ProductEnabledPage> { + late final ProductController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + bool _lookupsLoaded = false; + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = ProductController( + productRepository: GetIt.I(), + vendorRepository: GetIt.I(), + uomRepository: GetIt.I(), + )..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.wait([_controller.loadLookups(), _controller.fetch()]); + setState(() { + _lookupsLoaded = true; + }); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final products = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('장비 모델(제품) 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '제품코드, 제조사, 단위 정보를 관리합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + const SizedBox(width: 16), + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openProductForm(context), + child: const Text('신규 등록'), + ), + ], + ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 및 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('제품코드, 제품명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.vendorFilter), + initialValue: _controller.vendorFilter, + placeholder: const Text('제조사 전체'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('제조사 전체'); + } + final vendor = _controller.vendorOptions + .firstWhere( + (v) => v.id == value, + orElse: () => Vendor( + id: value, + vendorCode: '', + vendorName: '', + ), + ); + return Text(vendor.vendorName); + }, + onChanged: (value) { + _controller.updateVendorFilter(value); + }, + options: [ + const ShadOption( + value: null, + child: Text('제조사 전체'), + ), + ..._controller.vendorOptions.map( + (vendor) => ShadOption( + value: vendor.id, + child: Text(vendor.vendorName), + ), + ), + ], + ), + ), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.uomFilter), + initialValue: _controller.uomFilter, + placeholder: const Text('단위 전체'), + selectedOptionBuilder: (context, value) { + if (value == null) { + return const Text('단위 전체'); + } + final uom = _controller.uomOptions.firstWhere( + (u) => u.id == value, + orElse: () => Uom(id: value, uomName: ''), + ); + return Text(uom.uomName); + }, + onChanged: (value) { + _controller.updateUomFilter(value); + }, + options: [ + const ShadOption( + value: null, + child: Text('단위 전체'), + ), + ..._controller.uomOptions.map( + (uom) => ShadOption( + value: uom.id, + child: Text(uom.uomName), + ), + ), + ], + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: ProductStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading + ? null + : _applyFilters, + child: const Text('검색 적용'), + ), + if (_searchController.text.isNotEmpty || + _controller.vendorFilter != null || + _controller.uomFilter != null || + _controller.statusFilter != ProductStatusFilter.all) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateVendorFilter(null); + _controller.updateUomFilter(null); + _controller.updateStatusFilter( + ProductStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('제품 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : products.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 제품이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _ProductTable( + products: products, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (product) => + _openProductForm(context, product: product), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreProduct, + ), + ), + ], + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + String _statusLabel(ProductStatusFilter filter) { + switch (filter) { + case ProductStatusFilter.all: + return '전체(사용/미사용)'; + case ProductStatusFilter.activeOnly: + return '사용중'; + case ProductStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openProductForm( + BuildContext context, { + Product? product, + }) async { + final existing = product; + final isEdit = existing != null; + final productId = existing?.id; + if (isEdit && productId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + if (!_lookupsLoaded) { + _showSnack('선택 목록을 불러오는 중입니다. 잠시 후 다시 시도하세요.'); + return; + } + + final parentContext = context; + + final codeController = TextEditingController( + text: existing?.productCode ?? '', + ); + final nameController = TextEditingController( + text: existing?.productName ?? '', + ); + final noteController = TextEditingController(text: existing?.note ?? ''); + final vendorNotifier = ValueNotifier(existing?.vendor?.id); + final uomNotifier = ValueNotifier(existing?.uom?.id); + final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); + final saving = ValueNotifier(false); + final codeError = ValueNotifier(null); + final nameError = ValueNotifier(null); + final vendorError = ValueNotifier(null); + final uomError = ValueNotifier(null); + + await showDialog( + context: parentContext, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ShadCard( + title: Text( + isEdit ? '제품 수정' : '제품 등록', + style: theme.textTheme.h3, + ), + description: Text( + '제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final note = noteController.text.trim(); + final vendorId = vendorNotifier.value; + final uomId = uomNotifier.value; + + codeError.value = code.isEmpty + ? '제품코드를 입력하세요.' + : null; + nameError.value = name.isEmpty + ? '제품명을 입력하세요.' + : null; + vendorError.value = vendorId == null + ? '제조사를 선택하세요.' + : null; + uomError.value = uomId == null + ? '단위를 선택하세요.' + : null; + + if (codeError.value != null || + nameError.value != null || + vendorError.value != null || + uomError.value != null) { + return; + } + + saving.value = true; + final input = ProductInput( + productCode: code, + productName: name, + vendorId: vendorId!, + uomId: uomId!, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final response = isEdit + ? await _controller.update( + productId!, + input, + ) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '제품을 수정했습니다.' : '제품을 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], + ); + }, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '제품코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '제품명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: vendorNotifier, + builder: (_, value, __) { + return ValueListenableBuilder( + valueListenable: vendorError, + builder: (_, errorText, __) { + return _FormField( + label: '제조사', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: value, + onChanged: saving.value + ? null + : (next) { + vendorNotifier.value = next; + vendorError.value = null; + }, + options: _controller.vendorOptions + .map( + (vendor) => ShadOption( + value: vendor.id, + child: Text(vendor.vendorName), + ), + ) + .toList(), + placeholder: const Text('제조사를 선택하세요'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('제조사를 선택하세요'); + } + final vendor = _controller.vendorOptions + .firstWhere( + (v) => v.id == selected, + orElse: () => Vendor( + id: selected, + vendorCode: '', + vendorName: '', + ), + ); + return Text(vendor.vendorName); + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: + materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: uomNotifier, + builder: (_, value, __) { + return ValueListenableBuilder( + valueListenable: uomError, + builder: (_, errorText, __) { + return _FormField( + label: '단위', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: value, + onChanged: saving.value + ? null + : (next) { + uomNotifier.value = next; + uomError.value = null; + }, + options: _controller.uomOptions + .map( + (uom) => ShadOption( + value: uom.id, + child: Text(uom.uomName), + ), + ) + .toList(), + placeholder: const Text('단위를 선택하세요'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('단위를 선택하세요'); + } + final uom = _controller.uomOptions + .firstWhere( + (u) => u.id == selected, + orElse: () => + Uom(id: selected, uomName: ''), + ); + return Text(uom.uomName); + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: + materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existing.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existing.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + + codeController.dispose(); + nameController.dispose(); + noteController.dispose(); + vendorNotifier.dispose(); + uomNotifier.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + codeError.dispose(); + nameError.dispose(); + vendorError.dispose(); + uomError.dispose(); + } + + Future _confirmDelete(Product product) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('제품 삭제'), + content: Text('"${product.productName}" 제품을 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && product.id != null) { + final success = await _controller.delete(product.id!); + if (success && mounted) { + _showSnack('제품을 삭제했습니다.'); + } + } + } + + Future _restoreProduct(Product product) async { + if (product.id == null) return; + final restored = await _controller.restore(product.id!); + if (restored != null && mounted) { + _showSnack('제품을 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatDateTime(DateTime? value) { + if (value == null) return '-'; + return _dateFormat.format(value.toLocal()); + } +} + +class _ProductTable extends StatelessWidget { + const _ProductTable({ + required this.products, + required this.dateFormat, + required this.onEdit, + required this.onDelete, + required this.onRestore, + }); + + final List products; + final DateFormat dateFormat; + final void Function(Product product)? onEdit; + final void Function(Product product)? onDelete; + final void Function(Product product)? onRestore; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '제품코드', + '제품명', + '제조사', + '단위', + '사용', + '삭제', + '비고', + '변경일시', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = products.map((product) { + return [ + product.id?.toString() ?? '-', + product.productCode, + product.productName, + product.vendor?.vendorName ?? '-', + product.uom?.uomName ?? '-', + product.isActive ? 'Y' : 'N', + product.isDeleted ? 'Y' : '-', + product.note?.isEmpty ?? true ? '-' : product.note!, + product.updatedAt == null + ? '-' + : dateFormat.format(product.updatedAt!.toLocal()), + ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(product), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + product.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(product), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(product), + child: const Icon(LucideIcons.trash2, size: 16), + ), ], ), ), + ); + }).toList(); + + return SizedBox( + height: 56.0 * (products.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => index == 9 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), + ), + ); + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, ], ); } diff --git a/lib/features/masters/uom/data/dtos/uom_dto.dart b/lib/features/masters/uom/data/dtos/uom_dto.dart new file mode 100644 index 0000000..5f8959a --- /dev/null +++ b/lib/features/masters/uom/data/dtos/uom_dto.dart @@ -0,0 +1,70 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/uom.dart'; + +class UomDto { + UomDto({ + this.id, + required this.uomName, + this.isDefault = false, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String uomName; + final bool isDefault; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory UomDto.fromJson(Map json) { + return UomDto( + id: json['id'] as int?, + uomName: json['uom_name'] as String, + isDefault: (json['is_default'] as bool?) ?? false, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + Uom toEntity() => Uom( + id: id, + uomName: uomName, + isDefault: isDefault, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(UomDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/masters/uom/data/repositories/uom_repository_remote.dart b/lib/features/masters/uom/data/repositories/uom_repository_remote.dart new file mode 100644 index 0000000..55a3752 --- /dev/null +++ b/lib/features/masters/uom/data/repositories/uom_repository_remote.dart @@ -0,0 +1,35 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/uom.dart'; +import '../../domain/repositories/uom_repository.dart'; +import '../dtos/uom_dto.dart'; + +class UomRepositoryRemote implements UomRepository { + UomRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/uoms'; + + @override + Future> list({ + int page = 1, + int pageSize = 50, + String? query, + bool? isActive, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (isActive != null) 'is_active': isActive, + }, + options: Options(responseType: ResponseType.json), + ); + return UomDto.parsePaginated(response.data ?? const {}); + } +} diff --git a/lib/features/masters/uom/domain/entities/uom.dart b/lib/features/masters/uom/domain/entities/uom.dart new file mode 100644 index 0000000..84a6ece --- /dev/null +++ b/lib/features/masters/uom/domain/entities/uom.dart @@ -0,0 +1,43 @@ +class Uom { + Uom({ + this.id, + required this.uomName, + this.isDefault = false, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String uomName; + final bool isDefault; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + Uom copyWith({ + int? id, + String? uomName, + bool? isDefault, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Uom( + id: id ?? this.id, + uomName: uomName ?? this.uomName, + isDefault: isDefault ?? this.isDefault, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/features/masters/uom/domain/repositories/uom_repository.dart b/lib/features/masters/uom/domain/repositories/uom_repository.dart new file mode 100644 index 0000000..3b42a7e --- /dev/null +++ b/lib/features/masters/uom/domain/repositories/uom_repository.dart @@ -0,0 +1,13 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/uom.dart'; + +/// 단위(UOM) 조회 리포지토리 +abstract class UomRepository { + Future> list({ + int page = 1, + int pageSize = 50, + String? query, + bool? isActive, + }); +} diff --git a/lib/features/masters/vendor/data/dtos/vendor_dto.dart b/lib/features/masters/vendor/data/dtos/vendor_dto.dart index 71cba96..b972a7f 100644 --- a/lib/features/masters/vendor/data/dtos/vendor_dto.dart +++ b/lib/features/masters/vendor/data/dtos/vendor_dto.dart @@ -1,3 +1,5 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + import '../../domain/entities/vendor.dart'; /// 벤더 DTO (JSON 직렬화/역직렬화) @@ -49,26 +51,40 @@ class VendorDto { } Vendor toEntity() => Vendor( - id: id, - vendorCode: vendorCode, - vendorName: vendorName, - isActive: isActive, - isDeleted: isDeleted, - note: note, - createdAt: createdAt, - updatedAt: updatedAt, - ); + id: id, + vendorCode: vendorCode, + vendorName: vendorName, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); static VendorDto fromEntity(Vendor entity) => VendorDto( - id: entity.id, - vendorCode: entity.vendorCode, - vendorName: entity.vendorName, - isActive: entity.isActive, - isDeleted: entity.isDeleted, - note: entity.note, - createdAt: entity.createdAt, - updatedAt: entity.updatedAt, - ); + id: entity.id, + vendorCode: entity.vendorCode, + vendorName: entity.vendorName, + isActive: entity.isActive, + isDeleted: entity.isDeleted, + note: entity.note, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(VendorDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } } DateTime? _parseDate(Object? value) { @@ -78,3 +94,8 @@ DateTime? _parseDate(Object? value) { return null; } +Map vendorInputToJson(VendorInput input) { + final map = input.toPayload(); + map.removeWhere((key, value) => value == null); + return map; +} diff --git a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart index 19645cb..2bb45c0 100644 --- a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart +++ b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart @@ -1,9 +1,11 @@ import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + import '../../domain/entities/vendor.dart'; import '../../domain/repositories/vendor_repository.dart'; import '../dtos/vendor_dto.dart'; -import '../../../../../core/network/api_client.dart'; /// 원격 구현체: 공통 ApiClient(Dio) 사용 class VendorRepositoryRemote implements VendorRepository { @@ -14,57 +16,60 @@ class VendorRepositoryRemote implements VendorRepository { static const _basePath = '/vendors'; // TODO: 백엔드 경로 확정 시 수정 @override - Future> list({ + Future> list({ int page = 1, int pageSize = 20, String? query, - bool includeInactive = true, + bool? isActive, }) async { - final response = await _api.get>( + final response = await _api.get>( _basePath, query: { 'page': page, 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, - if (includeInactive) 'include': 'inactive', + if (isActive != null) 'is_active': isActive, }, options: Options(responseType: ResponseType.json), ); - final data = response.data ?? []; - return data - .whereType>() - .map((e) => VendorDto.fromJson(e).toEntity()) - .toList(); + final map = response.data ?? const {}; + return VendorDto.parsePaginated(map); } @override - Future create(Vendor vendor) async { - final dto = VendorDto.fromEntity(vendor); + Future create(VendorInput input) async { final response = await _api.post>( _basePath, - data: dto.toJson(), + data: vendorInputToJson(input), options: Options(responseType: ResponseType.json), ); - return VendorDto.fromJson(response.data ?? {}).toEntity(); + final data = (response.data?['data'] as Map?) ?? {}; + return VendorDto.fromJson(data).toEntity(); } @override - Future update(Vendor vendor) async { - if (vendor.id == null) { - throw ArgumentError('id가 없는 엔티티는 수정할 수 없습니다.'); - } - final dto = VendorDto.fromEntity(vendor); + Future update(int id, VendorInput input) async { final response = await _api.patch>( - '$_basePath/${vendor.id}', - data: dto.toJson(), + '$_basePath/$id', + data: vendorInputToJson(input), options: Options(responseType: ResponseType.json), ); - return VendorDto.fromJson(response.data ?? {}).toEntity(); + final data = (response.data?['data'] as Map?) ?? {}; + return VendorDto.fromJson(data).toEntity(); } @override Future delete(int id) async { await _api.delete('$_basePath/$id'); } -} + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return VendorDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/masters/vendor/domain/entities/vendor.dart b/lib/features/masters/vendor/domain/entities/vendor.dart index b5570a0..ab17731 100644 --- a/lib/features/masters/vendor/domain/entities/vendor.dart +++ b/lib/features/masters/vendor/domain/entities/vendor.dart @@ -59,3 +59,28 @@ class Vendor { } } +/// 벤더 신규/수정 입력 모델 +/// +/// - code는 생성 시 필수, 수정 시 읽기 전용이지만 API 전송을 위해 포함 +class VendorInput { + VendorInput({ + required this.vendorCode, + required this.vendorName, + this.isActive = true, + this.note, + }); + + final String vendorCode; + final String vendorName; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'vendor_code': vendorCode, + 'vendor_name': vendorName, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/vendor/domain/repositories/vendor_repository.dart b/lib/features/masters/vendor/domain/repositories/vendor_repository.dart index 7035a74..7a2be91 100644 --- a/lib/features/masters/vendor/domain/repositories/vendor_repository.dart +++ b/lib/features/masters/vendor/domain/repositories/vendor_repository.dart @@ -1,3 +1,5 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + import '../entities/vendor.dart'; /// 벤더 리포지토리 인터페이스 @@ -8,20 +10,22 @@ abstract class VendorRepository { /// 벤더 목록 조회 /// /// - 표준 쿼리 파라미터: page, page_size, q, include - Future> list({ + Future> list({ int page = 1, int pageSize = 20, String? query, - bool includeInactive = true, + bool? isActive, }); /// 벤더 생성 - Future create(Vendor vendor); + Future create(VendorInput input); /// 벤더 수정 (부분 업데이트 포함) - Future update(Vendor vendor); + Future update(int id, VendorInput input); /// 벤더 소프트 삭제 Future delete(int id); -} + /// 벤더 복구 (소프트 삭제 해제) + Future restore(int id); +} diff --git a/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart new file mode 100644 index 0000000..79b4d1b --- /dev/null +++ b/lib/features/masters/vendor/presentation/controllers/vendor_controller.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/vendor.dart'; +import '../../domain/repositories/vendor_repository.dart'; + +enum VendorStatusFilter { all, activeOnly, inactiveOnly } + +/// 벤더 화면 상태 컨트롤러 +/// +/// - 목록/검색/필터/페이지 상태를 관리한다. +/// - 생성/수정/삭제/복구 요청을 래핑하여 UI에 알린다. +class VendorController extends ChangeNotifier { + VendorController({required VendorRepository repository}) + : _repository = repository; + + final VendorRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + String _query = ''; + VendorStatusFilter _statusFilter = VendorStatusFilter.all; + String? _errorMessage; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + String get query => _query; + VendorStatusFilter get statusFilter => _statusFilter; + String? get errorMessage => _errorMessage; + + /// 목록 갱신 + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final isActive = switch (_statusFilter) { + VendorStatusFilter.all => null, + VendorStatusFilter.activeOnly => true, + VendorStatusFilter.inactiveOnly => false, + }; + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + isActive: isActive, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateStatusFilter(VendorStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + /// 신규 등록 + Future create(VendorInput input) async { + _setSubmitting(true); + try { + final vendor = await _repository.create(input); + await fetch(page: 1); + return vendor; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + /// 수정 + Future update(int id, VendorInput input) async { + _setSubmitting(true); + try { + final vendor = await _repository.update(id, input); + await fetch(page: _result?.page ?? 1); + return vendor; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + /// 삭제 (소프트) + Future delete(int id) async { + _setSubmitting(true); + try { + await _repository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + /// 복구 + Future restore(int id) async { + _setSubmitting(true); + try { + final vendor = await _repository.restore(id); + await fetch(page: _result?.page ?? 1); + return vendor; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index e5d840f..4619b71 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -6,6 +6,7 @@ import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../vendor/domain/entities/vendor.dart'; import '../../../vendor/domain/repositories/vendor_repository.dart'; +import '../controllers/vendor_controller.dart'; class VendorPage extends StatelessWidget { const VendorPage({super.key}); @@ -65,111 +66,630 @@ class _VendorEnabledPage extends StatefulWidget { } class _VendorEnabledPageState extends State<_VendorEnabledPage> { - final _repo = GetIt.I(); - final _loading = ValueNotifier(false); - final _vendors = ValueNotifier>([]); + late final VendorController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = VendorController(repository: GetIt.I()); + _controller.addListener(_onControllerChanged); + WidgetsBinding.instance.addPostFrameCallback((_) => _controller.fetch()); + } @override void dispose() { - _loading.dispose(); - _vendors.dispose(); + _controller.removeListener(_onControllerChanged); + _controller.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } - Future _load() async { - _loading.value = true; - try { - final list = await _repo.list(page: 1, pageSize: 50); - _vendors.value = list; - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('벤더 조회 실패: $e')), - ); - } - } finally { - _loading.value = false; + void _onControllerChanged() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); } } @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final vendors = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('제조사(벤더) 관리', style: theme.textTheme.h2), - const SizedBox(height: 6), - Text('벤더코드, 명칭, 사용여부 관리', style: theme.textTheme.muted), - ], - ), - Row( - children: [ - ValueListenableBuilder( - valueListenable: _loading, - builder: (_, loading, __) { - return ShadButton( - onPressed: loading ? null : _load, - child: Text(loading ? '로딩 중...' : '데이터 조회'), - ); - }, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('제조사(벤더) 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '벤더코드, 명칭, 사용여부, 삭제 상태를 관리합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + const SizedBox(width: 16), + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openVendorForm(context), + child: const Text('신규 등록'), ), ], ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 및 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 280, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: const Text('벤더코드, 벤더명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, value) => + Text(_statusLabel(value)), + onChanged: (value) { + if (value != null) { + _controller.updateStatusFilter(value); + _controller.fetch(page: 1); + } + }, + options: VendorStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading + ? null + : _applyFilters, + child: const Text('검색 적용'), + ), + if (_searchController.text.isNotEmpty || + _controller.statusFilter != VendorStatusFilter.all) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocusNode.requestFocus(); + _controller.updateQuery(''); + _controller.updateStatusFilter( + VendorStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('벤더 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : vendors.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 벤더가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _VendorTable( + vendors: vendors, + onEdit: _controller.isSubmitting + ? null + : (vendor) => + _openVendorForm(context, vendor: vendor), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreVendor, + dateFormat: _dateFormat, + ), + ), ], ), - const SizedBox(height: 16), - ShadCard( - title: Text('벤더 목록', style: theme.textTheme.h3), - child: ValueListenableBuilder>( - valueListenable: _vendors, - builder: (_, vendors, __) { - if (vendors.isEmpty) { - return Padding( - padding: const EdgeInsets.all(24), - child: Text('데이터가 없습니다. 상단의 "데이터 조회"를 눌러주세요.', - style: theme.textTheme.muted), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + String _statusLabel(VendorStatusFilter filter) { + switch (filter) { + case VendorStatusFilter.all: + return '전체(사용/미사용)'; + case VendorStatusFilter.activeOnly: + return '사용중'; + case VendorStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openVendorForm(BuildContext context, {Vendor? vendor}) async { + final existingVendor = vendor; + final isEdit = existingVendor != null; + final vendorId = existingVendor?.id; + if (isEdit && vendorId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + final codeController = TextEditingController( + text: existingVendor?.vendorCode ?? '', + ); + final nameController = TextEditingController( + text: existingVendor?.vendorName ?? '', + ); + final noteController = TextEditingController( + text: existingVendor?.note ?? '', + ); + final isActiveNotifier = ValueNotifier( + existingVendor?.isActive ?? true, + ); + final saving = ValueNotifier(false); + final codeError = ValueNotifier(null); + final nameError = ValueNotifier(null); + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: ShadCard( + title: Text( + isEdit ? '벤더 수정' : '벤더 등록', + style: theme.textTheme.h3, + ), + description: Text( + '벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final note = noteController.text.trim(); + + codeError.value = code.isEmpty + ? '벤더코드를 입력하세요.' + : null; + nameError.value = name.isEmpty + ? '벤더명을 입력하세요.' + : null; + + if (codeError.value != null || + nameError.value != null) { + return; + } + + saving.value = true; + final input = VendorInput( + vendorCode: code, + vendorName: name, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final response = isEdit + ? await _controller.update(vendorId!, input) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], ); - } - return SizedBox( - height: 56.0 * (vendors.length + 1), - child: ShadTable.list( - header: const [ - 'ID', - '벤더코드', - '벤더명', - '사용', - '비고', - '변경일시', - ].map((h) => ShadTableCell.header(child: Text(h))).toList(), - children: vendors - .map( - (v) => [ - '${v.id ?? '-'}', - v.vendorCode, - v.vendorName, - v.isActive ? 'Y' : 'N', - v.note ?? '-', - v.updatedAt?.toIso8601String() ?? '-', - ].map((c) => ShadTableCell(child: Text(c))).toList(), - ) - .toList(), - columnSpanExtent: (index) => const FixedTableSpanExtent(160), - ), - ); - }, + }, + ), + child: Padding( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '벤더코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '벤더명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existingVendor.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existingVendor.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], + ), + ), ), ), - ], + ); + }, + ); + + codeController.dispose(); + nameController.dispose(); + noteController.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + codeError.dispose(); + nameError.dispose(); + } + + Future _confirmDelete(Vendor vendor) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('벤더 삭제'), + content: Text('"${vendor.vendorName}" 벤더를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && vendor.id != null) { + final success = await _controller.delete(vendor.id!); + if (success && mounted) { + _showSnack('벤더를 삭제했습니다.'); + } + } + } + + Future _restoreVendor(Vendor vendor) async { + if (vendor.id == null) return; + final restored = await _controller.restore(vendor.id!); + if (restored != null && mounted) { + _showSnack('벤더를 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatDateTime(DateTime? value) { + if (value == null) { + return '-'; + } + return _dateFormat.format(value.toLocal()); + } +} + +class _VendorTable extends StatelessWidget { + const _VendorTable({ + required this.vendors, + required this.onEdit, + required this.onDelete, + required this.onRestore, + required this.dateFormat, + }); + + final List vendors; + final void Function(Vendor vendor)? onEdit; + final void Function(Vendor vendor)? onDelete; + final void Function(Vendor vendor)? onRestore; + final DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '벤더코드', + '벤더명', + '사용', + '삭제', + '비고', + '변경일시', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = vendors.map((vendor) { + return [ + vendor.id?.toString() ?? '-', + vendor.vendorCode, + vendor.vendorName, + vendor.isActive ? 'Y' : 'N', + vendor.isDeleted ? 'Y' : '-', + vendor.note?.isEmpty ?? true ? '-' : vendor.note!, + vendor.updatedAt == null + ? '-' + : dateFormat.format(vendor.updatedAt!.toLocal()), + ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(vendor), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + vendor.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(vendor), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(vendor), + child: const Icon(LucideIcons.trash2, size: 16), + ), + ], + ), + ), + ); + }).toList(); + + return SizedBox( + height: 56.0 * (vendors.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => index == 7 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), ), ); } } + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} diff --git a/lib/features/masters/warehouse/data/dtos/warehouse_dto.dart b/lib/features/masters/warehouse/data/dtos/warehouse_dto.dart new file mode 100644 index 0000000..25a029e --- /dev/null +++ b/lib/features/masters/warehouse/data/dtos/warehouse_dto.dart @@ -0,0 +1,142 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/warehouse.dart'; + +class WarehouseDto { + WarehouseDto({ + this.id, + required this.warehouseCode, + required this.warehouseName, + this.zipcode, + this.addressDetail, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String warehouseCode; + final String warehouseName; + final WarehouseZipcodeDto? zipcode; + final String? addressDetail; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + factory WarehouseDto.fromJson(Map json) { + return WarehouseDto( + id: json['id'] as int?, + warehouseCode: json['warehouse_code'] as String, + warehouseName: json['warehouse_name'] as String, + zipcode: json['zipcode'] is Map + ? WarehouseZipcodeDto.fromJson( + json['zipcode'] as Map, + ) + : null, + addressDetail: json['address_detail'] as String?, + isActive: (json['is_active'] as bool?) ?? true, + isDeleted: (json['is_deleted'] as bool?) ?? false, + note: json['note'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'warehouse_code': warehouseCode, + 'warehouse_name': warehouseName, + 'zipcode': zipcode?.toJson(), + 'address_detail': addressDetail, + 'is_active': isActive, + 'is_deleted': isDeleted, + 'note': note, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + Warehouse toEntity() => Warehouse( + id: id, + warehouseCode: warehouseCode, + warehouseName: warehouseName, + zipcode: zipcode?.toEntity(), + addressDetail: addressDetail, + isActive: isActive, + isDeleted: isDeleted, + note: note, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static PaginatedResult parsePaginated(Map? json) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(WarehouseDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +class WarehouseZipcodeDto { + WarehouseZipcodeDto({ + required this.zipcode, + this.sido, + this.sigungu, + this.roadName, + }); + + final String zipcode; + final String? sido; + final String? sigungu; + final String? roadName; + + factory WarehouseZipcodeDto.fromJson(Map json) { + return WarehouseZipcodeDto( + zipcode: json['zipcode'] as String, + sido: json['sido'] as String?, + sigungu: json['sigungu'] as String?, + roadName: json['road_name'] as String?, + ); + } + + Map toJson() { + return { + 'zipcode': zipcode, + 'sido': sido, + 'sigungu': sigungu, + 'road_name': roadName, + }; + } + + WarehouseZipcode toEntity() => WarehouseZipcode( + zipcode: zipcode, + sido: sido, + sigungu: sigungu, + roadName: roadName, + ); +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} + +Map warehouseInputToJson(WarehouseInput input) { + final map = input.toPayload(); + map.removeWhere((key, value) => value == null); + return map; +} diff --git a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart new file mode 100644 index 0000000..cb73df4 --- /dev/null +++ b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart @@ -0,0 +1,72 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/warehouse.dart'; +import '../../domain/repositories/warehouse_repository.dart'; +import '../dtos/warehouse_dto.dart'; + +class WarehouseRepositoryRemote implements WarehouseRepository { + WarehouseRepositoryRemote({required ApiClient apiClient}) : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/warehouses'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (isActive != null) 'is_active': isActive, + }, + options: Options(responseType: ResponseType.json), + ); + return WarehouseDto.parsePaginated(response.data ?? const {}); + } + + @override + Future create(WarehouseInput input) async { + final response = await _api.post>( + _basePath, + data: warehouseInputToJson(input), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return WarehouseDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, WarehouseInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: warehouseInputToJson(input), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return WarehouseDto.fromJson(data).toEntity(); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return WarehouseDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/masters/warehouse/domain/entities/warehouse.dart b/lib/features/masters/warehouse/domain/entities/warehouse.dart new file mode 100644 index 0000000..f571aa5 --- /dev/null +++ b/lib/features/masters/warehouse/domain/entities/warehouse.dart @@ -0,0 +1,94 @@ +class Warehouse { + Warehouse({ + this.id, + required this.warehouseCode, + required this.warehouseName, + this.zipcode, + this.addressDetail, + this.isActive = true, + this.isDeleted = false, + this.note, + this.createdAt, + this.updatedAt, + }); + + final int? id; + final String warehouseCode; + final String warehouseName; + final WarehouseZipcode? zipcode; + final String? addressDetail; + final bool isActive; + final bool isDeleted; + final String? note; + final DateTime? createdAt; + final DateTime? updatedAt; + + Warehouse copyWith({ + int? id, + String? warehouseCode, + String? warehouseName, + WarehouseZipcode? zipcode, + String? addressDetail, + bool? isActive, + bool? isDeleted, + String? note, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Warehouse( + id: id ?? this.id, + warehouseCode: warehouseCode ?? this.warehouseCode, + warehouseName: warehouseName ?? this.warehouseName, + zipcode: zipcode ?? this.zipcode, + addressDetail: addressDetail ?? this.addressDetail, + isActive: isActive ?? this.isActive, + isDeleted: isDeleted ?? this.isDeleted, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class WarehouseZipcode { + WarehouseZipcode({ + required this.zipcode, + this.sido, + this.sigungu, + this.roadName, + }); + + final String zipcode; + final String? sido; + final String? sigungu; + final String? roadName; +} + +class WarehouseInput { + WarehouseInput({ + required this.warehouseCode, + required this.warehouseName, + this.zipcode, + this.addressDetail, + this.isActive = true, + this.note, + }); + + final String warehouseCode; + final String warehouseName; + final String? zipcode; + final String? addressDetail; + final bool isActive; + final String? note; + + Map toPayload() { + return { + 'warehouse_code': warehouseCode, + 'warehouse_name': warehouseName, + 'zipcode': zipcode, + 'address_detail': addressDetail, + 'is_active': isActive, + 'note': note, + }; + } +} diff --git a/lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart b/lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart new file mode 100644 index 0000000..47ef9ab --- /dev/null +++ b/lib/features/masters/warehouse/domain/repositories/warehouse_repository.dart @@ -0,0 +1,20 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/warehouse.dart'; + +abstract class WarehouseRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }); + + Future create(WarehouseInput input); + + Future update(int id, WarehouseInput input); + + Future delete(int id); + + Future restore(int id); +} diff --git a/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart new file mode 100644 index 0000000..6d21781 --- /dev/null +++ b/lib/features/masters/warehouse/presentation/controllers/warehouse_controller.dart @@ -0,0 +1,133 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/warehouse.dart'; +import '../../domain/repositories/warehouse_repository.dart'; + +enum WarehouseStatusFilter { all, activeOnly, inactiveOnly } + +class WarehouseController extends ChangeNotifier { + WarehouseController({required WarehouseRepository repository}) + : _repository = repository; + + final WarehouseRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + String _query = ''; + WarehouseStatusFilter _statusFilter = WarehouseStatusFilter.all; + String? _errorMessage; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + String get query => _query; + WarehouseStatusFilter get statusFilter => _statusFilter; + String? get errorMessage => _errorMessage; + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final isActive = switch (_statusFilter) { + WarehouseStatusFilter.all => null, + WarehouseStatusFilter.activeOnly => true, + WarehouseStatusFilter.inactiveOnly => false, + }; + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.isEmpty ? null : _query, + isActive: isActive, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateStatusFilter(WarehouseStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + Future create(WarehouseInput input) async { + _setSubmitting(true); + try { + final created = await _repository.create(input); + await fetch(page: 1); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update(int id, WarehouseInput input) async { + _setSubmitting(true); + try { + final updated = await _repository.update(id, input); + await fetch(page: _result?.page ?? 1); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _repository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _repository.restore(id); + await fetch(page: _result?.page ?? 1); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 72f6e57..281fbf8 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -1,59 +1,759 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../domain/entities/warehouse.dart'; +import '../../domain/repositories/warehouse_repository.dart'; +import '../controllers/warehouse_controller.dart'; class WarehousePage extends StatelessWidget { const WarehousePage({super.key}); @override Widget build(BuildContext context) { - return const SpecPage( - title: '입고지(창고) 관리', - summary: '창고 주소와 사용 여부를 구성합니다.', - sections: [ - SpecSection( - title: '입력 폼', - items: [ - '창고코드 [Text]', - '창고명 [Text]', - '우편번호 [검색 연동]', - '상세주소 [Text]', - '사용여부 [Switch]', - '비고 [Text]', - ], - ), - SpecSection( - title: '수정 폼', - items: ['창고코드 [ReadOnly]', '생성일시 [ReadOnly]'], - ), - SpecSection( - title: '테이블 리스트', - description: '1행 예시', - table: SpecTable( - columns: [ - '번호', - '창고코드', - '창고명', - '우편번호', - '상세주소', - '사용여부', - '비고', - '변경일시', + final enabled = Environment.flag('FEATURE_WAREHOUSES_ENABLED'); + if (!enabled) { + return const SpecPage( + title: '입고지(창고) 관리', + summary: '창고 코드, 주소, 사용여부를 관리합니다.', + sections: [ + SpecSection( + title: '입력 폼', + items: [ + '창고코드 [Text]', + '창고명 [Text]', + '우편번호 [Text+검색]', + '상세주소 [Text]', + '사용여부 [Switch]', + '비고 [Text]', ], - rows: [ - [ - '1', - 'WH-01', - '서울 1창고', - '04532', - '서울시 중구 을지로 100', - 'Y', - '-', - '2024-03-01 10:00', + ), + SpecSection( + title: '수정 폼', + items: ['창고코드 [ReadOnly]', '생성일시 [ReadOnly]'], + ), + SpecSection( + title: '테이블 리스트', + description: '1행 예시', + table: SpecTable( + columns: [ + '번호', + '창고코드', + '창고명', + '우편번호', + '상세주소', + '사용여부', + '비고', + '변경일시', ], + rows: [ + [ + '1', + 'WH-001', + '서울 1창고', + '06000', + '강남대로 123', + 'Y', + '-', + '2024-03-01 10:00', + ], + ], + ), + ), + ], + ); + } + + return const _WarehouseEnabledPage(); + } +} + +class _WarehouseEnabledPage extends StatefulWidget { + const _WarehouseEnabledPage(); + + @override + State<_WarehouseEnabledPage> createState() => _WarehouseEnabledPageState(); +} + +class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { + late final WarehouseController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = WarehouseController( + repository: GetIt.I(), + )..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + _controller.fetch(); + }); + } + + void _handleControllerUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final warehouses = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('입고지(창고) 관리', style: theme.textTheme.h2), + const SizedBox(height: 6), + Text( + '창고 코드, 주소, 사용여부를 관리합니다.', + style: theme.textTheme.muted, + ), + ], + ), + ), + const SizedBox(width: 16), + ShadButton( + leading: const Icon(LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openWarehouseForm(context), + child: const Text('신규 등록'), + ), + ], + ), + const SizedBox(height: 24), + ShadCard( + title: Text('검색 및 필터', style: theme.textTheme.h3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('창고코드, 창고명 검색'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, filter) => + Text(_statusLabel(filter)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: WarehouseStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading + ? null + : _applyFilters, + child: const Text('검색 적용'), + ), + if (_searchController.text.isNotEmpty || + _controller.statusFilter != + WarehouseStatusFilter.all) + ShadButton.ghost( + onPressed: _controller.isLoading + ? null + : () { + _searchController.clear(); + _searchFocus.requestFocus(); + _controller.updateQuery(''); + _controller.updateStatusFilter( + WarehouseStatusFilter.all, + ); + _controller.fetch(page: 1); + }, + child: const Text('초기화'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('창고 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : warehouses.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 창고가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _WarehouseTable( + warehouses: warehouses, + dateFormat: _dateFormat, + onEdit: _controller.isSubmitting + ? null + : (warehouse) => _openWarehouseForm( + context, + warehouse: warehouse, + ), + onDelete: _controller.isSubmitting + ? null + : _confirmDelete, + onRestore: _controller.isSubmitting + ? null + : _restoreWarehouse, + ), + ), + ], + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + String _statusLabel(WarehouseStatusFilter filter) { + switch (filter) { + case WarehouseStatusFilter.all: + return '전체(사용/미사용)'; + case WarehouseStatusFilter.activeOnly: + return '사용중'; + case WarehouseStatusFilter.inactiveOnly: + return '미사용'; + } + } + + Future _openWarehouseForm( + BuildContext context, { + Warehouse? warehouse, + }) async { + final existing = warehouse; + final isEdit = existing != null; + final warehouseId = existing?.id; + if (isEdit && warehouseId == null) { + _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + return; + } + + final codeController = TextEditingController( + text: existing?.warehouseCode ?? '', + ); + final nameController = TextEditingController( + text: existing?.warehouseName ?? '', + ); + final zipcodeController = TextEditingController( + text: existing?.zipcode?.zipcode ?? '', + ); + final addressController = TextEditingController( + text: existing?.addressDetail ?? '', + ); + final noteController = TextEditingController(text: existing?.note ?? ''); + final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); + final saving = ValueNotifier(false); + final codeError = ValueNotifier(null); + final nameError = ValueNotifier(null); + + await showDialog( + context: context, + builder: (dialogContext) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + final navigator = Navigator.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 540), + child: ShadCard( + title: Text( + isEdit ? '창고 수정' : '창고 등록', + style: theme.textTheme.h3, + ), + description: Text( + '창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', + style: theme.textTheme.muted, + ), + footer: ValueListenableBuilder( + valueListenable: saving, + builder: (_, isSaving, __) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => navigator.pop(false), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: isSaving + ? null + : () async { + final code = codeController.text.trim(); + final name = nameController.text.trim(); + final zipcode = zipcodeController.text.trim(); + final address = addressController.text.trim(); + final note = noteController.text.trim(); + + codeError.value = code.isEmpty + ? '창고코드를 입력하세요.' + : null; + nameError.value = name.isEmpty + ? '창고명을 입력하세요.' + : null; + + if (codeError.value != null || + nameError.value != null) { + return; + } + + saving.value = true; + final input = WarehouseInput( + warehouseCode: code, + warehouseName: name, + zipcode: zipcode.isEmpty ? null : zipcode, + addressDetail: address.isEmpty + ? null + : address, + isActive: isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final response = isEdit + ? await _controller.update( + warehouseId!, + input, + ) + : await _controller.create(input); + saving.value = false; + if (response != null) { + if (!navigator.mounted) { + return; + } + if (mounted) { + _showSnack( + isEdit ? '창고를 수정했습니다.' : '창고를 등록했습니다.', + ); + } + navigator.pop(true); + } + }, + child: Text(isEdit ? '저장' : '등록'), + ), + ], + ); + }, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: codeError, + builder: (_, errorText, __) { + return _FormField( + label: '창고코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: codeController, + readOnly: isEdit, + onChanged: (_) { + if (codeController.text.trim().isNotEmpty) { + codeError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: nameError, + builder: (_, errorText, __) { + return _FormField( + label: '창고명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: nameController, + onChanged: (_) { + if (nameController.text.trim().isNotEmpty) { + nameError.value = null; + } + }, + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '우편번호', + child: ShadInput( + controller: zipcodeController, + placeholder: const Text('예: 06000'), + ), + ), + const SizedBox(height: 16), + _FormField( + label: '상세주소', + child: ShadInput( + controller: addressController, + placeholder: const Text('상세주소 입력'), + ), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: isActiveNotifier, + builder: (_, value, __) { + return _FormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: value, + onChanged: saving.value + ? null + : (next) => isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea(controller: noteController), + ), + if (isEdit) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(existing.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(existing.updatedAt)}', + style: theme.textTheme.small, + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + + if (!mounted) { + codeController.dispose(); + nameController.dispose(); + zipcodeController.dispose(); + addressController.dispose(); + noteController.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + codeError.dispose(); + nameError.dispose(); + return; + } + + codeController.dispose(); + nameController.dispose(); + zipcodeController.dispose(); + addressController.dispose(); + noteController.dispose(); + isActiveNotifier.dispose(); + saving.dispose(); + codeError.dispose(); + nameError.dispose(); + } + + Future _confirmDelete(Warehouse warehouse) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('창고 삭제'), + content: Text('"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + + if (confirmed == true && warehouse.id != null) { + final success = await _controller.delete(warehouse.id!); + if (success && mounted) { + _showSnack('창고를 삭제했습니다.'); + } + } + } + + Future _restoreWarehouse(Warehouse warehouse) async { + if (warehouse.id == null) return; + final restored = await _controller.restore(warehouse.id!); + if (restored != null && mounted) { + _showSnack('창고를 복구했습니다.'); + } + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + String _formatDateTime(DateTime? value) { + if (value == null) return '-'; + return _dateFormat.format(value.toLocal()); + } +} + +class _WarehouseTable extends StatelessWidget { + const _WarehouseTable({ + required this.warehouses, + required this.dateFormat, + required this.onEdit, + required this.onDelete, + required this.onRestore, + }); + + final List warehouses; + final DateFormat dateFormat; + final void Function(Warehouse warehouse)? onEdit; + final void Function(Warehouse warehouse)? onDelete; + final void Function(Warehouse warehouse)? onRestore; + + @override + Widget build(BuildContext context) { + final header = [ + 'ID', + '창고코드', + '창고명', + '우편번호', + '상세주소', + '사용', + '삭제', + '비고', + '변경일시', + '동작', + ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + + final rows = warehouses.map((warehouse) { + return [ + warehouse.id?.toString() ?? '-', + warehouse.warehouseCode, + warehouse.warehouseName, + warehouse.zipcode?.zipcode ?? '-', + warehouse.addressDetail?.isEmpty ?? true + ? '-' + : warehouse.addressDetail!, + warehouse.isActive ? 'Y' : 'N', + warehouse.isDeleted ? 'Y' : '-', + warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!, + warehouse.updatedAt == null + ? '-' + : dateFormat.format(warehouse.updatedAt!.toLocal()), + ].map((text) => ShadTableCell(child: Text(text))).toList()..add( + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(warehouse), + child: const Icon(LucideIcons.pencil, size: 16), + ), + const SizedBox(width: 8), + warehouse.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(warehouse), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(warehouse), + child: const Icon(LucideIcons.trash2, size: 16), + ), ], ), ), + ); + }).toList(); + + return SizedBox( + height: 56.0 * (warehouses.length + 1), + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => index == 9 + ? const FixedTableSpanExtent(160) + : const FixedTableSpanExtent(140), + ), + ); + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, ], ); } diff --git a/lib/injection_container.dart b/lib/injection_container.dart index df8c82a..9016e92 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -5,8 +5,16 @@ import 'package:get_it/get_it.dart'; import 'core/network/api_client.dart'; import 'core/network/interceptors/auth_interceptor.dart'; +import 'features/masters/customer/data/repositories/customer_repository_remote.dart'; +import 'features/masters/customer/domain/repositories/customer_repository.dart'; +import 'features/masters/product/data/repositories/product_repository_remote.dart'; +import 'features/masters/product/domain/repositories/product_repository.dart'; import 'features/masters/vendor/data/repositories/vendor_repository_remote.dart'; import 'features/masters/vendor/domain/repositories/vendor_repository.dart'; +import 'features/masters/warehouse/data/repositories/warehouse_repository_remote.dart'; +import 'features/masters/warehouse/domain/repositories/warehouse_repository.dart'; +import 'features/masters/uom/data/repositories/uom_repository_remote.dart'; +import 'features/masters/uom/domain/repositories/uom_repository.dart'; /// 전역 DI 컨테이너 final GetIt sl = GetIt.instance; @@ -39,4 +47,20 @@ Future initInjection({required String baseUrl, Duration? connectTimeout, D sl.registerLazySingleton( () => VendorRepositoryRemote(apiClient: sl()), ); + + sl.registerLazySingleton( + () => ProductRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => WarehouseRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => UomRepositoryRemote(apiClient: sl()), + ); + + sl.registerLazySingleton( + () => CustomerRepositoryRemote(apiClient: sl()), + ); } diff --git a/pubspec.lock b/pubspec.lock index e64a4ad..99de1b1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,6 +317,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b8aa5e2..045330a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/features/masters/customer/presentation/controllers/customer_controller_test.dart b/test/features/masters/customer/presentation/controllers/customer_controller_test.dart new file mode 100644 index 0000000..307d847 --- /dev/null +++ b/test/features/masters/customer/presentation/controllers/customer_controller_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart'; +import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart'; +import 'package:superport_v2/features/masters/customer/presentation/controllers/customer_controller.dart'; + +class _MockCustomerRepository extends Mock implements CustomerRepository {} + +class _FakeCustomerInput extends Fake implements CustomerInput {} + +void main() { + late CustomerController controller; + late _MockCustomerRepository repository; + + final sampleCustomer = Customer( + id: 1, + customerCode: 'C-001', + customerName: '슈퍼포트', + isPartner: true, + isGeneral: true, + ); + + PaginatedResult createResult({List? items}) { + final list = items ?? [sampleCustomer]; + return PaginatedResult( + items: list, + page: 1, + pageSize: 20, + total: list.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeCustomerInput()); + }); + + setUp(() { + repository = _MockCustomerRepository(); + controller = CustomerController(repository: repository); + }); + + group('fetch', () { + test('정상 조회 시 결과 저장', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isPartner: any(named: 'isPartner'), + isGeneral: any(named: 'isGeneral'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + isPartner: null, + isGeneral: null, + isActive: null, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isPartner: any(named: 'isPartner'), + isGeneral: any(named: 'isGeneral'), + isActive: any(named: 'isActive'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + test('필터 업데이트 반영', () { + controller.updateQuery('abc'); + controller.updateTypeFilter(CustomerTypeFilter.partner); + controller.updateStatusFilter(CustomerStatusFilter.inactiveOnly); + + expect(controller.query, 'abc'); + expect(controller.typeFilter, CustomerTypeFilter.partner); + expect(controller.statusFilter, CustomerStatusFilter.inactiveOnly); + }); + + group('mutations', () { + setUp(() { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isPartner: any(named: 'isPartner'), + isGeneral: any(named: 'isGeneral'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + }); + + final input = CustomerInput( + customerCode: 'C-001', + customerName: '슈퍼포트', + isPartner: true, + isGeneral: true, + ); + + test('create 성공', () async { + when( + () => repository.create(any()), + ).thenAnswer((_) async => sampleCustomer); + + final created = await controller.create(input); + + expect(created, isNotNull); + verify(() => repository.create(any())).called(1); + }); + + test('update 성공', () async { + when( + () => repository.update(any(), any()), + ).thenAnswer((_) async => sampleCustomer); + + final updated = await controller.update(1, input); + + expect(updated, isNotNull); + verify(() => repository.update(1, any())).called(1); + }); + + test('delete 성공', () async { + when(() => repository.delete(any())).thenAnswer((_) async {}); + + final success = await controller.delete(1); + + expect(success, isTrue); + verify(() => repository.delete(1)).called(1); + }); + + test('restore 성공', () async { + when( + () => repository.restore(any()), + ).thenAnswer((_) async => sampleCustomer); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => repository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart new file mode 100644 index 0000000..bc0fd91 --- /dev/null +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart'; +import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart'; +import 'package:superport_v2/features/masters/customer/presentation/pages/customer_page.dart'; + +class _MockCustomerRepository extends Mock implements CustomerRepository {} + +class _FakeCustomerInput extends Fake implements CustomerInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeCustomerInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 화면 유지', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_CUSTOMERS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pump(); + + expect(find.text('회사(고객사) 관리'), findsOneWidget); + expect(find.text('테이블 리스트'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockCustomerRepository repository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_CUSTOMERS_ENABLED=true\n'); + repository = _MockCustomerRepository(); + GetIt.I.registerLazySingleton(() => repository); + }); + + testWidgets('목록 조회 후 테이블 렌더', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isPartner: any(named: 'isPartner'), + isGeneral: any(named: 'isGeneral'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + Customer( + id: 1, + customerCode: 'C-001', + customerName: '슈퍼포트', + isPartner: true, + isGeneral: false, + email: 'partner@superport.com', + ), + ], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pumpAndSettle(); + + expect(find.text('C-001'), findsOneWidget); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + isPartner: null, + isGeneral: null, + isActive: null, + ), + ).called(1); + }); + + testWidgets('폼 검증: 필수값 누락 시 경고', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isPartner: any(named: 'isPartner'), + isGeneral: any(named: 'isGeneral'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('고객사코드를 입력하세요.'), findsOneWidget); + expect(find.text('고객사명을 입력하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async { + var listCallCount = 0; + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isPartner: any(named: 'isPartner'), + isGeneral: any(named: 'isGeneral'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async { + listCallCount += 1; + if (listCallCount == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [ + Customer( + id: 2, + customerCode: 'C-100', + customerName: '신규 고객', + isPartner: true, + isGeneral: true, + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + CustomerInput? capturedInput; + when(() => repository.create(any())).thenAnswer((invocation) async { + capturedInput = invocation.positionalArguments.first as CustomerInput; + return Customer( + id: 2, + customerCode: capturedInput!.customerCode, + customerName: capturedInput!.customerName, + isPartner: capturedInput!.isPartner, + isGeneral: capturedInput!.isGeneral, + ); + }); + + await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final dialog = find.byType(Dialog); + final editableTexts = find.descendant( + of: dialog, + matching: find.byType(EditableText), + ); + + await tester.enterText(editableTexts.at(0), 'C-100'); + await tester.enterText(editableTexts.at(1), '신규 고객'); + await tester.enterText(editableTexts.at(2), 'new@superport.com'); + await tester.enterText(editableTexts.at(3), '02-0000-0000'); + + // 유형 체크박스: 기본값 partner=false, general=true. partner on 추가 + await tester.tap(find.text('파트너')); + await tester.pump(); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.customerCode, 'C-100'); + expect(find.byType(Dialog), findsNothing); + expect(find.text('C-100'), findsOneWidget); + verify(() => repository.create(any())).called(1); + }); + }); +} diff --git a/test/features/masters/product/presentation/controllers/product_controller_test.dart b/test/features/masters/product/presentation/controllers/product_controller_test.dart new file mode 100644 index 0000000..16d566b --- /dev/null +++ b/test/features/masters/product/presentation/controllers/product_controller_test.dart @@ -0,0 +1,223 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/product/domain/entities/product.dart'; +import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart'; +import 'package:superport_v2/features/masters/product/presentation/controllers/product_controller.dart'; +import 'package:superport_v2/features/masters/uom/domain/entities/uom.dart'; +import 'package:superport_v2/features/masters/uom/domain/repositories/uom_repository.dart'; +import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; +import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart'; + +class _MockProductRepository extends Mock implements ProductRepository {} + +class _MockVendorRepository extends Mock implements VendorRepository {} + +class _MockUomRepository extends Mock implements UomRepository {} + +class _FakeProductInput extends Fake implements ProductInput {} + +void main() { + late ProductController controller; + late _MockProductRepository productRepository; + late _MockVendorRepository vendorRepository; + late _MockUomRepository uomRepository; + + final sampleProduct = Product( + id: 1, + productCode: 'P-001', + productName: '테스트 제품', + vendor: ProductVendor(id: 10, vendorCode: 'V-10', vendorName: '벤더'), + uom: ProductUom(id: 5, uomName: 'EA'), + ); + + PaginatedResult createResult({List? items}) { + final list = items ?? [sampleProduct]; + return PaginatedResult( + items: list, + page: 1, + pageSize: 20, + total: list.length, + ); + } + + PaginatedResult createVendorResult() { + return PaginatedResult( + items: [Vendor(id: 10, vendorCode: 'V-10', vendorName: '벤더')], + page: 1, + pageSize: 20, + total: 1, + ); + } + + PaginatedResult createUomResult() { + return PaginatedResult( + items: [Uom(id: 5, uomName: 'EA')], + page: 1, + pageSize: 20, + total: 1, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeProductInput()); + }); + + setUp(() { + productRepository = _MockProductRepository(); + vendorRepository = _MockVendorRepository(); + uomRepository = _MockUomRepository(); + controller = ProductController( + productRepository: productRepository, + vendorRepository: vendorRepository, + uomRepository: uomRepository, + ); + }); + + group('fetch', () { + test('정상 조회 시 결과를 저장한다', () async { + when( + () => productRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + vendorId: any(named: 'vendorId'), + uomId: any(named: 'uomId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + verify( + () => productRepository.list( + page: 1, + pageSize: 20, + query: null, + vendorId: null, + uomId: null, + isActive: null, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage가 설정된다', () async { + when( + () => productRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + vendorId: any(named: 'vendorId'), + uomId: any(named: 'uomId'), + isActive: any(named: 'isActive'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + test('loadLookups 호출 시 벤더/단위 목록을 설정한다', () async { + when( + () => vendorRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createVendorResult()); + when( + () => uomRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createUomResult()); + + await controller.loadLookups(); + + expect(controller.vendorOptions, isNotEmpty); + expect(controller.uomOptions, isNotEmpty); + }); + + test('쿼리/필터 업데이트 시 상태 반영', () { + controller.updateQuery('abc'); + controller.updateVendorFilter(1); + controller.updateUomFilter(2); + controller.updateStatusFilter(ProductStatusFilter.activeOnly); + + expect(controller.query, 'abc'); + expect(controller.vendorFilter, 1); + expect(controller.uomFilter, 2); + expect(controller.statusFilter, ProductStatusFilter.activeOnly); + }); + + group('mutations', () { + setUp(() { + when( + () => productRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + vendorId: any(named: 'vendorId'), + uomId: any(named: 'uomId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + }); + + final input = ProductInput( + productCode: 'P-001', + productName: '테스트 제품', + vendorId: 10, + uomId: 5, + ); + + test('create 성공 시 목록을 갱신한다', () async { + when( + () => productRepository.create(any()), + ).thenAnswer((_) async => sampleProduct); + + final created = await controller.create(input); + + expect(created, isNotNull); + verify(() => productRepository.create(any())).called(1); + }); + + test('update 성공 시 현재 페이지 유지하며 갱신한다', () async { + when( + () => productRepository.update(any(), any()), + ).thenAnswer((_) async => sampleProduct); + + final updated = await controller.update(1, input); + + expect(updated, isNotNull); + verify(() => productRepository.update(1, any())).called(1); + }); + + test('delete 성공 시 true 반환 및 갱신', () async { + when(() => productRepository.delete(any())).thenAnswer((_) async {}); + + final success = await controller.delete(1); + + expect(success, isTrue); + verify(() => productRepository.delete(1)).called(1); + }); + + test('restore 성공 시 갱신', () async { + when( + () => productRepository.restore(any()), + ).thenAnswer((_) async => sampleProduct); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => productRepository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/product/presentation/pages/product_page_test.dart b/test/features/masters/product/presentation/pages/product_page_test.dart new file mode 100644 index 0000000..6e61519 --- /dev/null +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/product/domain/entities/product.dart'; +import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart'; +import 'package:superport_v2/features/masters/product/presentation/pages/product_page.dart'; +import 'package:superport_v2/features/masters/uom/domain/entities/uom.dart'; +import 'package:superport_v2/features/masters/uom/domain/repositories/uom_repository.dart'; +import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; +import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart'; + +class _MockProductRepository extends Mock implements ProductRepository {} + +class _MockVendorRepository extends Mock implements VendorRepository {} + +class _MockUomRepository extends Mock implements UomRepository {} + +class _FakeProductInput extends Fake implements ProductInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeProductInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 문서 화면을 노출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pump(); + + expect(find.text('장비 모델(제품) 관리'), findsOneWidget); + expect(find.text('테이블 리스트'), findsOneWidget); + }); + + group('플래그 On 환경', () { + late _MockProductRepository productRepository; + late _MockVendorRepository vendorRepository; + late _MockUomRepository uomRepository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_PRODUCTS_ENABLED=true\n'); + productRepository = _MockProductRepository(); + vendorRepository = _MockVendorRepository(); + uomRepository = _MockUomRepository(); + GetIt.I.registerLazySingleton(() => productRepository); + GetIt.I.registerLazySingleton(() => vendorRepository); + GetIt.I.registerLazySingleton(() => uomRepository); + + when( + () => vendorRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [Vendor(id: 1, vendorCode: 'V-001', vendorName: '슈퍼벤더')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when( + () => uomRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [Uom(id: 5, uomName: 'EA')], + page: 1, + pageSize: 20, + total: 1, + ), + ); + }); + + testWidgets('목록 조회 후 테이블에 표시한다', (tester) async { + when( + () => productRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + vendorId: any(named: 'vendorId'), + uomId: any(named: 'uomId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + Product( + id: 1, + productCode: 'P-001', + productName: '테스트 제품', + vendor: ProductVendor( + id: 1, + vendorCode: 'V-001', + vendorName: '슈퍼벤더', + ), + uom: ProductUom(id: 5, uomName: 'EA'), + ), + ], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pumpAndSettle(); + + expect(find.text('P-001'), findsOneWidget); + verify( + () => productRepository.list( + page: 1, + pageSize: 20, + query: null, + vendorId: null, + uomId: null, + isActive: null, + ), + ).called(1); + }); + + testWidgets('폼 검증: 필수값 미입력 시 에러 메시지를 표시한다', (tester) async { + when( + () => productRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + vendorId: any(named: 'vendorId'), + uomId: any(named: 'uomId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('제품코드를 입력하세요.'), findsOneWidget); + expect(find.text('제품명을 입력하세요.'), findsOneWidget); + expect(find.text('제조사를 선택하세요.'), findsOneWidget); + expect(find.text('단위를 선택하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async { + var listCallCount = 0; + when( + () => productRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + vendorId: any(named: 'vendorId'), + uomId: any(named: 'uomId'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async { + listCallCount += 1; + if (listCallCount == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [ + Product( + id: 99, + productCode: 'NP-001', + productName: '신규 제품', + vendor: ProductVendor( + id: 1, + vendorCode: 'V-001', + vendorName: '슈퍼벤더', + ), + uom: ProductUom(id: 5, uomName: 'EA'), + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + ProductInput? capturedInput; + when(() => productRepository.create(any())).thenAnswer(( + invocation, + ) async { + capturedInput = invocation.positionalArguments.first as ProductInput; + return Product( + id: 99, + productCode: capturedInput!.productCode, + productName: capturedInput!.productName, + vendor: ProductVendor( + id: capturedInput!.vendorId, + vendorCode: 'V', + vendorName: '슈퍼벤더', + ), + uom: ProductUom(id: capturedInput!.uomId, uomName: 'EA'), + ); + }); + + await tester.pumpWidget(_buildApp(const ProductPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final dialog = find.byType(Dialog); + final editableTexts = find.descendant( + of: dialog, + matching: find.byType(EditableText), + ); + + await tester.enterText(editableTexts.at(0), 'NP-001'); + await tester.enterText(editableTexts.at(1), '신규 제품'); + + await tester.tap(find.text('제조사를 선택하세요')); + await tester.pumpAndSettle(); + await tester.tap(find.text('슈퍼벤더')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('단위를 선택하세요')); + await tester.pumpAndSettle(); + await tester.tap(find.text('EA')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.productCode, 'NP-001'); + expect(find.byType(Dialog), findsNothing); + expect(find.text('NP-001'), findsOneWidget); + verify(() => productRepository.create(any())).called(1); + }); + }); +} diff --git a/test/features/masters/vendor/presentation/controllers/vendor_controller_test.dart b/test/features/masters/vendor/presentation/controllers/vendor_controller_test.dart new file mode 100644 index 0000000..15e3c69 --- /dev/null +++ b/test/features/masters/vendor/presentation/controllers/vendor_controller_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; +import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart'; +import 'package:superport_v2/features/masters/vendor/presentation/controllers/vendor_controller.dart'; + +class _MockVendorRepository extends Mock implements VendorRepository {} + +class _FakeVendorInput extends Fake implements VendorInput {} + +void main() { + late VendorController controller; + late _MockVendorRepository repository; + + final sampleVendor = Vendor( + id: 1, + vendorCode: 'V-001', + vendorName: '슈퍼벤더', + isActive: true, + isDeleted: false, + ); + + PaginatedResult createResult({List? items}) { + final list = items ?? [sampleVendor]; + return PaginatedResult( + items: list, + page: 1, + pageSize: 20, + total: list.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeVendorInput()); + }); + + setUp(() { + repository = _MockVendorRepository(); + controller = VendorController(repository: repository); + }); + + group('fetch', () { + test('정상 조회 시 결과가 저장된다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.fetch(); + + expect(controller.isLoading, isFalse); + expect(controller.errorMessage, isNull); + expect(controller.result?.items, isNotEmpty); + verify( + () => + repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(1); + }); + + test('에러 발생 시 errorMessage가 세팅된다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.isLoading, isFalse); + expect(controller.errorMessage, isNotNull); + }); + }); + + test('updateQuery / updateStatusFilter 변경 시 상태 반영', () { + controller.updateQuery('abc'); + controller.updateStatusFilter(VendorStatusFilter.activeOnly); + + expect(controller.query, 'abc'); + expect(controller.statusFilter, VendorStatusFilter.activeOnly); + }); + + group('create/update/delete/restore', () { + setUp(() { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + }); + + test('create 성공 시 목록을 새로고침한다', () async { + when( + () => repository.create(any()), + ).thenAnswer((_) async => sampleVendor); + + final result = await controller.create( + VendorInput(vendorCode: 'V-001', vendorName: '슈퍼벤더'), + ); + + expect(result, isNotNull); + expect(controller.result?.items.length, 1); + verify(() => repository.create(any())).called(1); + verify( + () => + repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(1); + }); + + test('update 성공 시 현재 페이지를 유지하며 갱신한다', () async { + when( + () => repository.update(any(), any()), + ).thenAnswer((_) async => sampleVendor); + + final result = await controller.update( + 1, + VendorInput(vendorCode: 'V-001', vendorName: '슈퍼벤더'), + ); + + expect(result, isNotNull); + expect(controller.result?.items, isNotEmpty); + verify(() => repository.update(1, any())).called(1); + }); + + test('delete 성공 시 fetch가 재호출된다', () async { + when(() => repository.delete(any())).thenAnswer((_) async {}); + + final result = await controller.delete(1); + + expect(result, isTrue); + verify(() => repository.delete(1)).called(1); + }); + + test('restore 성공 시 fetch가 재호출된다', () async { + when( + () => repository.restore(any()), + ).thenAnswer((_) async => sampleVendor); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => repository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart new file mode 100644 index 0000000..19d05e4 --- /dev/null +++ b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; +import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart'; +import 'package:superport_v2/features/masters/vendor/presentation/pages/vendor_page.dart'; + +class _MockVendorRepository extends Mock implements VendorRepository {} + +class _FakeVendorInput extends Fake implements VendorInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeVendorInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('FEATURE_VENDORS_ENABLED=false 이면 스펙 페이지를 노출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pump(); + + expect(find.text('제조사(벤더) 관리'), findsOneWidget); + expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget); + }); + + testWidgets('기능 플래그 on 시 목록을 조회하여 표에 렌더링한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n'); + final repository = _MockVendorRepository(); + GetIt.I.registerLazySingleton(() => repository); + + final vendor = Vendor(id: 1, vendorCode: 'V-001', vendorName: '슈퍼벤더'); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [vendor], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pumpAndSettle(); + + expect(find.text('V-001'), findsOneWidget); + verify( + () => repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(1); + }); + + testWidgets('신규 등록 폼에서 필수값 미입력 시 검증 메시지를 보여준다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n'); + final repository = _MockVendorRepository(); + GetIt.I.registerLazySingleton(() => repository); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('벤더코드를 입력하세요.'), findsOneWidget); + expect(find.text('벤더명을 입력하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공 시 repository.create가 호출된다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n'); + final repository = _MockVendorRepository(); + GetIt.I.registerLazySingleton(() => repository); + + var listCallCount = 0; + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async { + listCallCount += 1; + if (listCallCount == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [Vendor(id: 99, vendorCode: 'NV-001', vendorName: '신규벤더')], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + VendorInput? capturedInput; + when(() => repository.create(any())).thenAnswer((invocation) async { + capturedInput = invocation.positionalArguments.first as VendorInput; + return Vendor( + id: 99, + vendorCode: capturedInput!.vendorCode, + vendorName: capturedInput!.vendorName, + ); + }); + + await tester.pumpWidget(_buildApp(const VendorPage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final dialog = find.byType(Dialog); + final editableTexts = find.descendant( + of: dialog, + matching: find.byType(EditableText), + ); + + await tester.enterText(editableTexts.at(0), 'NV-001'); + await tester.enterText(editableTexts.at(1), '신규벤더'); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.vendorCode, 'NV-001'); + expect(find.byType(Dialog), findsNothing); + expect(find.text('NV-001'), findsOneWidget); + verify(() => repository.create(any())).called(1); + }); +} diff --git a/test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart b/test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart new file mode 100644 index 0000000..eadbef7 --- /dev/null +++ b/test/features/masters/warehouse/presentation/controllers/warehouse_controller_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; +import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; +import 'package:superport_v2/features/masters/warehouse/presentation/controllers/warehouse_controller.dart'; + +class _MockWarehouseRepository extends Mock implements WarehouseRepository {} + +class _FakeWarehouseInput extends Fake implements WarehouseInput {} + +void main() { + late WarehouseController controller; + late _MockWarehouseRepository repository; + + final sampleWarehouse = Warehouse( + id: 1, + warehouseCode: 'WH-001', + warehouseName: '테스트 창고', + zipcode: WarehouseZipcode(zipcode: '06000'), + ); + + PaginatedResult createResult({List? items}) { + final list = items ?? [sampleWarehouse]; + return PaginatedResult( + items: list, + page: 1, + pageSize: 20, + total: list.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeWarehouseInput()); + }); + + setUp(() { + repository = _MockWarehouseRepository(); + controller = WarehouseController(repository: repository); + }); + + group('fetch', () { + test('정상 조회 시 결과 저장', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + verify( + () => + repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(1); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + test('쿼리/필터 업데이트 반영', () { + controller.updateQuery('abc'); + controller.updateStatusFilter(WarehouseStatusFilter.inactiveOnly); + + expect(controller.query, 'abc'); + expect(controller.statusFilter, WarehouseStatusFilter.inactiveOnly); + }); + + group('mutations', () { + setUp(() { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult()); + }); + + final input = WarehouseInput( + warehouseCode: 'WH-001', + warehouseName: '테스트 창고', + ); + + test('create 성공', () async { + when( + () => repository.create(any()), + ).thenAnswer((_) async => sampleWarehouse); + + final created = await controller.create(input); + + expect(created, isNotNull); + verify(() => repository.create(any())).called(1); + }); + + test('update 성공', () async { + when( + () => repository.update(any(), any()), + ).thenAnswer((_) async => sampleWarehouse); + + final updated = await controller.update(1, input); + + expect(updated, isNotNull); + verify(() => repository.update(1, any())).called(1); + }); + + test('delete 성공', () async { + when(() => repository.delete(any())).thenAnswer((_) async {}); + + final success = await controller.delete(1); + + expect(success, isTrue); + verify(() => repository.delete(1)).called(1); + }); + + test('restore 성공', () async { + when( + () => repository.restore(any()), + ).thenAnswer((_) async => sampleWarehouse); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => repository.restore(1)).called(1); + }); + }); +} diff --git a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart new file mode 100644 index 0000000..cf30fea --- /dev/null +++ b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; +import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; +import 'package:superport_v2/features/masters/warehouse/presentation/pages/warehouse_page.dart'; + +class _MockWarehouseRepository extends Mock implements WarehouseRepository {} + +class _FakeWarehouseInput extends Fake implements WarehouseInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeWarehouseInput()); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 화면', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pump(); + + expect(find.text('입고지(창고) 관리'), findsOneWidget); + expect(find.text('테이블 리스트'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockWarehouseRepository repository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_WAREHOUSES_ENABLED=true\n'); + repository = _MockWarehouseRepository(); + GetIt.I.registerLazySingleton(() => repository); + }); + + testWidgets('목록 조회 후 테이블 표시', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [ + Warehouse( + id: 1, + warehouseCode: 'WH-001', + warehouseName: '서울 창고', + zipcode: WarehouseZipcode(zipcode: '06000'), + ), + ], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pumpAndSettle(); + + expect(find.text('WH-001'), findsOneWidget); + verify( + () => + repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(1); + }); + + testWidgets('폼 검증: 필수값 누락', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + await tester.tap(find.text('등록')); + await tester.pump(); + + expect(find.text('창고코드를 입력하세요.'), findsOneWidget); + expect(find.text('창고명을 입력하세요.'), findsOneWidget); + }); + + testWidgets('신규 등록 성공', (tester) async { + var listCallCount = 0; + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async { + listCallCount += 1; + if (listCallCount == 1) { + return PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ); + } + return PaginatedResult( + items: [ + Warehouse( + id: 7, + warehouseCode: 'WH-100', + warehouseName: '신규 창고', + zipcode: WarehouseZipcode(zipcode: '12345'), + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + }); + + WarehouseInput? capturedInput; + when(() => repository.create(any())).thenAnswer((invocation) async { + capturedInput = invocation.positionalArguments.first as WarehouseInput; + return Warehouse( + id: 7, + warehouseCode: capturedInput!.warehouseCode, + warehouseName: capturedInput!.warehouseName, + ); + }); + + await tester.pumpWidget(_buildApp(const WarehousePage())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('신규 등록')); + await tester.pumpAndSettle(); + + final fields = find.descendant( + of: find.byType(Dialog), + matching: find.byType(EditableText), + ); + + await tester.enterText(fields.at(0), 'WH-100'); + await tester.enterText(fields.at(1), '신규 창고'); + await tester.enterText(fields.at(2), '12345'); + await tester.enterText(fields.at(3), '주소'); + + await tester.tap(find.text('등록')); + await tester.pumpAndSettle(); + + expect(capturedInput, isNotNull); + expect(capturedInput?.warehouseCode, 'WH-100'); + expect(find.byType(Dialog), findsNothing); + expect(find.text('WH-100'), findsOneWidget); + verify(() => repository.create(any())).called(1); + }); + }); +}