고객사 목록 쿼리스트링 연동 및 공통 JSON 파서 도입
This commit is contained in:
71
lib/core/common/utils/json_utils.dart
Normal file
71
lib/core/common/utils/json_utils.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Approval> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(ApprovalDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<Approval>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>? json, {
|
||||
bool includeSteps = false,
|
||||
}) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
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<ApprovalTemplate>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApprovalHistoryRecord> parsePaginated(
|
||||
Map<String, dynamic>? json,
|
||||
) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(ApprovalHistoryRecordDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
|
||||
return PaginatedResult<ApprovalHistoryRecord>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApprovalStepRecord> parsePaginated(
|
||||
Map<String, dynamic>? json,
|
||||
) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(ApprovalStepRecordDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
|
||||
return PaginatedResult<ApprovalStepRecord>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Customer> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(CustomerDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<Customer>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Customer>? 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<void> 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<Customer?> create(CustomerInput input) async {
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
|
||||
@@ -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<CustomerRepository>())
|
||||
..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 = <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) {
|
||||
@@ -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<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 generalNotifier = ValueNotifier<bool>(existing?.isGeneral ?? true);
|
||||
final isActiveNotifier = ValueNotifier<bool>(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<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(
|
||||
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<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),
|
||||
@@ -704,6 +912,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
zipcodeController.dispose();
|
||||
addressController.dispose();
|
||||
noteController.dispose();
|
||||
selectedPostalNotifier.dispose();
|
||||
partnerNotifier.dispose();
|
||||
generalNotifier.dispose();
|
||||
isActiveNotifier.dispose();
|
||||
|
||||
@@ -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<Group> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(GroupDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<Group>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MenuItem> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(MenuDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<MenuItem>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Product> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(ProductDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<Product>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Uom> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(UomDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<Uom>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserAccount> parsePaginated(
|
||||
Map<String, dynamic>? json,
|
||||
) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(UserDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<UserAccount>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vendor> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(VendorDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<Vendor>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Warehouse> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map(WarehouseDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<Warehouse>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 saving = ValueNotifier<bool>(false);
|
||||
final codeError = ValueNotifier<String?>(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<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(
|
||||
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<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),
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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
|
||||
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<ReportTypeFilter>(
|
||||
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<WarehouseFilterOption>(
|
||||
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<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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import '../entities/postal_code.dart';
|
||||
|
||||
/// 우편번호 검색 기능을 추상화한 저장소 인터페이스.
|
||||
abstract class PostalSearchRepository {
|
||||
/// 키워드를 기반으로 우편번호 목록을 검색한다.
|
||||
///
|
||||
/// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다.
|
||||
/// [limit]을 지정하면 최대 반환 건수를 제한한다.
|
||||
Future<List<PostalCode>> search({required String keyword, int limit = 20});
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> initInjection({
|
||||
sl.registerLazySingleton<ApprovalHistoryRepository>(
|
||||
() => ApprovalHistoryRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<PostalSearchRepository>(
|
||||
() => PostalSearchRepositoryRemote(apiClient: sl<ApiClient>()),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user