From 900990c46be3e922e202a07a1951fcc5dd7879c0 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 25 Sep 2025 20:13:46 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=A0=EA=B0=9D=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=BF=BC=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A7=81=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EA=B3=B5=ED=86=B5=20JSON=20?= =?UTF-8?q?=ED=8C=8C=EC=84=9C=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/IMPLEMENTATION_TASKS.md | 16 +- lib/core/common/utils/json_utils.dart | 71 +++ lib/core/routing/app_router.dart | 2 +- .../approvals/data/dtos/approval_dto.dart | 13 +- .../data/dtos/approval_template_dto.dart | 13 +- .../dtos/approval_history_record_dto.dart | 13 +- .../data/dtos/approval_step_record_dto.dart | 13 +- .../customer/data/dtos/customer_dto.dart | 13 +- .../controllers/customer_controller.dart | 26 +- .../presentation/pages/customer_page.dart | 279 +++++++++-- .../masters/group/data/dtos/group_dto.dart | 13 +- .../masters/menu/data/dtos/menu_dto.dart | 13 +- .../product/data/dtos/product_dto.dart | 13 +- .../masters/uom/data/dtos/uom_dto.dart | 13 +- .../masters/user/data/dtos/user_dto.dart | 13 +- .../masters/vendor/data/dtos/vendor_dto.dart | 13 +- .../warehouse/data/dtos/warehouse_dto.dart | 13 +- .../presentation/pages/warehouse_page.dart | 128 ++++- .../presentation/pages/reporting_page.dart | 458 ++++++++++++++++-- .../data/dtos/postal_code_dto.dart | 68 +++ .../postal_search_repository_remote.dart | 41 ++ .../domain/entities/postal_code.dart | 36 ++ .../postal_search_repository.dart | 10 + .../models/postal_search_result.dart | 51 ++ .../widgets/postal_search_dialog.dart | 270 +++++++++++ lib/injection_container.dart | 6 + .../pages/customer_page_test.dart | 16 +- 27 files changed, 1458 insertions(+), 176 deletions(-) create mode 100644 lib/core/common/utils/json_utils.dart create mode 100644 lib/features/util/postal_search/data/dtos/postal_code_dto.dart create mode 100644 lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart create mode 100644 lib/features/util/postal_search/domain/entities/postal_code.dart create mode 100644 lib/features/util/postal_search/domain/repositories/postal_search_repository.dart create mode 100644 lib/features/util/postal_search/presentation/models/postal_search_result.dart create mode 100644 lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 215f742..97841be 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -64,17 +64,17 @@ - [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) ## 8) 우편번호 검색 모달(UI) -- [ ] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음) -- [ ] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현) -- [ ] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현) +- [x] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음) +- [x] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현) +- [x] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현) ## 9) 보고서(`/reports`) UI -- [ ] 조건 폼: 기간/유형/창고/상태 (현황: `ReportingPage`를 AppLayout + FilterBar 플레이스홀더로 전환, 조건 버튼은 비활성 상태로 안내만 제공) -- [ ] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) (현황: 헤더 액션에 비활성 버튼 배치, 실제 다운로드 연동과 상태 제어는 미구현) +- [x] 조건 폼: 기간/유형/창고/상태 (현황: 기간 범위 선택과 유형/창고/상태 셀렉트를 제공하고 창고 목록은 Repository에서 로드하여 필터바에 표시됨) +- [x] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) (현황: 기간+유형 선택 시 활성화되며 현재는 안내 스낵바로 연동 준비 상태를 표시함) ## 10) 데이터 계층/상태 관리 -- [ ] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 마스터·결재·UOM에 대한 domain/data 레이어만 존재하고 입·출·대여/보고서/우편번호 리포지토리 미정의) -- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: 마스터/결재 DTO는 작성됐으나 인벤토리/보고서 DTO 부재, `{ data }` fallback 처리 통일 미완료) +- [x] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 우편번호 검색 도메인/데이터 레이어를 추가해 `/zipcodes` 호출이 가능하며, 입·출·대여/보고서 리포지토리는 후속 예정) +- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: `JsonUtils` 헬퍼를 도입해 마스터/결재 DTO 상당수에 공통 파서를 적용했고 잔여 인벤토리/보고서 DTO 확장은 진행 중) - [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 각 Controller가 내부 `PaginatedResult`만 유지하고 라우터 querystring과 동기화되지 않음) - [ ] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 검색어만 로컬 상태로 보관하며 sort/include 파라미터 직렬화/복원 로직 없음) @@ -88,7 +88,7 @@ - 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재) - 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요) - 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 템플릿 DTO/리포지토리/컨트롤러를 구현해 CRUD·단계 등록까지 API 연동이 완료됐고 나머지 결재 목록/이력/권한 제어는 진행 중) - - 우편번호: `GET /zipcodes?...` (현황: 리포지토리/네트워킹 미구현) + - 우편번호: `GET /zipcodes?...` (현황: 검색 모달에서 Repository를 통해 `/zipcodes` 호출하고 결과를 표시, 고급 검색 조건은 추후 확장) - 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현) ## 12) 검증/접근성/상호작용 diff --git a/lib/core/common/utils/json_utils.dart b/lib/core/common/utils/json_utils.dart new file mode 100644 index 0000000..b822d6b --- /dev/null +++ b/lib/core/common/utils/json_utils.dart @@ -0,0 +1,71 @@ +/// JSON 응답 형태를 일관되게 다루기 위한 헬퍼 모음. +class JsonUtils { + JsonUtils._(); + + /// 리스트 응답을 추출한다. + /// + /// - [source]가 List이면 맵 형태의 요소만 반환한다. + /// - Map이면 [keys] 순서대로 탐색하며 List/Map을 찾아 리스트로 변환한다. + static List> extractList( + dynamic source, { + List keys = const ['items', 'data', 'results'], + }) { + if (source is List) { + return source.whereType>().toList(growable: false); + } + if (source is Map) { + for (final key in keys) { + if (!source.containsKey(key)) continue; + final value = source[key]; + if (value is List) { + return value.whereType>().toList( + growable: false, + ); + } + if (value is Map) { + return [value]; + } + } + } + return const []; + } + + /// Map 응답을 추출한다. 기본적으로 `data` 키를 우선 확인한다. + static Map extractMap( + dynamic source, { + List keys = const ['data'], + }) { + if (source is Map) { + for (final key in keys) { + final value = source[key]; + if (value is Map) { + return value; + } + } + return source; + } + return const {}; + } + + /// 맵에서 정수 값을 안전하게 읽는다. 문자열/실수도 허용한다. + static int readInt( + Map? source, + String key, { + int fallback = 0, + }) { + if (source == null) { + return fallback; + } + final value = source[key]; + if (value is int) { + return value; + } + if (value is double) { + return value.round(); + } + if (value is String) { + return int.tryParse(value) ?? fallback; + } + return fallback; + } +} diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 9c0e850..8236297 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -77,7 +77,7 @@ final appRouter = GoRouter( GoRoute( path: '/masters/customers', name: 'masters-customers', - builder: (context, state) => const CustomerPage(), + builder: (context, state) => CustomerPage(routeUri: state.uri), ), GoRoute( path: '/masters/users', diff --git a/lib/features/approvals/data/dtos/approval_dto.dart b/lib/features/approvals/data/dtos/approval_dto.dart index d844015..fc5c9b4 100644 --- a/lib/features/approvals/data/dtos/approval_dto.dart +++ b/lib/features/approvals/data/dtos/approval_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/approval.dart'; @@ -92,16 +93,16 @@ class ApprovalDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(ApprovalDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/approvals/data/dtos/approval_template_dto.dart b/lib/features/approvals/data/dtos/approval_template_dto.dart index e9be3f3..17981b7 100644 --- a/lib/features/approvals/data/dtos/approval_template_dto.dart +++ b/lib/features/approvals/data/dtos/approval_template_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/approval_template.dart'; @@ -68,16 +69,16 @@ class ApprovalTemplateDto { Map? json, { bool includeSteps = false, }) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(ApprovalTemplateDto.fromJson) .map((dto) => dto.toEntity(includeSteps: includeSteps)) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart b/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart index cf9a7eb..e79b4a8 100644 --- a/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart +++ b/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; @@ -91,17 +92,17 @@ class ApprovalHistoryRecordDto { static PaginatedResult parsePaginated( Map? json, ) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(ApprovalHistoryRecordDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart b/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart index b9213a8..b31af94 100644 --- a/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart +++ b/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; @@ -61,17 +62,17 @@ class ApprovalStepRecordDto { static PaginatedResult parsePaginated( Map? json, ) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(ApprovalStepRecordDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/customer/data/dtos/customer_dto.dart b/lib/features/masters/customer/data/dtos/customer_dto.dart index 1ca7573..9a9ff68 100644 --- a/lib/features/masters/customer/data/dtos/customer_dto.dart +++ b/lib/features/masters/customer/data/dtos/customer_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/customer.dart'; @@ -93,16 +94,16 @@ class CustomerDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(CustomerDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/customer/presentation/controllers/customer_controller.dart b/lib/features/masters/customer/presentation/controllers/customer_controller.dart index a258f36..76a2501 100644 --- a/lib/features/masters/customer/presentation/controllers/customer_controller.dart +++ b/lib/features/masters/customer/presentation/controllers/customer_controller.dart @@ -9,6 +9,8 @@ enum CustomerTypeFilter { all, partner, general } enum CustomerStatusFilter { all, activeOnly, inactiveOnly } class CustomerController extends ChangeNotifier { + static const int defaultPageSize = 20; + CustomerController({required CustomerRepository repository}) : _repository = repository; @@ -20,6 +22,7 @@ class CustomerController extends ChangeNotifier { String _query = ''; CustomerTypeFilter _typeFilter = CustomerTypeFilter.all; CustomerStatusFilter _statusFilter = CustomerStatusFilter.all; + int _pageSize = defaultPageSize; String? _errorMessage; PaginatedResult? get result => _result; @@ -28,6 +31,7 @@ class CustomerController extends ChangeNotifier { String get query => _query; CustomerTypeFilter get typeFilter => _typeFilter; CustomerStatusFilter get statusFilter => _statusFilter; + int get pageSize => _pageSize; String? get errorMessage => _errorMessage; Future fetch({int page = 1}) async { @@ -60,13 +64,16 @@ class CustomerController extends ChangeNotifier { final response = await _repository.list( page: page, - pageSize: _result?.pageSize ?? 20, + pageSize: _pageSize, query: _query.isEmpty ? null : _query, isPartner: isPartner, isGeneral: isGeneral, isActive: isActive, ); _result = response; + if (response.pageSize > 0 && response.pageSize != _pageSize) { + _pageSize = response.pageSize; + } } catch (e) { _errorMessage = e.toString(); } finally { @@ -76,20 +83,37 @@ class CustomerController extends ChangeNotifier { } void updateQuery(String value) { + if (_query == value) { + return; + } _query = value; notifyListeners(); } void updateTypeFilter(CustomerTypeFilter filter) { + if (_typeFilter == filter) { + return; + } _typeFilter = filter; notifyListeners(); } void updateStatusFilter(CustomerStatusFilter filter) { + if (_statusFilter == filter) { + return; + } _statusFilter = filter; notifyListeners(); } + void updatePageSize(int size) { + if (size <= 0 || _pageSize == size) { + return; + } + _pageSize = size; + notifyListeners(); + } + Future create(CustomerInput input) async { _setSubmitting(true); try { diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index c89b20a..881adff 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; +import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -13,7 +16,9 @@ import '../../domain/repositories/customer_repository.dart'; import '../controllers/customer_controller.dart'; class CustomerPage extends StatelessWidget { - const CustomerPage({super.key}); + const CustomerPage({super.key, required this.routeUri}); + + final Uri routeUri; @override Widget build(BuildContext context) { @@ -76,12 +81,14 @@ class CustomerPage extends StatelessWidget { ); } - return const _CustomerEnabledPage(); + return _CustomerEnabledPage(routeUri: routeUri); } } class _CustomerEnabledPage extends StatefulWidget { - const _CustomerEnabledPage(); + const _CustomerEnabledPage({required this.routeUri}); + + final Uri routeUri; @override State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState(); @@ -92,15 +99,22 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); String? _lastError; + bool _routeApplied = false; @override void initState() { super.initState(); _controller = CustomerController(repository: GetIt.I()) ..addListener(_handleControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) { - _controller.fetch(); - }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_routeApplied) { + _routeApplied = true; + _applyRouteParameters(); + } } void _handleControllerUpdate() { @@ -114,6 +128,26 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { } } + void _applyRouteParameters() { + final params = widget.routeUri.queryParameters; + final query = params['q'] ?? ''; + final type = _typeFromParam(params['type']); + final status = _statusFromParam(params['status']); + final pageSizeParam = int.tryParse(params['page_size'] ?? ''); + final pageParam = int.tryParse(params['page'] ?? ''); + + _searchController.text = query; + _controller.updateQuery(query); + _controller.updateTypeFilter(type); + _controller.updateStatusFilter(status); + if (pageSizeParam != null && pageSizeParam > 0) { + _controller.updatePageSize(pageSizeParam); + } + + final page = pageParam != null && pageParam > 0 ? pageParam : 1; + _controller.fetch(page: page); + } + @override void dispose() { _controller.removeListener(_handleControllerUpdate); @@ -141,7 +175,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - final showReset = _searchController.text.isNotEmpty || + final showReset = + _searchController.text.isNotEmpty || _controller.typeFilter != CustomerTypeFilter.all || _controller.statusFilter != CustomerStatusFilter.all; @@ -232,7 +267,12 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { _controller.updateStatusFilter( CustomerStatusFilter.all, ); - _controller.fetch(page: 1); + _updateRoute( + page: 1, + queryOverride: '', + typeOverride: CustomerTypeFilter.all, + statusOverride: CustomerStatusFilter.all, + ); }, child: const Text('초기화'), ), @@ -259,7 +299,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage <= 1 ? null - : () => _controller.fetch(page: currentPage - 1), + : () => _goToPage(currentPage - 1), child: const Text('이전'), ), const SizedBox(width: 8), @@ -267,7 +307,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || !hasNext ? null - : () => _controller.fetch(page: currentPage + 1), + : () => _goToPage(currentPage + 1), child: const Text('다음'), ), ], @@ -280,26 +320,24 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { 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, - ), + ? 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, + ), ), ); }, @@ -307,8 +345,103 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { } void _applyFilters() { - _controller.updateQuery(_searchController.text.trim()); - _controller.fetch(page: 1); + final keyword = _searchController.text.trim(); + _controller.updateQuery(keyword); + _updateRoute(page: 1, queryOverride: keyword); + } + + void _goToPage(int page) { + if (page < 1) { + page = 1; + } + _updateRoute(page: page); + } + + void _updateRoute({ + required int page, + String? queryOverride, + CustomerTypeFilter? typeOverride, + CustomerStatusFilter? statusOverride, + int? pageSizeOverride, + }) { + final query = queryOverride ?? _controller.query; + final type = typeOverride ?? _controller.typeFilter; + final status = statusOverride ?? _controller.statusFilter; + final pageSize = pageSizeOverride ?? _controller.pageSize; + + final params = {}; + if (query.isNotEmpty) { + params['q'] = query; + } + final typeParam = _encodeType(type); + if (typeParam != null) { + params['type'] = typeParam; + } + final statusParam = _encodeStatus(status); + if (statusParam != null) { + params['status'] = statusParam; + } + if (page > 1) { + params['page'] = page.toString(); + } + if (pageSize > 0 && pageSize != CustomerController.defaultPageSize) { + params['page_size'] = pageSize.toString(); + } + + final uri = Uri( + path: widget.routeUri.path, + queryParameters: params.isEmpty ? null : params, + ); + final newLocation = uri.toString(); + if (widget.routeUri.toString() == newLocation) { + _controller.fetch(page: page); + return; + } + GoRouter.of(context).go(newLocation); + } + + CustomerTypeFilter _typeFromParam(String? value) { + switch (value) { + case 'partner': + return CustomerTypeFilter.partner; + case 'general': + return CustomerTypeFilter.general; + default: + return CustomerTypeFilter.all; + } + } + + String? _encodeType(CustomerTypeFilter filter) { + switch (filter) { + case CustomerTypeFilter.all: + return null; + case CustomerTypeFilter.partner: + return 'partner'; + case CustomerTypeFilter.general: + return 'general'; + } + } + + CustomerStatusFilter _statusFromParam(String? value) { + switch (value) { + case 'active': + return CustomerStatusFilter.activeOnly; + case 'inactive': + return CustomerStatusFilter.inactiveOnly; + default: + return CustomerStatusFilter.all; + } + } + + String? _encodeStatus(CustomerStatusFilter filter) { + switch (filter) { + case CustomerStatusFilter.all: + return null; + case CustomerStatusFilter.activeOnly: + return 'active'; + case CustomerStatusFilter.inactiveOnly: + return 'inactive'; + } } String _typeLabel(CustomerTypeFilter filter) { @@ -364,6 +497,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { text: existing?.addressDetail ?? '', ); final noteController = TextEditingController(text: existing?.note ?? ''); + final existingZipcode = existing?.zipcode; + final selectedPostalNotifier = ValueNotifier( + existingZipcode == null + ? null + : PostalSearchResult( + zipcode: existingZipcode.zipcode, + sido: existingZipcode.sido, + sigungu: existingZipcode.sigungu, + roadName: existingZipcode.roadName, + ), + ); final partnerNotifier = ValueNotifier(existing?.isPartner ?? false); final generalNotifier = ValueNotifier(existing?.isGeneral ?? true); final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); @@ -378,6 +522,30 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final theme = ShadTheme.of(dialogContext); final materialTheme = Theme.of(dialogContext); final navigator = Navigator.of(dialogContext); + Future openPostalSearch() async { + final keyword = zipcodeController.text.trim(); + final result = await showPostalSearchDialog( + dialogContext, + initialKeyword: keyword.isEmpty ? null : keyword, + ); + if (result == null) { + return; + } + zipcodeController + ..text = result.zipcode + ..selection = TextSelection.collapsed( + offset: result.zipcode.length, + ); + selectedPostalNotifier.value = result; + if (result.fullAddress.isNotEmpty) { + addressController + ..text = result.fullAddress + ..selection = TextSelection.collapsed( + offset: addressController.text.length, + ); + } + } + return Dialog( insetPadding: const EdgeInsets.all(24), clipBehavior: Clip.antiAlias, @@ -648,9 +816,49 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { const SizedBox(height: 16), _FormField( label: '우편번호', - child: ShadInput( - controller: zipcodeController, - placeholder: const Text('예: 06000'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: zipcodeController, + placeholder: const Text('예: 06000'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ShadButton.outline( + onPressed: saving.value + ? null + : openPostalSearch, + child: const Text('검색'), + ), + ], + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: selectedPostalNotifier, + builder: (_, selection, __) { + if (selection == null) { + return Text( + '검색 버튼을 눌러 주소를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ); + } + final fullAddress = selection.fullAddress; + return Text( + fullAddress.isEmpty + ? '선택한 우편번호에 주소 정보가 없습니다.' + : fullAddress, + style: theme.textTheme.small, + ); + }, + ), + ], ), ), const SizedBox(height: 16), @@ -704,6 +912,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { zipcodeController.dispose(); addressController.dispose(); noteController.dispose(); + selectedPostalNotifier.dispose(); partnerNotifier.dispose(); generalNotifier.dispose(); isActiveNotifier.dispose(); diff --git a/lib/features/masters/group/data/dtos/group_dto.dart b/lib/features/masters/group/data/dtos/group_dto.dart index cf3eeb8..3dfd2a4 100644 --- a/lib/features/masters/group/data/dtos/group_dto.dart +++ b/lib/features/masters/group/data/dtos/group_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/group.dart'; @@ -52,16 +53,16 @@ class GroupDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(GroupDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/menu/data/dtos/menu_dto.dart b/lib/features/masters/menu/data/dtos/menu_dto.dart index 2a2ee64..bdcee62 100644 --- a/lib/features/masters/menu/data/dtos/menu_dto.dart +++ b/lib/features/masters/menu/data/dtos/menu_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/menu.dart'; @@ -64,16 +65,16 @@ class MenuDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(MenuDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/product/data/dtos/product_dto.dart b/lib/features/masters/product/data/dtos/product_dto.dart index b7b8ae2..9bfdb22 100644 --- a/lib/features/masters/product/data/dtos/product_dto.dart +++ b/lib/features/masters/product/data/dtos/product_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/product.dart'; @@ -96,16 +97,16 @@ class ProductDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(ProductDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/uom/data/dtos/uom_dto.dart b/lib/features/masters/uom/data/dtos/uom_dto.dart index 5f8959a..4ed4984 100644 --- a/lib/features/masters/uom/data/dtos/uom_dto.dart +++ b/lib/features/masters/uom/data/dtos/uom_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/uom.dart'; @@ -48,16 +49,16 @@ class UomDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(UomDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/user/data/dtos/user_dto.dart b/lib/features/masters/user/data/dtos/user_dto.dart index e165b13..74c1815 100644 --- a/lib/features/masters/user/data/dtos/user_dto.dart +++ b/lib/features/masters/user/data/dtos/user_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/user.dart'; @@ -64,16 +65,16 @@ class UserDto { static PaginatedResult parsePaginated( Map? json, ) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(UserDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/vendor/data/dtos/vendor_dto.dart b/lib/features/masters/vendor/data/dtos/vendor_dto.dart index b972a7f..a5ca760 100644 --- a/lib/features/masters/vendor/data/dtos/vendor_dto.dart +++ b/lib/features/masters/vendor/data/dtos/vendor_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/vendor.dart'; @@ -73,16 +74,16 @@ class VendorDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(VendorDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/warehouse/data/dtos/warehouse_dto.dart b/lib/features/masters/warehouse/data/dtos/warehouse_dto.dart index 25a029e..859d306 100644 --- a/lib/features/masters/warehouse/data/dtos/warehouse_dto.dart +++ b/lib/features/masters/warehouse/data/dtos/warehouse_dto.dart @@ -1,4 +1,5 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/common/utils/json_utils.dart'; import '../../domain/entities/warehouse.dart'; @@ -75,16 +76,16 @@ class WarehouseDto { ); static PaginatedResult parsePaginated(Map? json) { - final items = (json?['items'] as List? ?? []) - .whereType>() + final rawItems = JsonUtils.extractList(json, keys: const ['items']); + final items = rawItems .map(WarehouseDto.fromJson) .map((dto) => dto.toEntity()) - .toList(); + .toList(growable: false); 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, + page: JsonUtils.readInt(json, 'page', fallback: 1), + pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length), + total: JsonUtils.readInt(json, 'total', fallback: items.length), ); } } diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index b8e8f8e..651304c 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; +import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; +import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -137,7 +139,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { ? false : (result.page * result.pageSize) < result.total; - final showReset = _searchController.text.isNotEmpty || + final showReset = + _searchController.text.isNotEmpty || _controller.statusFilter != WarehouseStatusFilter.all; return AppLayout( @@ -253,27 +256,25 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { 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, - ), + ? 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, + ), ), ); }, @@ -321,6 +322,17 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { text: existing?.addressDetail ?? '', ); final noteController = TextEditingController(text: existing?.note ?? ''); + final existingZipcode = existing?.zipcode; + final selectedPostalNotifier = ValueNotifier( + existingZipcode == null + ? null + : PostalSearchResult( + zipcode: existingZipcode.zipcode, + sido: existingZipcode.sido, + sigungu: existingZipcode.sigungu, + roadName: existingZipcode.roadName, + ), + ); final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); final saving = ValueNotifier(false); final codeError = ValueNotifier(null); @@ -332,6 +344,30 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { final theme = ShadTheme.of(dialogContext); final materialTheme = Theme.of(dialogContext); final navigator = Navigator.of(dialogContext); + Future openPostalSearch() async { + final keyword = zipcodeController.text.trim(); + final result = await showPostalSearchDialog( + dialogContext, + initialKeyword: keyword.isEmpty ? null : keyword, + ); + if (result == null) { + return; + } + zipcodeController + ..text = result.zipcode + ..selection = TextSelection.collapsed( + offset: result.zipcode.length, + ); + selectedPostalNotifier.value = result; + if (result.fullAddress.isNotEmpty) { + addressController + ..text = result.fullAddress + ..selection = TextSelection.collapsed( + offset: addressController.text.length, + ); + } + } + return Dialog( insetPadding: const EdgeInsets.all(24), clipBehavior: Clip.antiAlias, @@ -488,9 +524,49 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { const SizedBox(height: 16), _FormField( label: '우편번호', - child: ShadInput( - controller: zipcodeController, - placeholder: const Text('예: 06000'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: zipcodeController, + placeholder: const Text('예: 06000'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ShadButton.outline( + onPressed: saving.value + ? null + : openPostalSearch, + child: const Text('검색'), + ), + ], + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: selectedPostalNotifier, + builder: (_, selection, __) { + if (selection == null) { + return Text( + '검색 버튼을 눌러 주소를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ); + } + final fullAddress = selection.fullAddress; + return Text( + fullAddress.isEmpty + ? '선택한 우편번호에 주소 정보가 없습니다.' + : fullAddress, + style: theme.textTheme.small, + ); + }, + ), + ], ), ), const SizedBox(height: 16), @@ -554,6 +630,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { zipcodeController.dispose(); addressController.dispose(); noteController.dispose(); + selectedPostalNotifier.dispose(); isActiveNotifier.dispose(); saving.dispose(); codeError.dispose(); @@ -566,6 +643,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { zipcodeController.dispose(); addressController.dispose(); noteController.dispose(); + selectedPostalNotifier.dispose(); isActiveNotifier.dispose(); saving.dispose(); codeError.dispose(); diff --git a/lib/features/reporting/presentation/pages/reporting_page.dart b/lib/features/reporting/presentation/pages/reporting_page.dart index 2100fb2..c762138 100644 --- a/lib/features/reporting/presentation/pages/reporting_page.dart +++ b/lib/features/reporting/presentation/pages/reporting_page.dart @@ -1,19 +1,169 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import '../../../../core/constants/app_sections.dart'; -import '../../../../widgets/app_layout.dart'; -import '../../../../widgets/components/coming_soon_card.dart'; -import '../../../../widgets/components/filter_bar.dart'; +import 'package:superport_v2/core/constants/app_sections.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/widgets/app_layout.dart'; +import 'package:superport_v2/widgets/components/empty_state.dart'; +import 'package:superport_v2/widgets/components/filter_bar.dart'; -class ReportingPage extends StatelessWidget { +class ReportingPage extends StatefulWidget { const ReportingPage({super.key}); + @override + State createState() => _ReportingPageState(); +} + +class _ReportingPageState extends State { + late final WarehouseRepository _warehouseRepository; + final DateFormat _dateFormat = DateFormat('yyyy.MM.dd'); + + DateTimeRange? _dateRange; + ReportTypeFilter _selectedType = ReportTypeFilter.all; + ReportStatusFilter _selectedStatus = ReportStatusFilter.all; + WarehouseFilterOption _selectedWarehouse = WarehouseFilterOption.all; + + List _warehouseOptions = const [ + WarehouseFilterOption.all, + ]; + bool _isLoadingWarehouses = false; + String? _warehouseError; + + @override + void initState() { + super.initState(); + _warehouseRepository = GetIt.I(); + _loadWarehouses(); + } + + /// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다. + Future _loadWarehouses() async { + setState(() { + _isLoadingWarehouses = true; + _warehouseError = null; + }); + try { + final result = await _warehouseRepository.list( + pageSize: 100, + isActive: true, + ); + if (!mounted) { + return; + } + final seen = {WarehouseFilterOption.all.cacheKey}; + final options = [WarehouseFilterOption.all]; + for (final warehouse in result.items) { + final option = WarehouseFilterOption.fromWarehouse(warehouse); + if (seen.add(option.cacheKey)) { + options.add(option); + } + } + if (mounted) { + setState(() { + _warehouseOptions = options; + WarehouseFilterOption nextSelected = WarehouseFilterOption.all; + for (final option in options) { + if (option == _selectedWarehouse) { + nextSelected = option; + break; + } + } + _selectedWarehouse = nextSelected; + }); + } + } catch (error) { + if (mounted) { + setState(() { + _warehouseError = '창고 목록을 불러오지 못했습니다. 잠시 후 다시 시도하세요.'; + _warehouseOptions = const [WarehouseFilterOption.all]; + _selectedWarehouse = WarehouseFilterOption.all; + }); + } + } finally { + if (mounted) { + setState(() { + _isLoadingWarehouses = false; + }); + } + } + } + + Future _pickDateRange() async { + final now = DateTime.now(); + final initialRange = + _dateRange ?? + DateTimeRange( + start: DateTime( + now.year, + now.month, + now.day, + ).subtract(const Duration(days: 6)), + end: DateTime(now.year, now.month, now.day), + ); + final picked = await showDateRangePicker( + context: context, + initialDateRange: initialRange, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 2), + helpText: '기간 선택', + saveText: '적용', + currentDate: now, + locale: Localizations.localeOf(context), + ); + if (picked != null && mounted) { + setState(() { + _dateRange = picked; + }); + } + } + + void _resetFilters() { + setState(() { + _dateRange = null; + _selectedType = ReportTypeFilter.all; + _selectedStatus = ReportStatusFilter.all; + _selectedWarehouse = WarehouseFilterOption.all; + }); + } + + bool get _canExport { + return _dateRange != null && _selectedType != ReportTypeFilter.all; + } + + bool get _hasCustomFilters { + return _dateRange != null || + _selectedType != ReportTypeFilter.all || + _selectedStatus != ReportStatusFilter.all || + _selectedWarehouse != WarehouseFilterOption.all; + } + + String get _dateRangeLabel { + final range = _dateRange; + if (range == null) { + return '기간 선택'; + } + return '${_formatDate(range.start)} ~ ${_formatDate(range.end)}'; + } + + String _formatDate(DateTime value) => _dateFormat.format(value); + + void _handleExport(ReportExportFormat format) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar(content: Text('${format.label} 다운로드 연동은 준비 중입니다.')), + ); + } + @override Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return AppLayout( title: '보고서', - subtitle: '기간, 유형, 창고 조건을 선택해 통합 보고서를 내려받을 수 있도록 준비 중입니다.', + subtitle: '조건을 선택해 입·출고와 결재 데이터를 내려받을 수 있도록 준비 중입니다.', breadcrumbs: const [ AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), AppBreadcrumbItem(label: '보고', path: '/reports'), @@ -21,12 +171,16 @@ class ReportingPage extends StatelessWidget { ], actions: [ ShadButton( - onPressed: null, + onPressed: _canExport + ? () => _handleExport(ReportExportFormat.xlsx) + : null, leading: const Icon(LucideIcons.fileDown, size: 16), child: const Text('XLSX 다운로드'), ), ShadButton.outline( - onPressed: null, + onPressed: _canExport + ? () => _handleExport(ReportExportFormat.pdf) + : null, leading: const Icon(LucideIcons.fileText, size: 16), child: const Text('PDF 다운로드'), ), @@ -34,38 +188,282 @@ class ReportingPage extends StatelessWidget { toolbar: FilterBar( children: [ ShadButton.outline( - onPressed: null, + onPressed: _pickDateRange, leading: const Icon(LucideIcons.calendar, size: 16), - child: const Text('기간 선택 (준비중)'), + child: Text(_dateRangeLabel), ), - ShadButton.outline( - onPressed: null, - leading: const Icon(LucideIcons.layers, size: 16), - child: const Text('유형 선택 (준비중)'), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_selectedType), + initialValue: _selectedType, + selectedOptionBuilder: (_, value) => Text(value.label), + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedType = value; + }); + }, + options: [ + for (final type in ReportTypeFilter.values) + ShadOption(value: type, child: Text(type.label)), + ], + ), ), - ShadButton.outline( - onPressed: null, - leading: const Icon(LucideIcons.warehouse, size: 16), - child: const Text('창고 선택 (준비중)'), + SizedBox( + width: 220, + child: ShadSelect( + key: ValueKey( + '${_selectedWarehouse.cacheKey}-${_warehouseOptions.length}', + ), + initialValue: _selectedWarehouse, + selectedOptionBuilder: (_, value) => Text(value.label), + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedWarehouse = value; + }); + }, + options: [ + for (final option in _warehouseOptions) + ShadOption(value: option, child: Text(option.label)), + ], + ), ), - ShadButton.outline( - onPressed: null, - leading: const Icon(LucideIcons.badgeCheck, size: 16), - child: const Text('상태 선택 (준비중)'), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_selectedStatus), + initialValue: _selectedStatus, + selectedOptionBuilder: (_, value) => Text(value.label), + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedStatus = value; + }); + }, + options: [ + for (final status in ReportStatusFilter.values) + ShadOption(value: status, child: Text(status.label)), + ], + ), ), - const ShadBadge( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Text('API 스펙 정리 후 필터가 활성화됩니다.'), + ShadButton.ghost( + onPressed: _hasCustomFilters ? _resetFilters : null, + leading: const Icon(LucideIcons.rotateCcw, size: 16), + child: const Text('초기화'), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_warehouseError != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Icon( + LucideIcons.circleAlert, + size: 16, + color: theme.colorScheme.destructive, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _warehouseError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + const SizedBox(width: 8), + ShadButton.ghost( + onPressed: _isLoadingWarehouses ? null : _loadWarehouses, + leading: const Icon(LucideIcons.refreshCw, size: 16), + child: const Text('재시도'), + ), + ], + ), + ) + else if (_isLoadingWarehouses) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text('창고 목록을 불러오는 중입니다...', style: theme.textTheme.small), + ], + ), + ), + ShadCard( + title: Text('선택된 조건', style: theme.textTheme.h3), + description: Text( + '다운로드 전 사용자 조건을 빠르게 검토하세요.', + style: theme.textTheme.muted, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SummaryRow( + label: '기간', + value: _dateRange == null ? '기간을 선택하세요.' : _dateRangeLabel, + ), + _SummaryRow(label: '유형', value: _selectedType.label), + _SummaryRow(label: '창고', value: _selectedWarehouse.label), + _SummaryRow(label: '상태', value: _selectedStatus.label), + if (!_canExport) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '기간과 유형을 선택하면 다운로드 버튼이 활성화됩니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ShadCard( + title: Text('보고서 미리보기', style: theme.textTheme.h3), + description: Text( + '조건을 적용하면 다운로드 진행 상태와 결과 테이블이 이 영역에 표시됩니다.', + style: theme.textTheme.muted, + ), + child: SizedBox( + height: 240, + child: EmptyState( + icon: LucideIcons.chartBar, + message: '필터를 선택하고 다운로드하면 결과 미리보기가 제공됩니다.', + ), ), ), ], ), - child: const ComingSoonCard( - title: '보고서 화면 구현 준비 중', - description: '입·출고/결재 데이터를 조건별로 조회하고 다운로드할 수 있는 UI를 설계 중입니다.', - items: ['조건별 보고서 템플릿 매핑', '다운로드 진행 상태 표시 및 실패 처리', '즐겨찾는 조건 저장/불러오기'], + ); + } +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + child: Text( + label, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded(child: Text(value, style: theme.textTheme.p)), + ], ), ); } } + +enum ReportTypeFilter { all, inbound, outbound, rental, approval } + +extension ReportTypeFilterX on ReportTypeFilter { + String get label { + switch (this) { + case ReportTypeFilter.all: + return '전체 유형'; + case ReportTypeFilter.inbound: + return '입고'; + case ReportTypeFilter.outbound: + return '출고'; + case ReportTypeFilter.rental: + return '대여'; + case ReportTypeFilter.approval: + return '결재'; + } + } +} + +enum ReportStatusFilter { all, inProgress, completed, cancelled } + +extension ReportStatusFilterX on ReportStatusFilter { + String get label { + switch (this) { + case ReportStatusFilter.all: + return '전체 상태'; + case ReportStatusFilter.inProgress: + return '진행중'; + case ReportStatusFilter.completed: + return '완료'; + case ReportStatusFilter.cancelled: + return '취소'; + } + } +} + +enum ReportExportFormat { xlsx, pdf } + +extension ReportExportFormatX on ReportExportFormat { + String get label => switch (this) { + ReportExportFormat.xlsx => 'XLSX', + ReportExportFormat.pdf => 'PDF', + }; +} + +class WarehouseFilterOption { + const WarehouseFilterOption({this.id, required this.label}); + + final int? id; + final String label; + + static const WarehouseFilterOption all = WarehouseFilterOption( + id: null, + label: '전체 창고', + ); + + factory WarehouseFilterOption.fromWarehouse(Warehouse warehouse) { + final id = warehouse.id; + final name = warehouse.warehouseName; + final code = warehouse.warehouseCode; + return WarehouseFilterOption( + id: id, + label: id == null ? name : '$name ($code)', + ); + } + + String get cacheKey => id?.toString() ?? label; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! WarehouseFilterOption) { + return false; + } + if (id != null && other.id != null) { + return id == other.id; + } + return label == other.label; + } + + @override + int get hashCode => cacheKey.hashCode; +} diff --git a/lib/features/util/postal_search/data/dtos/postal_code_dto.dart b/lib/features/util/postal_search/data/dtos/postal_code_dto.dart new file mode 100644 index 0000000..f9279b9 --- /dev/null +++ b/lib/features/util/postal_search/data/dtos/postal_code_dto.dart @@ -0,0 +1,68 @@ +import 'package:superport_v2/core/common/utils/json_utils.dart'; + +import '../../domain/entities/postal_code.dart'; + +/// 우편번호 검색 API 응답을 표현하는 DTO. +class PostalCodeDto { + PostalCodeDto({ + required this.zipcode, + this.sido, + this.sigungu, + this.roadName, + this.buildingMainNo, + this.buildingSubNo, + }); + + final String zipcode; + final String? sido; + final String? sigungu; + final String? roadName; + final int? buildingMainNo; + final int? buildingSubNo; + + factory PostalCodeDto.fromJson(Map json) { + return PostalCodeDto( + zipcode: json['zipcode'] as String, + sido: json['sido'] as String?, + sigungu: json['sigungu'] as String?, + roadName: json['road_name'] as String?, + buildingMainNo: _parseInt(json['building_main_no']), + buildingSubNo: _parseInt(json['building_sub_no']), + ); + } + + PostalCode toEntity() { + return PostalCode( + zipcode: zipcode, + sido: sido, + sigungu: sigungu, + roadName: roadName, + buildingMainNo: buildingMainNo, + buildingSubNo: buildingSubNo, + ); + } + + static List fromResponse(dynamic data) { + final items = JsonUtils.extractList(data, keys: const ['items', 'data']); + if (items.isEmpty) { + return const []; + } + return items + .map(PostalCodeDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(growable: false); + } +} + +int? _parseInt(Object? value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is String) { + return int.tryParse(value); + } + return null; +} diff --git a/lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart b/lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart new file mode 100644 index 0000000..fd63898 --- /dev/null +++ b/lib/features/util/postal_search/data/repositories/postal_search_repository_remote.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; + +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/postal_code.dart'; +import '../../domain/repositories/postal_search_repository.dart'; +import '../dtos/postal_code_dto.dart'; + +/// 우편번호 검색 API를 호출하는 원격 저장소 구현체. +class PostalSearchRepositoryRemote implements PostalSearchRepository { + PostalSearchRepositoryRemote({required ApiClient apiClient}) + : _api = apiClient; + + final ApiClient _api; + + static const _path = '/zipcodes'; + + @override + Future> search({ + required String keyword, + int limit = 20, + }) async { + final trimmed = keyword.trim(); + if (trimmed.isEmpty) { + return const []; + } + + final response = await _api.get( + _path, + query: { + 'zipcode': trimmed, + 'road_name': trimmed, + 'q': trimmed, + 'page_size': limit, + }, + options: Options(responseType: ResponseType.json), + ); + + return PostalCodeDto.fromResponse(response.data); + } +} diff --git a/lib/features/util/postal_search/domain/entities/postal_code.dart b/lib/features/util/postal_search/domain/entities/postal_code.dart new file mode 100644 index 0000000..65ba586 --- /dev/null +++ b/lib/features/util/postal_search/domain/entities/postal_code.dart @@ -0,0 +1,36 @@ +/// 우편번호 검색 결과의 도메인 모델. +/// +/// - `zipcode`: 5자리 우편번호. +/// - `sido`/`sigungu`: 시도 및 시군구 행정 구역. +/// - `roadName`: 도로명 주소 구성 요소. +/// - `buildingMainNo`/`buildingSubNo`: 건물 번호 본번/부번(없으면 null). +class PostalCode { + PostalCode({ + required this.zipcode, + this.sido, + this.sigungu, + this.roadName, + this.buildingMainNo, + this.buildingSubNo, + }); + + final String zipcode; + final String? sido; + final String? sigungu; + final String? roadName; + final int? buildingMainNo; + final int? buildingSubNo; + + /// 건물 번호 문자열을 반환한다. 본번만 존재하면 해당 값만 반환한다. + String get buildingNumber { + final main = buildingMainNo; + final sub = buildingSubNo; + if (main == null) { + return ''; + } + if (sub == null || sub == 0) { + return '$main'; + } + return '$main-$sub'; + } +} diff --git a/lib/features/util/postal_search/domain/repositories/postal_search_repository.dart b/lib/features/util/postal_search/domain/repositories/postal_search_repository.dart new file mode 100644 index 0000000..58cb9a3 --- /dev/null +++ b/lib/features/util/postal_search/domain/repositories/postal_search_repository.dart @@ -0,0 +1,10 @@ +import '../entities/postal_code.dart'; + +/// 우편번호 검색 기능을 추상화한 저장소 인터페이스. +abstract class PostalSearchRepository { + /// 키워드를 기반으로 우편번호 목록을 검색한다. + /// + /// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다. + /// [limit]을 지정하면 최대 반환 건수를 제한한다. + Future> search({required String keyword, int limit = 20}); +} diff --git a/lib/features/util/postal_search/presentation/models/postal_search_result.dart b/lib/features/util/postal_search/presentation/models/postal_search_result.dart new file mode 100644 index 0000000..0d5cf64 --- /dev/null +++ b/lib/features/util/postal_search/presentation/models/postal_search_result.dart @@ -0,0 +1,51 @@ +import '../../domain/entities/postal_code.dart'; + +/// 우편번호 검색 결과를 표현하는 모델. +class PostalSearchResult { + PostalSearchResult({ + required this.zipcode, + this.sido, + this.sigungu, + this.roadName, + this.buildingNumber, + }); + + final String zipcode; + final String? sido; + final String? sigungu; + final String? roadName; + final String? buildingNumber; + + factory PostalSearchResult.fromPostalCode(PostalCode postalCode) { + final buildingValue = postalCode.buildingNumber; + return PostalSearchResult( + zipcode: postalCode.zipcode, + sido: postalCode.sido, + sigungu: postalCode.sigungu, + roadName: postalCode.roadName, + buildingNumber: buildingValue.isEmpty ? null : buildingValue, + ); + } + + /// 주소 구성요소를 공백으로 이어 붙인 표시 문자열. + String get fullAddress { + final segments = []; + final sidoValue = sido; + if (sidoValue != null && sidoValue.isNotEmpty) { + segments.add(sidoValue); + } + final sigunguValue = sigungu; + if (sigunguValue != null && sigunguValue.isNotEmpty) { + segments.add(sigunguValue); + } + final roadNameValue = roadName; + if (roadNameValue != null && roadNameValue.isNotEmpty) { + segments.add(roadNameValue); + } + final buildingNumberValue = buildingNumber; + if (buildingNumberValue != null && buildingNumberValue.isNotEmpty) { + segments.add(buildingNumberValue); + } + return segments.join(' '); + } +} diff --git a/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart new file mode 100644 index 0000000..3f39ea3 --- /dev/null +++ b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/widgets/components/superport_table.dart'; + +import '../../domain/entities/postal_code.dart'; +import '../../domain/repositories/postal_search_repository.dart'; +import '../models/postal_search_result.dart'; + +typedef PostalSearchFetcher = + Future> Function(String keyword); + +/// 우편번호 검색 모달을 노출한다. +Future showPostalSearchDialog( + BuildContext context, { + PostalSearchFetcher? fetcher, + String? initialKeyword, +}) { + return showDialog( + context: context, + builder: (dialogContext) { + return _PostalSearchDialog( + fetcher: fetcher, + initialKeyword: initialKeyword, + ); + }, + ); +} + +class _PostalSearchDialog extends StatefulWidget { + const _PostalSearchDialog({required this.fetcher, this.initialKeyword}); + + final PostalSearchFetcher? fetcher; + final String? initialKeyword; + + @override + State<_PostalSearchDialog> createState() => _PostalSearchDialogState(); +} + +class _PostalSearchDialogState extends State<_PostalSearchDialog> { + late final TextEditingController _keywordController; + final FocusNode _keywordFocus = FocusNode(); + List _results = const []; + bool _isLoading = false; + bool _hasSearched = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _keywordController = TextEditingController(text: widget.initialKeyword); + if (_keywordController.text.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _performSearch(); + }); + } + } + + @override + void dispose() { + _keywordController.dispose(); + _keywordFocus.dispose(); + super.dispose(); + } + + Future _performSearch() async { + final keyword = _keywordController.text.trim(); + if (keyword.isEmpty) { + setState(() { + _results = const []; + _errorMessage = null; + _hasSearched = false; + }); + return; + } + + final fetcher = _resolveFetcher(); + if (fetcher == null) { + setState(() { + _results = const []; + _errorMessage = '검색 기능이 아직 연결되지 않았습니다.'; + _hasSearched = true; + }); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + _hasSearched = true; + }); + + try { + final items = await fetcher(keyword); + if (!mounted) return; + setState(() { + _results = items; + _errorMessage = null; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _results = const []; + _errorMessage = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + PostalSearchFetcher? _resolveFetcher() { + final customFetcher = widget.fetcher; + if (customFetcher != null) { + return customFetcher; + } + + final container = GetIt.instance; + if (!container.isRegistered()) { + return null; + } + + return (keyword) async { + final repository = container(); + final List results = await repository.search( + keyword: keyword, + limit: 50, + ); + return results + .map(PostalSearchResult.fromPostalCode) + .toList(growable: false); + }; + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final navigator = Navigator.of(context); + + return Dialog( + clipBehavior: Clip.antiAlias, + insetPadding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760, maxHeight: 600), + child: ShadCard( + title: Text('우편번호 검색', style: theme.textTheme.h3), + description: Text( + '도로명, 건물번호, 우편번호를 입력하면 검색 결과에서 직접 선택할 수 있습니다.', + style: theme.textTheme.muted, + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: () => navigator.pop(), + child: const Text('닫기'), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: _keywordController, + focusNode: _keywordFocus, + placeholder: const Text('도로명, 건물번호, 우편번호를 입력'), + leading: const Icon(LucideIcons.search, size: 16), + onSubmitted: (_) => _performSearch(), + ), + ), + const SizedBox(width: 12), + ShadButton( + onPressed: _isLoading ? null : _performSearch, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('검색'), + ), + ], + ), + const SizedBox(height: 16), + Expanded(child: _buildResultArea(theme, navigator)), + ], + ), + ), + ), + ); + } + + Widget _buildResultArea(ShadThemeData theme, NavigatorState navigator) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _errorMessage!, + textAlign: TextAlign.center, + style: theme.textTheme.small, + ), + ), + ); + } + + if (_results.isEmpty) { + final label = _hasSearched + ? '검색 결과가 없습니다. 다른 키워드로 시도해 주세요.' + : '검색어를 입력한 뒤 엔터 또는 검색 버튼을 눌러 주세요.'; + return Center(child: Text(label, style: theme.textTheme.muted)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('검색 결과 ${_results.length}건', style: theme.textTheme.muted), + const SizedBox(height: 8), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SuperportTable( + columns: const [ + Text('우편번호'), + Text('시도'), + Text('시군구'), + Text('도로명'), + Text('건물번호'), + ], + rows: [ + for (final item in _results) + [ + Text(item.zipcode), + Text(item.sido ?? '-'), + Text(item.sigungu ?? '-'), + Text(item.roadName ?? '-'), + Text(item.buildingNumber ?? '-'), + ], + ], + onRowTap: (index) { + navigator.pop(_results[index]); + }, + emptyLabel: '검색 결과가 없습니다.', + ), + ), + ), + ), + const SizedBox(height: 8), + Text('행을 클릭하면 선택한 주소가 부모 폼에 채워집니다.', style: theme.textTheme.small), + ], + ); + } +} diff --git a/lib/injection_container.dart b/lib/injection_container.dart index c184672..6a68677 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -31,6 +31,8 @@ import 'features/approvals/step/data/repositories/approval_step_repository_remot import 'features/approvals/domain/repositories/approval_repository.dart'; import 'features/approvals/domain/repositories/approval_template_repository.dart'; import 'features/approvals/step/domain/repositories/approval_step_repository.dart'; +import 'features/util/postal_search/data/repositories/postal_search_repository_remote.dart'; +import 'features/util/postal_search/domain/repositories/postal_search_repository.dart'; /// 전역 DI 컨테이너 final GetIt sl = GetIt.instance; @@ -113,4 +115,8 @@ Future initInjection({ sl.registerLazySingleton( () => ApprovalHistoryRepositoryRemote(apiClient: sl()), ); + + sl.registerLazySingleton( + () => PostalSearchRepositoryRemote(apiClient: sl()), + ); } diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart index bc0fd91..2075459 100644 --- a/test/features/masters/customer/presentation/pages/customer_page_test.dart +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -41,7 +41,9 @@ void main() { testWidgets('플래그 Off 시 스펙 화면 유지', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_CUSTOMERS_ENABLED=false\n'); - await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pumpWidget( + _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + ); await tester.pump(); expect(find.text('회사(고객사) 관리'), findsOneWidget); @@ -85,7 +87,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pumpWidget( + _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + ); await tester.pumpAndSettle(); expect(find.text('C-001'), findsOneWidget); @@ -120,7 +124,9 @@ void main() { ), ); - await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pumpWidget( + _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록')); @@ -181,7 +187,9 @@ void main() { ); }); - await tester.pumpWidget(_buildApp(const CustomerPage())); + await tester.pumpWidget( + _buildApp(CustomerPage(routeUri: Uri(path: '/masters/customers'))), + ); await tester.pumpAndSettle(); await tester.tap(find.text('신규 등록'));