고객사 목록 쿼리스트링 연동 및 공통 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

@@ -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(
path: '/masters/customers',
name: 'masters-customers',
builder: (context, state) => const CustomerPage(),
builder: (context, state) => CustomerPage(routeUri: state.uri),
),
GoRoute(
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/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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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),
);
}
}

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/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();

View File

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

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_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>()),
);
}