고객사 목록 쿼리스트링 연동 및 공통 JSON 파서 도입

This commit is contained in:
JiWoong Sul
2025-09-25 20:13:46 +09:00
parent 8a6ad1e81b
commit 900990c46b
27 changed files with 1458 additions and 176 deletions

View File

@@ -64,17 +64,17 @@
- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) - [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정)
## 8) 우편번호 검색 모달(UI) ## 8) 우편번호 검색 모달(UI)
- [ ] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음) - [x] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음)
- [ ] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현) - [x] 결과 테이블: 우편번호/시도/시군구/도로명/건물번호 (현황: 결과 렌더링 미구현)
- [ ] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현) - [x] 선택 시: 부모 폼 `zipcode`/주소 구성요소 채움 (현황: Warehouse/Customer 폼과의 데이터 바인딩 미구현)
## 9) 보고서(`/reports`) UI ## 9) 보고서(`/reports`) UI
- [ ] 조건 폼: 기간/유형/창고/상태 (현황: `ReportingPage`를 AppLayout + FilterBar 플레이스홀더로 전환, 조건 버튼은 비활성 상태로 안내만 제공) - [x] 조건 폼: 기간/유형/창고/상태 (현황: 기간 범위 선택과 유형/창고/상태 셀렉트를 제공하고 창고 목록은 Repository에서 로드하여 필터바에 표시됨)
- [ ] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) (현황: 헤더 액션에 비활성 버튼 배치, 실제 다운로드 연동과 상태 제어는 미구현) - [x] 액션: XLSX/PDF 버튼 — 미제공 시 버튼 비활성 UI(연동은 API 단계에서) (현황: 기간+유형 선택 시 활성화되며 현재는 안내 스낵바로 연동 준비 상태를 표시함)
## 10) 데이터 계층/상태 관리 ## 10) 데이터 계층/상태 관리
- [ ] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 마스터·결재·UOM에 대한 domain/data 레이어만 존재하고 입·출·대여/보고서/우편번호 리포지토리 미정의) - [x] 리포지토리 인터페이스(domain) 정의 및 구현(data): 트랜잭션/결재/마스터/룩업/우편번호 (현황: 우편번호 검색 도메인/데이터 레이어를 추가해 `/zipcodes` 호출이 가능하며, 입·출·대여/보고서 리포지토리는 후속 예정)
- [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: 마스터/결재 DTO는 작성됐으나 인벤토리/보고서 DTO 부재, `{ data }` fallback 처리 통일 미완료) - [ ] DTO ↔ 도메인 엔티티 매핑(응답 `{ items }`/`{ data }` 구조 표준화) (현황: `JsonUtils` 헬퍼를 도입해 마스터/결재 DTO 상당수에 공통 파서를 적용했고 잔여 인벤토리/보고서 DTO 확장은 진행 중)
- [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 각 Controller가 내부 `PaginatedResult`만 유지하고 라우터 querystring과 동기화되지 않음) - [ ] 페이지네이션 상태(현재 페이지/사이즈/전체) 및 필터 상태 싱크(go_router querystring 연동) (현황: 각 Controller가 내부 `PaginatedResult`만 유지하고 라우터 querystring과 동기화되지 않음)
- [ ] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 검색어만 로컬 상태로 보관하며 sort/include 파라미터 직렬화/복원 로직 없음) - [ ] 정렬/검색/Include 옵션 직렬화 및 유지 (현황: 검색어만 로컬 상태로 보관하며 sort/include 파라미터 직렬화/복원 로직 없음)
@@ -88,7 +88,7 @@
- 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재) - 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재)
- 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요) - 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요)
- 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 템플릿 DTO/리포지토리/컨트롤러를 구현해 CRUD·단계 등록까지 API 연동이 완료됐고 나머지 결재 목록/이력/권한 제어는 진행 중) - 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 템플릿 DTO/리포지토리/컨트롤러를 구현해 CRUD·단계 등록까지 API 연동이 완료됐고 나머지 결재 목록/이력/권한 제어는 진행 중)
- 우편번호: `GET /zipcodes?...` (현황: 리포지토리/네트워킹 미구현) - 우편번호: `GET /zipcodes?...` (현황: 검색 모달에서 Repository를 통해 `/zipcodes` 호출하고 결과를 표시, 고급 검색 조건은 추후 확장)
- 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현) - 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현)
## 12) 검증/접근성/상호작용 ## 12) 검증/접근성/상호작용

View File

@@ -0,0 +1,71 @@
/// JSON 응답 형태를 일관되게 다루기 위한 헬퍼 모음.
class JsonUtils {
JsonUtils._();
/// 리스트 응답을 추출한다.
///
/// - [source]가 List이면 맵 형태의 요소만 반환한다.
/// - Map이면 [keys] 순서대로 탐색하며 List/Map을 찾아 리스트로 변환한다.
static List<Map<String, dynamic>> extractList(
dynamic source, {
List<String> keys = const ['items', 'data', 'results'],
}) {
if (source is List) {
return source.whereType<Map<String, dynamic>>().toList(growable: false);
}
if (source is Map<String, dynamic>) {
for (final key in keys) {
if (!source.containsKey(key)) continue;
final value = source[key];
if (value is List) {
return value.whereType<Map<String, dynamic>>().toList(
growable: false,
);
}
if (value is Map<String, dynamic>) {
return [value];
}
}
}
return const [];
}
/// Map 응답을 추출한다. 기본적으로 `data` 키를 우선 확인한다.
static Map<String, dynamic> extractMap(
dynamic source, {
List<String> keys = const ['data'],
}) {
if (source is Map<String, dynamic>) {
for (final key in keys) {
final value = source[key];
if (value is Map<String, dynamic>) {
return value;
}
}
return source;
}
return const {};
}
/// 맵에서 정수 값을 안전하게 읽는다. 문자열/실수도 허용한다.
static int readInt(
Map<String, dynamic>? 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;
}
}

View File

@@ -77,7 +77,7 @@ final appRouter = GoRouter(
GoRoute( GoRoute(
path: '/masters/customers', path: '/masters/customers',
name: 'masters-customers', name: 'masters-customers',
builder: (context, state) => const CustomerPage(), builder: (context, state) => CustomerPage(routeUri: state.uri),
), ),
GoRoute( GoRoute(
path: '/masters/users', path: '/masters/users',

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/approval.dart';
@@ -92,16 +93,16 @@ class ApprovalDto {
); );
static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(ApprovalDto.fromJson) .map(ApprovalDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<Approval>( return PaginatedResult<Approval>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/approval_template.dart';
@@ -68,16 +69,16 @@ class ApprovalTemplateDto {
Map<String, dynamic>? json, { Map<String, dynamic>? json, {
bool includeSteps = false, bool includeSteps = false,
}) { }) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(ApprovalTemplateDto.fromJson) .map(ApprovalTemplateDto.fromJson)
.map((dto) => dto.toEntity(includeSteps: includeSteps)) .map((dto) => dto.toEntity(includeSteps: includeSteps))
.toList(); .toList(growable: false);
return PaginatedResult<ApprovalTemplate>( return PaginatedResult<ApprovalTemplate>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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/data/dtos/approval_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
@@ -91,17 +92,17 @@ class ApprovalHistoryRecordDto {
static PaginatedResult<ApprovalHistoryRecord> parsePaginated( static PaginatedResult<ApprovalHistoryRecord> parsePaginated(
Map<String, dynamic>? json, Map<String, dynamic>? json,
) { ) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(ApprovalHistoryRecordDto.fromJson) .map(ApprovalHistoryRecordDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<ApprovalHistoryRecord>( return PaginatedResult<ApprovalHistoryRecord>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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/data/dtos/approval_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
@@ -61,17 +62,17 @@ class ApprovalStepRecordDto {
static PaginatedResult<ApprovalStepRecord> parsePaginated( static PaginatedResult<ApprovalStepRecord> parsePaginated(
Map<String, dynamic>? json, Map<String, dynamic>? json,
) { ) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(ApprovalStepRecordDto.fromJson) .map(ApprovalStepRecordDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<ApprovalStepRecord>( return PaginatedResult<ApprovalStepRecord>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/customer.dart';
@@ -93,16 +94,16 @@ class CustomerDto {
); );
static PaginatedResult<Customer> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Customer> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(CustomerDto.fromJson) .map(CustomerDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<Customer>( return PaginatedResult<Customer>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -9,6 +9,8 @@ enum CustomerTypeFilter { all, partner, general }
enum CustomerStatusFilter { all, activeOnly, inactiveOnly } enum CustomerStatusFilter { all, activeOnly, inactiveOnly }
class CustomerController extends ChangeNotifier { class CustomerController extends ChangeNotifier {
static const int defaultPageSize = 20;
CustomerController({required CustomerRepository repository}) CustomerController({required CustomerRepository repository})
: _repository = repository; : _repository = repository;
@@ -20,6 +22,7 @@ class CustomerController extends ChangeNotifier {
String _query = ''; String _query = '';
CustomerTypeFilter _typeFilter = CustomerTypeFilter.all; CustomerTypeFilter _typeFilter = CustomerTypeFilter.all;
CustomerStatusFilter _statusFilter = CustomerStatusFilter.all; CustomerStatusFilter _statusFilter = CustomerStatusFilter.all;
int _pageSize = defaultPageSize;
String? _errorMessage; String? _errorMessage;
PaginatedResult<Customer>? get result => _result; PaginatedResult<Customer>? get result => _result;
@@ -28,6 +31,7 @@ class CustomerController extends ChangeNotifier {
String get query => _query; String get query => _query;
CustomerTypeFilter get typeFilter => _typeFilter; CustomerTypeFilter get typeFilter => _typeFilter;
CustomerStatusFilter get statusFilter => _statusFilter; CustomerStatusFilter get statusFilter => _statusFilter;
int get pageSize => _pageSize;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
Future<void> fetch({int page = 1}) async { Future<void> fetch({int page = 1}) async {
@@ -60,13 +64,16 @@ class CustomerController extends ChangeNotifier {
final response = await _repository.list( final response = await _repository.list(
page: page, page: page,
pageSize: _result?.pageSize ?? 20, pageSize: _pageSize,
query: _query.isEmpty ? null : _query, query: _query.isEmpty ? null : _query,
isPartner: isPartner, isPartner: isPartner,
isGeneral: isGeneral, isGeneral: isGeneral,
isActive: isActive, isActive: isActive,
); );
_result = response; _result = response;
if (response.pageSize > 0 && response.pageSize != _pageSize) {
_pageSize = response.pageSize;
}
} catch (e) { } catch (e) {
_errorMessage = e.toString(); _errorMessage = e.toString();
} finally { } finally {
@@ -76,20 +83,37 @@ class CustomerController extends ChangeNotifier {
} }
void updateQuery(String value) { void updateQuery(String value) {
if (_query == value) {
return;
}
_query = value; _query = value;
notifyListeners(); notifyListeners();
} }
void updateTypeFilter(CustomerTypeFilter filter) { void updateTypeFilter(CustomerTypeFilter filter) {
if (_typeFilter == filter) {
return;
}
_typeFilter = filter; _typeFilter = filter;
notifyListeners(); notifyListeners();
} }
void updateStatusFilter(CustomerStatusFilter filter) { void updateStatusFilter(CustomerStatusFilter filter) {
if (_statusFilter == filter) {
return;
}
_statusFilter = filter; _statusFilter = filter;
notifyListeners(); notifyListeners();
} }
void updatePageSize(int size) {
if (size <= 0 || _pageSize == size) {
return;
}
_pageSize = size;
notifyListeners();
}
Future<Customer?> create(CustomerInput input) async { Future<Customer?> create(CustomerInput input) async {
_setSubmitting(true); _setSubmitting(true);
try { try {

View File

@@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/filter_bar.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 '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart'; import '../../../../../widgets/spec_page.dart';
@@ -13,7 +16,9 @@ import '../../domain/repositories/customer_repository.dart';
import '../controllers/customer_controller.dart'; import '../controllers/customer_controller.dart';
class CustomerPage extends StatelessWidget { class CustomerPage extends StatelessWidget {
const CustomerPage({super.key}); const CustomerPage({super.key, required this.routeUri});
final Uri routeUri;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -76,12 +81,14 @@ class CustomerPage extends StatelessWidget {
); );
} }
return const _CustomerEnabledPage(); return _CustomerEnabledPage(routeUri: routeUri);
} }
} }
class _CustomerEnabledPage extends StatefulWidget { class _CustomerEnabledPage extends StatefulWidget {
const _CustomerEnabledPage(); const _CustomerEnabledPage({required this.routeUri});
final Uri routeUri;
@override @override
State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState(); State<_CustomerEnabledPage> createState() => _CustomerEnabledPageState();
@@ -92,15 +99,22 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode(); final FocusNode _searchFocus = FocusNode();
String? _lastError; String? _lastError;
bool _routeApplied = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = CustomerController(repository: GetIt.I<CustomerRepository>()) _controller = CustomerController(repository: GetIt.I<CustomerRepository>())
..addListener(_handleControllerUpdate); ..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) { }
_controller.fetch();
}); @override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_routeApplied) {
_routeApplied = true;
_applyRouteParameters();
}
} }
void _handleControllerUpdate() { 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 @override
void dispose() { void dispose() {
_controller.removeListener(_handleControllerUpdate); _controller.removeListener(_handleControllerUpdate);
@@ -141,7 +175,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
? false ? false
: (result.page * result.pageSize) < result.total; : (result.page * result.pageSize) < result.total;
final showReset = _searchController.text.isNotEmpty || final showReset =
_searchController.text.isNotEmpty ||
_controller.typeFilter != CustomerTypeFilter.all || _controller.typeFilter != CustomerTypeFilter.all ||
_controller.statusFilter != CustomerStatusFilter.all; _controller.statusFilter != CustomerStatusFilter.all;
@@ -232,7 +267,12 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
_controller.updateStatusFilter( _controller.updateStatusFilter(
CustomerStatusFilter.all, CustomerStatusFilter.all,
); );
_controller.fetch(page: 1); _updateRoute(
page: 1,
queryOverride: '',
typeOverride: CustomerTypeFilter.all,
statusOverride: CustomerStatusFilter.all,
);
}, },
child: const Text('초기화'), child: const Text('초기화'),
), ),
@@ -259,7 +299,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1 onPressed: _controller.isLoading || currentPage <= 1
? null ? null
: () => _controller.fetch(page: currentPage - 1), : () => _goToPage(currentPage - 1),
child: const Text('이전'), child: const Text('이전'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -267,7 +307,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext onPressed: _controller.isLoading || !hasNext
? null ? null
: () => _controller.fetch(page: currentPage + 1), : () => _goToPage(currentPage + 1),
child: const Text('다음'), child: const Text('다음'),
), ),
], ],
@@ -293,9 +333,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
? null ? null
: (customer) => : (customer) =>
_openCustomerForm(context, customer: customer), _openCustomerForm(context, customer: customer),
onDelete: _controller.isSubmitting onDelete: _controller.isSubmitting ? null : _confirmDelete,
? null
: _confirmDelete,
onRestore: _controller.isSubmitting onRestore: _controller.isSubmitting
? null ? null
: _restoreCustomer, : _restoreCustomer,
@@ -307,8 +345,103 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
} }
void _applyFilters() { void _applyFilters() {
_controller.updateQuery(_searchController.text.trim()); final keyword = _searchController.text.trim();
_controller.fetch(page: 1); _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 = <String, String>{};
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) { String _typeLabel(CustomerTypeFilter filter) {
@@ -364,6 +497,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
text: existing?.addressDetail ?? '', text: existing?.addressDetail ?? '',
); );
final noteController = TextEditingController(text: existing?.note ?? ''); final noteController = TextEditingController(text: existing?.note ?? '');
final existingZipcode = existing?.zipcode;
final selectedPostalNotifier = ValueNotifier<PostalSearchResult?>(
existingZipcode == null
? null
: PostalSearchResult(
zipcode: existingZipcode.zipcode,
sido: existingZipcode.sido,
sigungu: existingZipcode.sigungu,
roadName: existingZipcode.roadName,
),
);
final partnerNotifier = ValueNotifier<bool>(existing?.isPartner ?? false); final partnerNotifier = ValueNotifier<bool>(existing?.isPartner ?? false);
final generalNotifier = ValueNotifier<bool>(existing?.isGeneral ?? true); final generalNotifier = ValueNotifier<bool>(existing?.isGeneral ?? true);
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true); final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
@@ -378,6 +522,30 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
final theme = ShadTheme.of(dialogContext); final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext); final materialTheme = Theme.of(dialogContext);
final navigator = Navigator.of(dialogContext); final navigator = Navigator.of(dialogContext);
Future<void> 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( return Dialog(
insetPadding: const EdgeInsets.all(24), insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -648,9 +816,49 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
_FormField( _FormField(
label: '우편번호', label: '우편번호',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: ShadInput( child: ShadInput(
controller: zipcodeController, controller: zipcodeController,
placeholder: const Text('예: 06000'), 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<PostalSearchResult?>(
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), const SizedBox(height: 16),
@@ -704,6 +912,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
zipcodeController.dispose(); zipcodeController.dispose();
addressController.dispose(); addressController.dispose();
noteController.dispose(); noteController.dispose();
selectedPostalNotifier.dispose();
partnerNotifier.dispose(); partnerNotifier.dispose();
generalNotifier.dispose(); generalNotifier.dispose();
isActiveNotifier.dispose(); isActiveNotifier.dispose();

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/group.dart';
@@ -52,16 +53,16 @@ class GroupDto {
); );
static PaginatedResult<Group> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Group> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(GroupDto.fromJson) .map(GroupDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<Group>( return PaginatedResult<Group>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/menu.dart';
@@ -64,16 +65,16 @@ class MenuDto {
); );
static PaginatedResult<MenuItem> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<MenuItem> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(MenuDto.fromJson) .map(MenuDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<MenuItem>( return PaginatedResult<MenuItem>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/product.dart';
@@ -96,16 +97,16 @@ class ProductDto {
); );
static PaginatedResult<Product> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Product> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(ProductDto.fromJson) .map(ProductDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<Product>( return PaginatedResult<Product>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/uom.dart';
@@ -48,16 +49,16 @@ class UomDto {
); );
static PaginatedResult<Uom> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Uom> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(UomDto.fromJson) .map(UomDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<Uom>( return PaginatedResult<Uom>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/user.dart';
@@ -64,16 +65,16 @@ class UserDto {
static PaginatedResult<UserAccount> parsePaginated( static PaginatedResult<UserAccount> parsePaginated(
Map<String, dynamic>? json, Map<String, dynamic>? json,
) { ) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(UserDto.fromJson) .map(UserDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<UserAccount>( return PaginatedResult<UserAccount>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/vendor.dart';
@@ -73,16 +74,16 @@ class VendorDto {
); );
static PaginatedResult<Vendor> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Vendor> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(VendorDto.fromJson) .map(VendorDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<Vendor>( return PaginatedResult<Vendor>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:superport_v2/core/common/models/paginated_result.dart'; 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'; import '../../domain/entities/warehouse.dart';
@@ -75,16 +76,16 @@ class WarehouseDto {
); );
static PaginatedResult<Warehouse> parsePaginated(Map<String, dynamic>? json) { static PaginatedResult<Warehouse> parsePaginated(Map<String, dynamic>? json) {
final items = (json?['items'] as List<dynamic>? ?? []) final rawItems = JsonUtils.extractList(json, keys: const ['items']);
.whereType<Map<String, dynamic>>() final items = rawItems
.map(WarehouseDto.fromJson) .map(WarehouseDto.fromJson)
.map((dto) => dto.toEntity()) .map((dto) => dto.toEntity())
.toList(); .toList(growable: false);
return PaginatedResult<Warehouse>( return PaginatedResult<Warehouse>(
items: items, items: items,
page: json?['page'] as int? ?? 1, page: JsonUtils.readInt(json, 'page', fallback: 1),
pageSize: json?['page_size'] as int? ?? items.length, pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
total: json?['total'] as int? ?? items.length, total: JsonUtils.readInt(json, 'total', fallback: items.length),
); );
} }
} }

View File

@@ -5,6 +5,8 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/filter_bar.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 '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart'; import '../../../../../widgets/spec_page.dart';
@@ -137,7 +139,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
? false ? false
: (result.page * result.pageSize) < result.total; : (result.page * result.pageSize) < result.total;
final showReset = _searchController.text.isNotEmpty || final showReset =
_searchController.text.isNotEmpty ||
_controller.statusFilter != WarehouseStatusFilter.all; _controller.statusFilter != WarehouseStatusFilter.all;
return AppLayout( return AppLayout(
@@ -267,9 +270,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
? null ? null
: (warehouse) => : (warehouse) =>
_openWarehouseForm(context, warehouse: warehouse), _openWarehouseForm(context, warehouse: warehouse),
onDelete: _controller.isSubmitting onDelete: _controller.isSubmitting ? null : _confirmDelete,
? null
: _confirmDelete,
onRestore: _controller.isSubmitting onRestore: _controller.isSubmitting
? null ? null
: _restoreWarehouse, : _restoreWarehouse,
@@ -321,6 +322,17 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
text: existing?.addressDetail ?? '', text: existing?.addressDetail ?? '',
); );
final noteController = TextEditingController(text: existing?.note ?? ''); final noteController = TextEditingController(text: existing?.note ?? '');
final existingZipcode = existing?.zipcode;
final selectedPostalNotifier = ValueNotifier<PostalSearchResult?>(
existingZipcode == null
? null
: PostalSearchResult(
zipcode: existingZipcode.zipcode,
sido: existingZipcode.sido,
sigungu: existingZipcode.sigungu,
roadName: existingZipcode.roadName,
),
);
final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true); final isActiveNotifier = ValueNotifier<bool>(existing?.isActive ?? true);
final saving = ValueNotifier<bool>(false); final saving = ValueNotifier<bool>(false);
final codeError = ValueNotifier<String?>(null); final codeError = ValueNotifier<String?>(null);
@@ -332,6 +344,30 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
final theme = ShadTheme.of(dialogContext); final theme = ShadTheme.of(dialogContext);
final materialTheme = Theme.of(dialogContext); final materialTheme = Theme.of(dialogContext);
final navigator = Navigator.of(dialogContext); final navigator = Navigator.of(dialogContext);
Future<void> 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( return Dialog(
insetPadding: const EdgeInsets.all(24), insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -488,9 +524,49 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
_FormField( _FormField(
label: '우편번호', label: '우편번호',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: ShadInput( child: ShadInput(
controller: zipcodeController, controller: zipcodeController,
placeholder: const Text('예: 06000'), 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<PostalSearchResult?>(
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), const SizedBox(height: 16),
@@ -554,6 +630,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
zipcodeController.dispose(); zipcodeController.dispose();
addressController.dispose(); addressController.dispose();
noteController.dispose(); noteController.dispose();
selectedPostalNotifier.dispose();
isActiveNotifier.dispose(); isActiveNotifier.dispose();
saving.dispose(); saving.dispose();
codeError.dispose(); codeError.dispose();
@@ -566,6 +643,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
zipcodeController.dispose(); zipcodeController.dispose();
addressController.dispose(); addressController.dispose();
noteController.dispose(); noteController.dispose();
selectedPostalNotifier.dispose();
isActiveNotifier.dispose(); isActiveNotifier.dispose();
saving.dispose(); saving.dispose();
codeError.dispose(); codeError.dispose();

View File

@@ -1,19 +1,169 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/constants/app_sections.dart'; import 'package:superport_v2/core/constants/app_sections.dart';
import '../../../../widgets/app_layout.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import '../../../../widgets/components/coming_soon_card.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
import '../../../../widgets/components/filter_bar.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}); const ReportingPage({super.key});
@override
State<ReportingPage> createState() => _ReportingPageState();
}
class _ReportingPageState extends State<ReportingPage> {
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<WarehouseFilterOption> _warehouseOptions = const [
WarehouseFilterOption.all,
];
bool _isLoadingWarehouses = false;
String? _warehouseError;
@override
void initState() {
super.initState();
_warehouseRepository = GetIt.I<WarehouseRepository>();
_loadWarehouses();
}
/// 활성 창고 목록을 불러와 드롭다운 옵션을 준비한다.
Future<void> _loadWarehouses() async {
setState(() {
_isLoadingWarehouses = true;
_warehouseError = null;
});
try {
final result = await _warehouseRepository.list(
pageSize: 100,
isActive: true,
);
if (!mounted) {
return;
}
final seen = <String>{WarehouseFilterOption.all.cacheKey};
final options = <WarehouseFilterOption>[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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AppLayout( return AppLayout(
title: '보고서', title: '보고서',
subtitle: '기간, 유형, 창고 조건을 선택해 통합 보고서를 내려받을 수 있도록 준비 중입니다.', subtitle: '조건을 선택해 입·출고와 결재 데이터를 내려받을 수 있도록 준비 중입니다.',
breadcrumbs: const [ breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '보고', path: '/reports'), AppBreadcrumbItem(label: '보고', path: '/reports'),
@@ -21,12 +171,16 @@ class ReportingPage extends StatelessWidget {
], ],
actions: [ actions: [
ShadButton( ShadButton(
onPressed: null, onPressed: _canExport
? () => _handleExport(ReportExportFormat.xlsx)
: null,
leading: const Icon(LucideIcons.fileDown, size: 16), leading: const Icon(LucideIcons.fileDown, size: 16),
child: const Text('XLSX 다운로드'), child: const Text('XLSX 다운로드'),
), ),
ShadButton.outline( ShadButton.outline(
onPressed: null, onPressed: _canExport
? () => _handleExport(ReportExportFormat.pdf)
: null,
leading: const Icon(LucideIcons.fileText, size: 16), leading: const Icon(LucideIcons.fileText, size: 16),
child: const Text('PDF 다운로드'), child: const Text('PDF 다운로드'),
), ),
@@ -34,38 +188,282 @@ class ReportingPage extends StatelessWidget {
toolbar: FilterBar( toolbar: FilterBar(
children: [ children: [
ShadButton.outline( ShadButton.outline(
onPressed: null, onPressed: _pickDateRange,
leading: const Icon(LucideIcons.calendar, size: 16), leading: const Icon(LucideIcons.calendar, size: 16),
child: const Text('기간 선택 (준비중)'), child: Text(_dateRangeLabel),
), ),
ShadButton.outline( SizedBox(
onPressed: null, width: 200,
leading: const Icon(LucideIcons.layers, size: 16), child: ShadSelect<ReportTypeFilter>(
child: const Text('유형 선택 (준비중)'), 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('창고 선택 (준비중)'),
), ),
ShadButton.outline( SizedBox(
onPressed: null, width: 220,
leading: const Icon(LucideIcons.badgeCheck, size: 16), child: ShadSelect<WarehouseFilterOption>(
child: const Text('상태 선택 (준비중)'), 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)),
],
),
),
SizedBox(
width: 200,
child: ShadSelect<ReportStatusFilter>(
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)),
],
),
),
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 ShadBadge(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text('API 스펙 정리 후 필터가 활성화됩니다.'),
), ),
), ),
], ],
), ),
child: const ComingSoonCard( ),
title: '보고서 화면 구현 준비 중', const SizedBox(height: 24),
description: '입·출고/결재 데이터를 조건별로 조회하고 다운로드할 수 있는 UI를 설계 중입니다.', ShadCard(
items: ['조건별 보고서 템플릿 매핑', '다운로드 진행 상태 표시 및 실패 처리', '즐겨찾는 조건 저장/불러오기'], title: Text('보고서 미리보기', style: theme.textTheme.h3),
description: Text(
'조건을 적용하면 다운로드 진행 상태와 결과 테이블이 이 영역에 표시됩니다.',
style: theme.textTheme.muted,
),
child: SizedBox(
height: 240,
child: EmptyState(
icon: LucideIcons.chartBar,
message: '필터를 선택하고 다운로드하면 결과 미리보기가 제공됩니다.',
),
),
),
],
), ),
); );
} }
} }
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;
}

View File

@@ -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<String, dynamic> 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<PostalCode> 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;
}

View File

@@ -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<List<PostalCode>> search({
required String keyword,
int limit = 20,
}) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) {
return const [];
}
final response = await _api.get<dynamic>(
_path,
query: {
'zipcode': trimmed,
'road_name': trimmed,
'q': trimmed,
'page_size': limit,
},
options: Options(responseType: ResponseType.json),
);
return PostalCodeDto.fromResponse(response.data);
}
}

View File

@@ -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';
}
}

View File

@@ -0,0 +1,10 @@
import '../entities/postal_code.dart';
/// 우편번호 검색 기능을 추상화한 저장소 인터페이스.
abstract class PostalSearchRepository {
/// 키워드를 기반으로 우편번호 목록을 검색한다.
///
/// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다.
/// [limit]을 지정하면 최대 반환 건수를 제한한다.
Future<List<PostalCode>> search({required String keyword, int limit = 20});
}

View File

@@ -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 = <String>[];
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(' ');
}
}

View File

@@ -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<List<PostalSearchResult>> Function(String keyword);
/// 우편번호 검색 모달을 노출한다.
Future<PostalSearchResult?> showPostalSearchDialog(
BuildContext context, {
PostalSearchFetcher? fetcher,
String? initialKeyword,
}) {
return showDialog<PostalSearchResult>(
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<PostalSearchResult> _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<void> _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<PostalSearchRepository>()) {
return null;
}
return (keyword) async {
final repository = container<PostalSearchRepository>();
final List<PostalCode> 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),
],
);
}
}

View File

@@ -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_repository.dart';
import 'features/approvals/domain/repositories/approval_template_repository.dart'; import 'features/approvals/domain/repositories/approval_template_repository.dart';
import 'features/approvals/step/domain/repositories/approval_step_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 컨테이너 /// 전역 DI 컨테이너
final GetIt sl = GetIt.instance; final GetIt sl = GetIt.instance;
@@ -113,4 +115,8 @@ Future<void> initInjection({
sl.registerLazySingleton<ApprovalHistoryRepository>( sl.registerLazySingleton<ApprovalHistoryRepository>(
() => ApprovalHistoryRepositoryRemote(apiClient: sl<ApiClient>()), () => ApprovalHistoryRepositoryRemote(apiClient: sl<ApiClient>()),
); );
sl.registerLazySingleton<PostalSearchRepository>(
() => PostalSearchRepositoryRemote(apiClient: sl<ApiClient>()),
);
} }

View File

@@ -41,7 +41,9 @@ void main() {
testWidgets('플래그 Off 시 스펙 화면 유지', (tester) async { testWidgets('플래그 Off 시 스펙 화면 유지', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_CUSTOMERS_ENABLED=false\n'); 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(); await tester.pump();
expect(find.text('회사(고객사) 관리'), findsOneWidget); 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(); await tester.pumpAndSettle();
expect(find.text('C-001'), findsOneWidget); 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.pumpAndSettle();
await tester.tap(find.text('신규 등록')); 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.pumpAndSettle();
await tester.tap(find.text('신규 등록')); await tester.tap(find.text('신규 등록'));