From 9e2244f260d5a976fb5d8befd6c96cc4087ec52f Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 16 Oct 2025 17:27:20 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20=EB=B0=8F=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EB=AA=A8=EB=93=88=EC=9D=84=20v4=20API=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval_repository_remote.dart | 36 +- .../repositories/approval_repository.dart | 7 +- .../controllers/approval_controller.dart | 94 ++-- .../presentation/pages/approval_page.dart | 120 +++--- .../inventory_lookup_repository_remote.dart | 8 +- .../widgets/warehouse_select_field.dart | 404 +++++++++++++----- .../entities/stock_transaction_input.dart | 14 +- .../customer/data/dtos/customer_dto.dart | 5 + .../customer_repository_remote.dart | 2 +- .../customer/domain/entities/customer.dart | 7 + .../presentation/pages/customer_page.dart | 13 + .../repositories/group_repository_remote.dart | 2 +- .../group_permission_repository_remote.dart | 2 +- .../repositories/menu_repository_remote.dart | 2 +- .../product_repository_remote.dart | 2 +- .../presentation/pages/product_page.dart | 83 ++-- .../widgets/uom_autocomplete_field.dart | 315 ++++++++++++++ .../widgets/vendor_autocomplete_field.dart | 329 ++++++++++++++ .../repositories/uom_repository_remote.dart | 2 +- .../repositories/user_repository_remote.dart | 2 +- .../vendor_repository_remote.dart | 2 +- .../warehouse_repository_remote.dart | 2 +- .../reporting_repository_remote.dart | 32 +- .../approval_page_permission_test.dart | 7 +- .../data/approval_repository_remote_test.dart | 94 ++++ .../controllers/approval_controller_test.dart | 104 +++-- .../pages/approval_page_test.dart | 7 +- ...ventory_lookup_repository_remote_test.dart | 4 +- ...ck_transaction_repository_remote_test.dart | 4 +- .../data/customer_repository_remote_test.dart | 2 +- .../pages/customer_page_test.dart | 9 +- .../data/product_repository_remote_test.dart | 2 +- .../presentation/pages/product_page_test.dart | 2 +- .../reporting_repository_remote_test.dart | 4 +- 34 files changed, 1394 insertions(+), 330 deletions(-) create mode 100644 lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart create mode 100644 lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart diff --git a/lib/features/approvals/data/repositories/approval_repository_remote.dart b/lib/features/approvals/data/repositories/approval_repository_remote.dart index c40e20b..250e581 100644 --- a/lib/features/approvals/data/repositories/approval_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_repository_remote.dart @@ -25,24 +25,28 @@ class ApprovalRepositoryRemote implements ApprovalRepository { Future> list({ int page = 1, int pageSize = 20, - String? query, - String? status, - DateTime? from, - DateTime? to, + int? transactionId, + int? approvalStatusId, + int? requestedById, bool includeHistories = false, bool includeSteps = false, }) async { + final includeParts = []; + if (includeSteps) { + includeParts.add('steps'); + } + if (includeHistories) { + includeParts.add('histories'); + } final response = await _api.get>( _basePath, query: { 'page': page, 'page_size': pageSize, - if (query != null && query.isNotEmpty) 'q': query, - if (status != null && status.isNotEmpty) 'status': status, - if (from != null) 'from': from.toIso8601String(), - if (to != null) 'to': to.toIso8601String(), - if (includeHistories) 'include_histories': true, - if (includeSteps) 'include_steps': true, + if (transactionId != null) 'transaction_id': transactionId, + if (approvalStatusId != null) 'approval_status_id': approvalStatusId, + if (requestedById != null) 'requested_by_id': requestedById, + if (includeParts.isNotEmpty) 'include': includeParts.join(','), }, options: Options(responseType: ResponseType.json), ); @@ -56,12 +60,16 @@ class ApprovalRepositoryRemote implements ApprovalRepository { bool includeSteps = true, bool includeHistories = true, }) async { + final includeParts = []; + if (includeSteps) { + includeParts.add('steps'); + } + if (includeHistories) { + includeParts.add('histories'); + } final response = await _api.get>( '$_basePath/$id', - query: { - if (includeSteps) 'include_steps': true, - if (includeHistories) 'include_histories': true, - }, + query: {if (includeParts.isNotEmpty) 'include': includeParts.join(',')}, options: Options(responseType: ResponseType.json), ); final data = (response.data?['data'] as Map?) ?? {}; diff --git a/lib/features/approvals/domain/repositories/approval_repository.dart b/lib/features/approvals/domain/repositories/approval_repository.dart index 9883f07..f463485 100644 --- a/lib/features/approvals/domain/repositories/approval_repository.dart +++ b/lib/features/approvals/domain/repositories/approval_repository.dart @@ -11,10 +11,9 @@ abstract class ApprovalRepository { Future> list({ int page = 1, int pageSize = 20, - String? query, - String? status, - DateTime? from, - DateTime? to, + int? transactionId, + int? approvalStatusId, + int? requestedById, bool includeHistories = false, bool includeSteps = false, }); diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index 077613f..013328d 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -64,10 +64,11 @@ class ApprovalController extends ChangeNotifier { int? _applyingTemplateId; ApprovalProceedStatus? _proceedStatus; String? _errorMessage; - String _query = ''; ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; - DateTime? _fromDate; - DateTime? _toDate; + int? _transactionIdFilter; + int? _requestedById; + String? _requestedByName; + String? _requestedByEmployeeNo; List _actions = const []; List _templates = const []; final Map _statusLookup = {}; @@ -85,10 +86,11 @@ class ApprovalController extends ChangeNotifier { bool get isPerformingAction => _isPerformingAction; int? get processingStepId => _processingStepId; String? get errorMessage => _errorMessage; - String get query => _query; ApprovalStatusFilter get statusFilter => _statusFilter; - DateTime? get fromDate => _fromDate; - DateTime? get toDate => _toDate; + int? get transactionIdFilter => _transactionIdFilter; + int? get requestedById => _requestedById; + String? get requestedByName => _requestedByName; + String? get requestedByEmployeeNo => _requestedByEmployeeNo; List get actionOptions => _actions; bool get hasActionOptions => _actions.isNotEmpty; List get templates => _templates; @@ -116,14 +118,13 @@ class ApprovalController extends ChangeNotifier { _errorMessage = null; notifyListeners(); try { - final statusParam = _statusCodeFor(_statusFilter); + final statusId = _statusIdFor(_statusFilter); final response = await _repository.list( page: page, pageSize: _result?.pageSize ?? 20, - query: _query.isEmpty ? null : _query, - status: statusParam, - from: _fromDate, - to: _toDate, + transactionId: _transactionIdFilter, + approvalStatusId: statusId, + requestedById: _requestedById, includeSteps: false, includeHistories: false, ); @@ -176,18 +177,29 @@ class ApprovalController extends ChangeNotifier { _statusLookup ..clear() ..addEntries( - items.map( - (item) => MapEntry( - (item.code ?? item.name).toLowerCase(), - item, - ), - ), + items.expand((item) { + final keys = {}; + final code = item.code?.trim(); + if (code != null && code.isNotEmpty) { + keys.add(code.toLowerCase()); + } + final name = item.name.trim(); + if (name.isNotEmpty) { + keys.add(name.toLowerCase()); + } + keys.add(item.id.toString()); + return keys.map((key) => MapEntry(key, item)); + }), ); for (final entry in _defaultStatusCodes.entries) { - final code = entry.value.toLowerCase(); - final lookup = _statusLookup[code]; + final defaultCode = entry.value; + final normalized = defaultCode.toLowerCase(); + final lookup = _statusLookup[normalized]; if (lookup != null) { - _statusCodeAliases[entry.value] = lookup.code?.toLowerCase() ?? code; + final alias = lookup.code?.toLowerCase() ?? normalized; + _statusCodeAliases[defaultCode] = alias; + } else { + _statusCodeAliases[defaultCode] = defaultCode; } } notifyListeners(); @@ -229,6 +241,15 @@ class ApprovalController extends ChangeNotifier { return _statusCodeAliases[defaultCode] ?? defaultCode; } + int? _statusIdFor(ApprovalStatusFilter filter) { + final code = _statusCodeFor(filter); + if (code == null) { + return null; + } + final lookup = _statusLookup[code.toLowerCase()]; + return lookup?.id; + } + /// 활성화된 결재 템플릿 목록을 조회해 캐싱한다. /// /// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다. @@ -325,8 +346,7 @@ class ApprovalController extends ChangeNotifier { final proceedStatus = await _repository.canProceed(approvalId); _proceedStatus = proceedStatus; if (!proceedStatus.canProceed) { - _errorMessage = proceedStatus.reason ?? - '결재 단계가 현재 상태에서 진행될 수 없습니다.'; + _errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.'; return false; } @@ -421,31 +441,33 @@ class ApprovalController extends ChangeNotifier { } } - /// 검색 키워드를 변경하고 UI 갱신을 유도한다. - void updateQuery(String value) { - _query = value; - notifyListeners(); - } - /// 상태 필터 값을 변경한다. void updateStatusFilter(ApprovalStatusFilter filter) { _statusFilter = filter; notifyListeners(); } - /// 조회 기간을 설정한다. 두 값 모두 `null`이면 기간 조건을 해제한다. - void updateDateRange(DateTime? from, DateTime? to) { - _fromDate = from; - _toDate = to; + /// 트랜잭션 ID 필터를 갱신한다. null이면 조건을 제거한다. + void updateTransactionFilter(int? transactionId) { + _transactionIdFilter = transactionId; notifyListeners(); } - /// 검색어/상태/기간 등의 필터 조건을 초기화한다. + /// 상신자(요청자) 필터를 갱신한다. null 값을 전달하면 조건을 제거한다. + void updateRequestedByFilter({int? id, String? name, String? employeeNo}) { + _requestedById = id; + _requestedByName = name; + _requestedByEmployeeNo = employeeNo; + notifyListeners(); + } + + /// 상태/트랜잭션/상신자 필터를 초기값으로 되돌린다. void clearFilters() { - _query = ''; _statusFilter = ApprovalStatusFilter.all; - _fromDate = null; - _toDate = null; + _transactionIdFilter = null; + _requestedById = null; + _requestedByName = null; + _requestedByEmployeeNo = null; notifyListeners(); } diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 4d24ba5..774a9ff 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -11,7 +11,6 @@ import '../../../../core/permissions/permission_resources.dart'; import '../../../../widgets/app_layout.dart'; import '../../../../widgets/components/feedback.dart'; import '../../../../widgets/components/filter_bar.dart'; -import '../../../../widgets/components/superport_date_picker.dart'; import '../../../../widgets/components/superport_dialog.dart'; import '../../../../widgets/components/superport_table.dart'; import '../../../../widgets/components/feature_disabled_placeholder.dart'; @@ -20,6 +19,7 @@ import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; +import '../../../inventory/shared/widgets/employee_autocomplete_field.dart'; import '../controllers/approval_controller.dart'; const _approvalsResourcePath = PermissionResources.approvals; @@ -73,11 +73,11 @@ class _ApprovalEnabledPage extends StatefulWidget { class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { late final ApprovalController _controller; - final TextEditingController _searchController = TextEditingController(); - final FocusNode _searchFocus = FocusNode(); - final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd'); + final TextEditingController _transactionController = TextEditingController(); + final TextEditingController _requesterController = TextEditingController(); + final FocusNode _transactionFocus = FocusNode(); + InventoryEmployeeSuggestion? _selectedRequester; final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); - DateTimeRange? _dateRange; String? _lastError; int? _selectedTemplateId; @@ -114,8 +114,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { void dispose() { _controller.removeListener(_handleControllerUpdate); _controller.dispose(); - _searchController.dispose(); - _searchFocus.dispose(); + _transactionController.dispose(); + _requesterController.dispose(); + _transactionFocus.dispose(); super.dispose(); } @@ -197,16 +198,39 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { ), children: [ SizedBox( - width: 260, + width: 180, child: ShadInput( - controller: _searchController, - focusNode: _searchFocus, - placeholder: const Text('결재번호, 트랜잭션번호, 상신자 검색'), - leading: const Icon(lucide.LucideIcons.search, size: 16), + controller: _transactionController, + focusNode: _transactionFocus, + enabled: !_controller.isLoadingList, + keyboardType: TextInputType.number, + placeholder: const Text('트랜잭션 ID 입력'), + leading: const Icon(lucide.LucideIcons.hash, size: 16), onChanged: (_) => setState(() {}), onSubmitted: (_) => _applyFilters(), ), ), + SizedBox( + width: 280, + child: InventoryEmployeeAutocompleteField( + controller: _requesterController, + initialSuggestion: _selectedRequester, + enabled: !_controller.isLoadingList, + onSuggestionSelected: (suggestion) { + setState(() => _selectedRequester = suggestion); + }, + onChanged: () { + setState(() { + final selectedLabel = _selectedRequester == null + ? '' + : '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})'; + if (_requesterController.text.trim() != selectedLabel) { + _selectedRequester = null; + } + }); + }, + ), + ), SizedBox( width: 200, child: ShadSelect( @@ -229,37 +253,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { .toList(), ), ), - SizedBox( - width: 220, - child: SuperportDateRangePickerButton( - value: _dateRange, - dateFormat: _dateFormat, - enabled: !_controller.isLoadingList, - firstDate: DateTime(DateTime.now().year - 5), - lastDate: DateTime(DateTime.now().year + 1), - initialDateRange: - _dateRange ?? - DateTimeRange( - start: DateTime.now().subtract(const Duration(days: 7)), - end: DateTime.now(), - ), - onChanged: (range) { - if (range == null) return; - setState(() => _dateRange = range); - _controller.updateDateRange(range.start, range.end); - _controller.fetch(page: 1); - }, - ), - ), - if (_dateRange != null) - ShadButton.ghost( - onPressed: () { - setState(() => _dateRange = null); - _controller.updateDateRange(null, null); - _controller.fetch(page: 1); - }, - child: const Text('기간 초기화'), - ), ], ), child: Column( @@ -476,25 +469,48 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { } void _applyFilters() { - _controller.updateQuery(_searchController.text.trim()); - if (_dateRange != null) { - _controller.updateDateRange(_dateRange!.start, _dateRange!.end); + final transactionText = _transactionController.text.trim(); + if (transactionText.isNotEmpty) { + final transactionId = int.tryParse(transactionText); + if (transactionId == null) { + SuperportToast.error(context, '트랜잭션 ID는 숫자만 입력해야 합니다.'); + return; + } + _controller.updateTransactionFilter(transactionId); + } else { + _controller.updateTransactionFilter(null); } + + final requester = _selectedRequester; + if (requester != null) { + _controller.updateRequestedByFilter( + id: requester.id, + name: requester.name, + employeeNo: requester.employeeNo, + ); + } else if (_requesterController.text.trim().isEmpty) { + _controller.updateRequestedByFilter(id: null); + } + _controller.fetch(page: 1); } void _resetFilters() { - _searchController.clear(); - _searchFocus.requestFocus(); - _dateRange = null; + _transactionController.clear(); + _requesterController.clear(); + setState(() => _selectedRequester = null); + _transactionFocus.requestFocus(); _controller.clearFilters(); _controller.fetch(page: 1); } bool _hasFilters() { - return _searchController.text.isNotEmpty || - _controller.statusFilter != ApprovalStatusFilter.all || - _dateRange != null; + return _transactionController.text.trim().isNotEmpty || + _requesterController.text.trim().isNotEmpty || + _selectedRequester != null || + _controller.transactionIdFilter != null || + _controller.requestedById != null || + _controller.statusFilter != ApprovalStatusFilter.all; } Future _handleStepAction( diff --git a/lib/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart b/lib/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart index bef6bff..db308f4 100644 --- a/lib/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart +++ b/lib/features/inventory/lookups/data/repositories/inventory_lookup_repository_remote.dart @@ -39,22 +39,22 @@ class InventoryLookupRepositoryRemote implements InventoryLookupRepository { return _fetchList( _approvalActionsPath, activeOnly: activeOnly, - // Approval actions는 is_active 필터가 없을 수 있어 조건적으로 전달. - includeIsActive: false, + // Approval actions는 active 필터가 없을 수 있어 조건적으로 전달한다. + includeActiveFilter: false, ); } Future> _fetchList( String path, { required bool activeOnly, - bool includeIsActive = true, + bool includeActiveFilter = true, }) async { final response = await _api.get>( path, query: { 'page': 1, 'page_size': 200, - if (includeIsActive && activeOnly) 'is_active': true, + if (includeActiveFilter && activeOnly) 'active': true, }, options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/inventory/shared/widgets/warehouse_select_field.dart b/lib/features/inventory/shared/widgets/warehouse_select_field.dart index 7ae1e86..d506c48 100644 --- a/lib/features/inventory/shared/widgets/warehouse_select_field.dart +++ b/lib/features/inventory/shared/widgets/warehouse_select_field.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -59,31 +61,53 @@ class InventoryWarehouseSelectField extends StatefulWidget { class _InventoryWarehouseSelectFieldState extends State { + static const Duration _debounceDuration = Duration(milliseconds: 250); + WarehouseRepository? get _repository => GetIt.I.isRegistered() ? GetIt.I() : null; - List _options = const []; + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final List _initialOptions = []; + final List _suggestions = []; InventoryWarehouseOption? _selected; - bool _isLoading = false; + bool _isLoadingInitial = false; + bool _isSearching = false; String? _error; + Timer? _debounce; + int _requestId = 0; + bool _isApplyingText = false; + + InventoryWarehouseOption get _allOption => + InventoryWarehouseOption(id: -1, code: 'ALL', name: widget.allLabel); @override void initState() { super.initState(); - _loadWarehouses(); + _controller.addListener(_handleTextChanged); + _loadInitialOptions(); } @override void didUpdateWidget(covariant InventoryWarehouseSelectField oldWidget) { super.didUpdateWidget(oldWidget); if (widget.initialWarehouseId != oldWidget.initialWarehouseId) { - _syncSelection(widget.initialWarehouseId); + _applyInitialSelection(allowFetch: true); } } - Future _loadWarehouses() async { + @override + void dispose() { + _controller.removeListener(_handleTextChanged); + _controller.dispose(); + _focusNode.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + Future _loadInitialOptions() async { final repository = _repository; if (repository == null) { setState(() { @@ -92,7 +116,7 @@ class _InventoryWarehouseSelectFieldState return; } setState(() { - _isLoading = true; + _isLoadingInitial = true; _error = null; }); try { @@ -105,58 +129,91 @@ class _InventoryWarehouseSelectFieldState final options = result.items .map(InventoryWarehouseOption.fromWarehouse) .toList(growable: false); - final selected = _findOptionById(options, widget.initialWarehouseId); setState(() { - _options = options; - _selected = selected; - _isLoading = false; + _initialOptions + ..clear() + ..addAll(options); + _suggestions + ..clear() + ..addAll(_availableOptions(options)); + _isLoadingInitial = false; }); - if (selected != null) { - widget.onChanged(selected); - } + await _applyInitialSelection(options: options, allowFetch: true); } catch (error) { setState(() { final failure = Failure.from(error); _error = failure.describe(); - _isLoading = false; + _isLoadingInitial = false; }); } } - void _syncSelection(int? warehouseId) { - if (_options.isEmpty) { - if (warehouseId == null && _selected != null) { - setState(() { - _selected = null; - }); - widget.onChanged(null); + List _availableOptions( + List base, + ) { + if (!widget.includeAllOption) { + return List.from(base); + } + return [_allOption, ...base]; + } + + Future _applyInitialSelection({ + List? options, + bool allowFetch = false, + }) async { + final id = widget.initialWarehouseId; + if (id == null) { + if (_selected != null) { + _setSelection(null, notify: true); } return; } - - final next = _findOptionById(_options, warehouseId); - if (warehouseId == null && _selected != null) { - setState(() { - _selected = null; - }); - widget.onChanged(null); + final source = options ?? _initialOptions; + final match = _findById(source, id); + if (match != null) { + _setSelection(match, notify: true); return; } - if (!identical(next, _selected)) { + if (!allowFetch) { + return; + } + final repository = _repository; + if (repository == null) { + return; + } + try { + final result = await repository.list( + page: 1, + pageSize: 20, + query: id.toString(), + isActive: true, + includeZipcode: false, + ); + final fetched = result.items + .map(InventoryWarehouseOption.fromWarehouse) + .toList(growable: false); + final option = _findById(fetched, id); + if (option == null) { + return; + } + if (!_initialOptions.any((item) => item.id == option.id)) { + _initialOptions.add(option); + } setState(() { - _selected = next; + _suggestions + ..clear() + ..addAll(_availableOptions(_initialOptions)); }); - widget.onChanged(next); + _setSelection(option, notify: true); + } catch (_) { + // 초기 선정을 찾지 못하면 기본값 유지. } } - InventoryWarehouseOption? _findOptionById( + InventoryWarehouseOption? _findById( List options, - int? id, + int id, ) { - if (id == null) { - return null; - } for (final option in options) { if (option.id == id) { return option; @@ -165,23 +222,136 @@ class _InventoryWarehouseSelectFieldState return null; } + void _handleTextChanged() { + if (_isApplyingText) { + return; + } + final keyword = _controller.text.trim(); + if (keyword.isEmpty) { + _debounce?.cancel(); + setState(() { + _isSearching = false; + _suggestions + ..clear() + ..addAll(_availableOptions(_initialOptions)); + }); + return; + } + + final label = _selected == null + ? null + : _selected!.id == -1 + ? widget.allLabel + : _displayLabel(_selected!); + if (label != null && keyword != label) { + _selected = null; + widget.onChanged(null); + } + _scheduleSearch(keyword); + } + + void _scheduleSearch(String keyword) { + _debounce?.cancel(); + _debounce = Timer(_debounceDuration, () => _performSearch(keyword)); + } + + Future _performSearch(String keyword) async { + final repository = _repository; + if (repository == null) { + return; + } + final request = ++_requestId; + setState(() { + _isSearching = true; + }); + try { + final result = await repository.list( + page: 1, + pageSize: 20, + query: keyword, + isActive: true, + includeZipcode: false, + ); + if (!mounted || request != _requestId) { + return; + } + final items = result.items + .map(InventoryWarehouseOption.fromWarehouse) + .toList(growable: false); + setState(() { + _suggestions + ..clear() + ..addAll(items); + _isSearching = false; + }); + } catch (_) { + if (!mounted || request != _requestId) { + return; + } + setState(() { + _suggestions.clear(); + _isSearching = false; + }); + } + } + + void _setSelection(InventoryWarehouseOption? option, {bool notify = true}) { + if (option != null && + option.id != -1 && + !_initialOptions.any((item) => item.id == option.id)) { + _initialOptions.add(option); + } + _selected = option; + _isApplyingText = true; + if (option == null) { + _controller.clear(); + if (notify) { + widget.onChanged(null); + } + } else if (option.id == -1) { + _controller + ..text = widget.allLabel + ..selection = TextSelection.collapsed(offset: widget.allLabel.length); + if (notify) { + widget.onChanged(null); + } + } else { + final label = _displayLabel(option); + _controller + ..text = label + ..selection = TextSelection.collapsed(offset: label.length); + if (notify) { + widget.onChanged(option); + } + } + _isApplyingText = false; + } + + String _displayLabel(InventoryWarehouseOption option) { + return '${option.name} (${option.code})'; + } + + Widget _buildLoadingInput() { + return Stack( + alignment: Alignment.centerLeft, + children: const [ + ShadInput(readOnly: true), + Padding( + padding: EdgeInsets.only(left: 12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { - if (_isLoading) { - return Stack( - alignment: Alignment.centerLeft, - children: const [ - ShadInput(readOnly: true), - Padding( - padding: EdgeInsets.only(left: 12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ], - ); + if (_isLoadingInitial) { + return _buildLoadingInput(); } if (_error != null) { @@ -192,58 +362,96 @@ class _InventoryWarehouseSelectFieldState ); } - if (_options.isEmpty) { - return ShadInput( - readOnly: true, - enabled: false, - placeholder: const Text('선택 가능한 창고가 없습니다'), - ); - } - - return ShadSelect( - enabled: widget.enabled, - initialValue: _selected?.id, - placeholder: widget.placeholder ?? const Text('창고 선택'), - selectedOptionBuilder: (context, value) { - final option = value == null - ? _selected - : _options.firstWhere( - (item) => item.id == value, - orElse: () => _selected ?? _options.first, - ); - if (option == null) { - return widget.placeholder ?? const Text('창고 선택'); - } - return Text('${option.name} (${option.code})'); - }, - onChanged: (value) { - if (value == null) { - if (_selected != null) { - setState(() { - _selected = null; - }); - widget.onChanged(null); + return RawAutocomplete( + textEditingController: _controller, + focusNode: _focusNode, + optionsBuilder: (textEditingValue) { + if (textEditingValue.text.trim().isEmpty) { + if (_suggestions.isEmpty) { + return _availableOptions(_initialOptions); } + return _suggestions; + } + return _suggestions; + }, + displayStringForOption: (option) => + option.id == -1 ? widget.allLabel : _displayLabel(option), + onSelected: (option) { + if (!mounted) { return; } - final option = _options.firstWhere( - (item) => item.id == value, - orElse: () => _options.first, - ); - setState(() { - _selected = option; - }); - widget.onChanged(option); + if (option.id == -1) { + _setSelection(_allOption); + } else { + _setSelection(option); + } }, - options: [ - if (widget.includeAllOption) - ShadOption(value: null, child: Text(widget.allLabel)), - for (final option in _options) - ShadOption( - value: option.id, - child: Text('${option.name} · ${option.code}'), + fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { + final placeholder = widget.placeholder ?? const Text('창고 선택'); + return Stack( + alignment: Alignment.centerRight, + children: [ + ShadInput( + controller: textController, + focusNode: focusNode, + enabled: widget.enabled, + readOnly: !widget.enabled, + placeholder: placeholder, + ), + if (_isSearching) + const Padding( + padding: EdgeInsets.only(right: 12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ); + }, + optionsViewBuilder: (context, onSelected, options) { + if (options.isEmpty) { + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, minWidth: 260), + child: ShadCard( + padding: const EdgeInsets.symmetric(vertical: 12), + child: const Center(child: Text('검색 결과가 없습니다.')), + ), + ), + ); + } + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240, minWidth: 260), + child: ShadCard( + padding: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + final isAll = option.id == -1; + final label = isAll ? widget.allLabel : _displayLabel(option); + return InkWell( + onTap: () => onSelected(option), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Text(label), + ), + ); + }, + ), + ), ), - ], + ); + }, ); } } diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart index bd06b2b..694d39e 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -195,13 +195,23 @@ class StockTransactionListFilter { 'transaction_status_id': transactionStatusId, if (warehouseId != null) 'warehouse_id': warehouseId, if (customerId != null) 'customer_id': customerId, - if (from != null) 'from': from!.toIso8601String(), - if (to != null) 'to': to!.toIso8601String(), + if (from != null) 'date_from': _formatDate(from!), + if (to != null) 'date_to': _formatDate(to!), if (sort != null && sort!.trim().isNotEmpty) 'sort': sort, if (order != null && order!.trim().isNotEmpty) 'order': order, if (include.isNotEmpty) 'include': include.join(','), }; } + + /// 백엔드가 요구하는 `yyyy-MM-dd`(NaiveDate) 형식으로 변환한다. + String _formatDate(DateTime value) { + final iso = value.toIso8601String(); + final separatorIndex = iso.indexOf('T'); + if (separatorIndex == -1) { + return iso; + } + return iso.substring(0, separatorIndex); + } } /// 재고 트랜잭션 생성 시 결재(Approval) 정보를 담는 입력 모델. diff --git a/lib/features/masters/customer/data/dtos/customer_dto.dart b/lib/features/masters/customer/data/dtos/customer_dto.dart index bf01aae..82adedd 100644 --- a/lib/features/masters/customer/data/dtos/customer_dto.dart +++ b/lib/features/masters/customer/data/dtos/customer_dto.dart @@ -9,6 +9,7 @@ class CustomerDto { this.id, required this.customerCode, required this.customerName, + this.contactName, this.isPartner = false, this.isGeneral = true, this.email, @@ -25,6 +26,7 @@ class CustomerDto { final int? id; final String customerCode; final String customerName; + final String? contactName; final bool isPartner; final bool isGeneral; final String? email; @@ -43,6 +45,7 @@ class CustomerDto { id: json['id'] as int?, customerCode: json['customer_code'] as String, customerName: json['customer_name'] as String, + contactName: json['contact_name'] as String?, isPartner: (json['is_partner'] as bool?) ?? false, isGeneral: (json['is_general'] as bool?) ?? true, email: json['email'] as String?, @@ -65,6 +68,7 @@ class CustomerDto { if (id != null) 'id': id, 'customer_code': customerCode, 'customer_name': customerName, + 'contact_name': contactName, 'is_partner': isPartner, 'is_general': isGeneral, 'email': email, @@ -84,6 +88,7 @@ class CustomerDto { id: id, customerCode: customerCode, customerName: customerName, + contactName: contactName, isPartner: isPartner, isGeneral: isGeneral, email: email, diff --git a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart index 6d53a2c..68cfaca 100644 --- a/lib/features/masters/customer/data/repositories/customer_repository_remote.dart +++ b/lib/features/masters/customer/data/repositories/customer_repository_remote.dart @@ -33,7 +33,7 @@ class CustomerRepositoryRemote implements CustomerRepository { if (query != null && query.isNotEmpty) 'q': query, if (isPartner != null) 'is_partner': isPartner, if (isGeneral != null) 'is_general': isGeneral, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, }, options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/masters/customer/domain/entities/customer.dart b/lib/features/masters/customer/domain/entities/customer.dart index 11e425b..5c50257 100644 --- a/lib/features/masters/customer/domain/entities/customer.dart +++ b/lib/features/masters/customer/domain/entities/customer.dart @@ -4,6 +4,7 @@ class Customer { this.id, required this.customerCode, required this.customerName, + this.contactName, this.isPartner = false, this.isGeneral = true, this.email, @@ -20,6 +21,7 @@ class Customer { final int? id; final String customerCode; final String customerName; + final String? contactName; final bool isPartner; final bool isGeneral; final String? email; @@ -37,6 +39,7 @@ class Customer { int? id, String? customerCode, String? customerName, + String? contactName, bool? isPartner, bool? isGeneral, String? email, @@ -53,6 +56,7 @@ class Customer { id: id ?? this.id, customerCode: customerCode ?? this.customerCode, customerName: customerName ?? this.customerName, + contactName: contactName ?? this.contactName, isPartner: isPartner ?? this.isPartner, isGeneral: isGeneral ?? this.isGeneral, email: email ?? this.email, @@ -88,6 +92,7 @@ class CustomerInput { CustomerInput({ required this.customerCode, required this.customerName, + this.contactName, required this.isPartner, required this.isGeneral, this.email, @@ -100,6 +105,7 @@ class CustomerInput { final String customerCode; final String customerName; + final String? contactName; final bool isPartner; final bool isGeneral; final String? email; @@ -114,6 +120,7 @@ class CustomerInput { return { 'customer_code': customerCode, 'customer_name': customerName, + 'contact_name': contactName, 'is_partner': isPartner, 'is_general': isGeneral, 'email': email, diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index d37f1ab..30590c8 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -496,6 +496,9 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final nameController = TextEditingController( text: existing?.customerName ?? '', ); + final contactController = TextEditingController( + text: existing?.contactName ?? '', + ); final emailController = TextEditingController(text: existing?.email ?? ''); final mobileController = TextEditingController( text: existing?.mobileNo ?? '', @@ -597,6 +600,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { : () async { final code = codeController.text.trim(); final name = nameController.text.trim(); + final contact = contactController.text.trim(); final email = emailController.text.trim(); final mobile = mobileController.text.trim(); final zipcode = zipcodeController.text.trim(); @@ -633,6 +637,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final input = CustomerInput( customerCode: code, customerName: name, + contactName: contact.isEmpty ? null : contact, isPartner: partner, isGeneral: general, email: email.isEmpty ? null : email, @@ -748,6 +753,11 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { }, ), const SizedBox(height: 16), + _FormField( + label: '담당자', + child: ShadInput(controller: contactController), + ), + const SizedBox(height: 16), ValueListenableBuilder( valueListenable: partnerNotifier, builder: (_, partner, __) { @@ -949,6 +959,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { codeController.dispose(); nameController.dispose(); + contactController.dispose(); emailController.dispose(); mobileController.dispose(); zipcodeController.dispose(); @@ -1047,6 +1058,7 @@ class _CustomerTable extends StatelessWidget { 'ID', '고객사코드', '고객사명', + '담당자', '유형', '이메일', '연락처', @@ -1072,6 +1084,7 @@ class _CustomerTable extends StatelessWidget { customer.id?.toString() ?? '-', customer.customerCode, customer.customerName, + customer.contactName?.isEmpty ?? true ? '-' : customer.contactName!, resolveType(customer), customer.email?.isEmpty ?? true ? '-' : customer.email!, customer.mobileNo?.isEmpty ?? true ? '-' : customer.mobileNo!, diff --git a/lib/features/masters/group/data/repositories/group_repository_remote.dart b/lib/features/masters/group/data/repositories/group_repository_remote.dart index a7f5569..df4c317 100644 --- a/lib/features/masters/group/data/repositories/group_repository_remote.dart +++ b/lib/features/masters/group/data/repositories/group_repository_remote.dart @@ -40,7 +40,7 @@ class GroupRepositoryRemote implements GroupRepository { 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, if (isDefault != null) 'is_default': isDefault, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, if (includeParts.isNotEmpty) 'include': includeParts.join(','), }, options: Options(responseType: ResponseType.json), diff --git a/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart index 5425366..496a332 100644 --- a/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart +++ b/lib/features/masters/group_permission/data/repositories/group_permission_repository_remote.dart @@ -33,7 +33,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository { 'page_size': pageSize, if (groupId != null) 'group_id': groupId, if (menuId != null) 'menu_id': menuId, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, if (includeDeleted) 'include_deleted': true, 'include': 'group,menu', }, diff --git a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart index d12de6e..267e934 100644 --- a/lib/features/masters/menu/data/repositories/menu_repository_remote.dart +++ b/lib/features/masters/menu/data/repositories/menu_repository_remote.dart @@ -32,7 +32,7 @@ class MenuRepositoryRemote implements MenuRepository { 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, if (parentId != null) 'parent_id': parentId, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, if (includeDeleted) 'include_deleted': true, 'include': 'parent', }, diff --git a/lib/features/masters/product/data/repositories/product_repository_remote.dart b/lib/features/masters/product/data/repositories/product_repository_remote.dart index 3d5789a..b6bd4a1 100644 --- a/lib/features/masters/product/data/repositories/product_repository_remote.dart +++ b/lib/features/masters/product/data/repositories/product_repository_remote.dart @@ -33,7 +33,7 @@ class ProductRepositoryRemote implements ProductRepository { if (query != null && query.isNotEmpty) 'q': query, if (vendorId != null) 'vendor_id': vendorId, if (uomId != null) 'uom_id': uomId, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, 'include': 'vendor,uom', }, options: Options(responseType: ResponseType.json), diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 3443539..51074b2 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -18,6 +18,8 @@ import '../../../vendor/domain/repositories/vendor_repository.dart'; import '../../domain/entities/product.dart'; import '../../domain/repositories/product_repository.dart'; import '../controllers/product_controller.dart'; +import '../widgets/uom_autocomplete_field.dart'; +import '../widgets/vendor_autocomplete_field.dart'; /// 제품 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다. class ProductPage extends StatelessWidget { @@ -686,42 +688,27 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { return ValueListenableBuilder( valueListenable: vendorError, builder: (_, errorText, __) { + final vendorLabel = () { + final vendor = existing?.vendor; + if (vendor == null) { + return null; + } + return '${vendor.vendorName} (${vendor.vendorCode})'; + }(); return _FormField( label: '제조사', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ShadSelect( - initialValue: value, - onChanged: saving.value - ? null - : (next) { - vendorNotifier.value = next; - vendorError.value = null; - }, - options: _controller.vendorOptions - .map( - (vendor) => ShadOption( - value: vendor.id, - child: Text(vendor.vendorName), - ), - ) - .toList(), + VendorAutocompleteField( + initialOptions: _controller.vendorOptions, + selectedVendorId: value, + initialLabel: vendorLabel, + enabled: !isSaving, placeholder: const Text('제조사를 선택하세요'), - selectedOptionBuilder: (context, selected) { - if (selected == null) { - return const Text('제조사를 선택하세요'); - } - final vendor = _controller.vendorOptions - .firstWhere( - (v) => v.id == selected, - orElse: () => Vendor( - id: selected, - vendorCode: '', - vendorName: '', - ), - ); - return Text(vendor.vendorName); + onSelected: (id) { + vendorNotifier.value = id; + vendorError.value = null; }, ), if (errorText != null) @@ -748,39 +735,21 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { return ValueListenableBuilder( valueListenable: uomError, builder: (_, errorText, __) { + final uomLabel = existing?.uom?.uomName; return _FormField( label: '단위', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ShadSelect( - initialValue: value, - onChanged: saving.value - ? null - : (next) { - uomNotifier.value = next; - uomError.value = null; - }, - options: _controller.uomOptions - .map( - (uom) => ShadOption( - value: uom.id, - child: Text(uom.uomName), - ), - ) - .toList(), + UomAutocompleteField( + initialOptions: _controller.uomOptions, + selectedUomId: value, + initialLabel: uomLabel, + enabled: !isSaving, placeholder: const Text('단위를 선택하세요'), - selectedOptionBuilder: (context, selected) { - if (selected == null) { - return const Text('단위를 선택하세요'); - } - final uom = _controller.uomOptions - .firstWhere( - (u) => u.id == selected, - orElse: () => - Uom(id: selected, uomName: ''), - ); - return Text(uom.uomName); + onSelected: (id) { + uomNotifier.value = id; + uomError.value = null; }, ), if (errorText != null) diff --git a/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart b/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart new file mode 100644 index 0000000..a4beb2a --- /dev/null +++ b/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart @@ -0,0 +1,315 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../core/network/failure.dart'; +import '../../../uom/domain/entities/uom.dart'; +import '../../../uom/domain/repositories/uom_repository.dart'; + +/// 단위를 검색/선택할 수 있는 자동완성 입력 필드. +class UomAutocompleteField extends StatefulWidget { + const UomAutocompleteField({ + super.key, + required this.initialOptions, + this.selectedUomId, + this.initialLabel, + required this.onSelected, + this.enabled = true, + this.placeholder, + }); + + final List initialOptions; + final int? selectedUomId; + final String? initialLabel; + final ValueChanged onSelected; + final bool enabled; + final Widget? placeholder; + + @override + State createState() => _UomAutocompleteFieldState(); +} + +class _UomAutocompleteFieldState extends State { + static const Duration _debounceDuration = Duration(milliseconds: 250); + + UomRepository? get _repository => + GetIt.I.isRegistered() ? GetIt.I() : null; + + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final List _baseOptions = []; + final List _suggestions = []; + Uom? _selected; + bool _isSearching = false; + String? _error; + Timer? _debounce; + int _requestId = 0; + bool _isApplyingText = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_handleTextChanged); + _initializeOptions(); + } + + @override + void didUpdateWidget(covariant UomAutocompleteField oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(widget.initialOptions, oldWidget.initialOptions)) { + _baseOptions + ..clear() + ..addAll(widget.initialOptions); + if (_controller.text.trim().isEmpty) { + _resetSuggestions(); + } + } + if (widget.selectedUomId != oldWidget.selectedUomId) { + _applySelectionFromId(widget.selectedUomId, label: widget.initialLabel); + } + } + + @override + void dispose() { + _controller.removeListener(_handleTextChanged); + _controller.dispose(); + _focusNode.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _initializeOptions() { + _baseOptions + ..clear() + ..addAll(widget.initialOptions); + _resetSuggestions(); + if (widget.selectedUomId != null) { + _applySelectionFromId(widget.selectedUomId, label: widget.initialLabel); + } else if (widget.initialLabel != null && widget.initialLabel!.isNotEmpty) { + _isApplyingText = true; + _controller + ..text = widget.initialLabel! + ..selection = TextSelection.collapsed( + offset: widget.initialLabel!.length, + ); + _isApplyingText = false; + } + } + + void _resetSuggestions() { + setState(() { + _suggestions + ..clear() + ..addAll(_baseOptions); + }); + } + + void _handleTextChanged() { + if (_isApplyingText) { + return; + } + final keyword = _controller.text.trim(); + if (keyword.isEmpty) { + _debounce?.cancel(); + _resetSuggestions(); + return; + } + + if (_selected != null && keyword != _selected!.uomName) { + _selected = null; + widget.onSelected(null); + } + _scheduleSearch(keyword); + } + + void _scheduleSearch(String keyword) { + _debounce?.cancel(); + _debounce = Timer(_debounceDuration, () => _search(keyword)); + } + + Future _search(String keyword) async { + final repository = _repository; + if (repository == null) { + setState(() { + _error = '단위 데이터 소스를 찾을 수 없습니다.'; + }); + return; + } + final request = ++_requestId; + setState(() { + _isSearching = true; + _error = null; + }); + try { + final result = await repository.list( + page: 1, + pageSize: 20, + query: keyword, + isActive: true, + ); + if (!mounted || request != _requestId) { + return; + } + final items = result.items.toList(growable: false); + setState(() { + _suggestions + ..clear() + ..addAll(items); + _isSearching = false; + }); + } catch (error) { + if (!mounted || request != _requestId) { + return; + } + setState(() { + final failure = Failure.from(error); + _error = failure.describe(); + _suggestions.clear(); + _isSearching = false; + }); + } + } + + void _applySelectionFromId(int? id, {String? label}) { + if (id == null) { + _setSelection(null, notify: true); + return; + } + final match = _findById(_baseOptions, id) ?? _findById(_suggestions, id); + if (match != null) { + _setSelection(match, notify: false); + return; + } + if (label != null && label.isNotEmpty) { + _isApplyingText = true; + _controller + ..text = label + ..selection = TextSelection.collapsed(offset: label.length); + _isApplyingText = false; + } + } + + Uom? _findById(List options, int id) { + for (final option in options) { + if (option.id == id) { + return option; + } + } + return null; + } + + void _setSelection(Uom? uom, {bool notify = true}) { + _selected = uom; + if (uom != null && !_baseOptions.any((item) => item.id == uom.id)) { + _baseOptions.add(uom); + } + _isApplyingText = true; + if (uom == null) { + _controller.clear(); + if (notify) { + widget.onSelected(null); + } + } else { + _controller + ..text = uom.uomName + ..selection = TextSelection.collapsed(offset: uom.uomName.length); + if (notify) { + widget.onSelected(uom.id); + } + } + _isApplyingText = false; + } + + @override + Widget build(BuildContext context) { + if (_error != null && _baseOptions.isEmpty && _suggestions.isEmpty) { + return ShadInput( + readOnly: true, + enabled: false, + placeholder: Text(_error!), + ); + } + + return RawAutocomplete( + textEditingController: _controller, + focusNode: _focusNode, + optionsBuilder: (textEditingValue) { + if (textEditingValue.text.trim().isEmpty) { + if (_suggestions.isEmpty) { + return _baseOptions; + } + return _baseOptions; + } + return _suggestions; + }, + displayStringForOption: (option) => option.uomName, + onSelected: (uom) => _setSelection(uom), + fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { + final placeholder = widget.placeholder ?? const Text('단위를 선택하세요'); + return Stack( + alignment: Alignment.centerRight, + children: [ + ShadInput( + controller: textController, + focusNode: focusNode, + enabled: widget.enabled, + readOnly: !widget.enabled, + placeholder: placeholder, + ), + if (_isSearching) + const Padding( + padding: EdgeInsets.only(right: 12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ); + }, + optionsViewBuilder: (context, onSelected, options) { + if (options.isEmpty) { + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, minWidth: 220), + child: ShadCard( + padding: const EdgeInsets.symmetric(vertical: 12), + child: const Center(child: Text('검색 결과가 없습니다.')), + ), + ), + ); + } + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240, minWidth: 220), + child: ShadCard( + padding: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final uom = options.elementAt(index); + return InkWell( + onTap: () => onSelected(uom), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Text(uom.uomName), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart b/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart new file mode 100644 index 0000000..987b5da --- /dev/null +++ b/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart @@ -0,0 +1,329 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../core/network/failure.dart'; +import '../../../vendor/domain/entities/vendor.dart'; +import '../../../vendor/domain/repositories/vendor_repository.dart'; + +/// 공급업체를 검색해 선택할 수 있는 자동완성 입력 필드. +class VendorAutocompleteField extends StatefulWidget { + const VendorAutocompleteField({ + super.key, + required this.initialOptions, + this.selectedVendorId, + this.initialLabel, + required this.onSelected, + this.enabled = true, + this.placeholder, + }); + + final List initialOptions; + final int? selectedVendorId; + final String? initialLabel; + final ValueChanged onSelected; + final bool enabled; + final Widget? placeholder; + + @override + State createState() => + _VendorAutocompleteFieldState(); +} + +class _VendorAutocompleteFieldState extends State { + static const Duration _debounceDuration = Duration(milliseconds: 250); + + VendorRepository? get _repository => GetIt.I.isRegistered() + ? GetIt.I() + : null; + + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final List _baseOptions = []; + final List _suggestions = []; + Vendor? _selected; + bool _isSearching = false; + String? _error; + Timer? _debounce; + int _requestId = 0; + bool _isApplyingText = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_handleTextChanged); + _initializeOptions(); + } + + @override + void didUpdateWidget(covariant VendorAutocompleteField oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(widget.initialOptions, oldWidget.initialOptions)) { + _baseOptions + ..clear() + ..addAll(widget.initialOptions); + if (_controller.text.trim().isEmpty) { + _resetSuggestions(); + } + } + if (widget.selectedVendorId != oldWidget.selectedVendorId) { + _applySelectionFromId( + widget.selectedVendorId, + label: widget.initialLabel, + ); + } + } + + @override + void dispose() { + _controller.removeListener(_handleTextChanged); + _controller.dispose(); + _focusNode.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _initializeOptions() { + _baseOptions + ..clear() + ..addAll(widget.initialOptions); + _resetSuggestions(); + if (widget.selectedVendorId != null) { + _applySelectionFromId( + widget.selectedVendorId, + label: widget.initialLabel, + ); + } else if (widget.initialLabel != null && widget.initialLabel!.isNotEmpty) { + _isApplyingText = true; + _controller + ..text = widget.initialLabel! + ..selection = TextSelection.collapsed( + offset: widget.initialLabel!.length, + ); + _isApplyingText = false; + } + } + + void _resetSuggestions() { + setState(() { + _suggestions + ..clear() + ..addAll(_baseOptions); + }); + } + + void _handleTextChanged() { + if (_isApplyingText) { + return; + } + final keyword = _controller.text.trim(); + if (keyword.isEmpty) { + _debounce?.cancel(); + _resetSuggestions(); + return; + } + + if (_selected != null && keyword != _displayLabel(_selected!)) { + _selected = null; + widget.onSelected(null); + } + _scheduleSearch(keyword); + } + + void _scheduleSearch(String keyword) { + _debounce?.cancel(); + _debounce = Timer(_debounceDuration, () => _search(keyword)); + } + + Future _search(String keyword) async { + final repository = _repository; + if (repository == null) { + setState(() { + _error = '공급업체 데이터 소스를 찾을 수 없습니다.'; + }); + return; + } + final request = ++_requestId; + setState(() { + _isSearching = true; + _error = null; + }); + try { + final result = await repository.list( + page: 1, + pageSize: 20, + query: keyword, + isActive: true, + ); + if (!mounted || request != _requestId) { + return; + } + final items = result.items.toList(growable: false); + setState(() { + _suggestions + ..clear() + ..addAll(items); + _isSearching = false; + }); + } catch (error) { + if (!mounted || request != _requestId) { + return; + } + setState(() { + final failure = Failure.from(error); + _error = failure.describe(); + _suggestions.clear(); + _isSearching = false; + }); + } + } + + void _applySelectionFromId(int? id, {String? label}) { + if (id == null) { + _setSelection(null, notify: true); + return; + } + final match = _findById(_baseOptions, id) ?? _findById(_suggestions, id); + if (match != null) { + _setSelection(match, notify: false); + return; + } + if (label != null && label.isNotEmpty) { + _isApplyingText = true; + _controller + ..text = label + ..selection = TextSelection.collapsed(offset: label.length); + _isApplyingText = false; + } + } + + Vendor? _findById(List options, int id) { + for (final option in options) { + if (option.id == id) { + return option; + } + } + return null; + } + + void _setSelection(Vendor? vendor, {bool notify = true}) { + _selected = vendor; + if (vendor != null && !_baseOptions.any((item) => item.id == vendor.id)) { + _baseOptions.add(vendor); + } + _isApplyingText = true; + if (vendor == null) { + _controller.clear(); + if (notify) { + widget.onSelected(null); + } + } else { + final label = _displayLabel(vendor); + _controller + ..text = label + ..selection = TextSelection.collapsed(offset: label.length); + if (notify) { + widget.onSelected(vendor.id); + } + } + _isApplyingText = false; + } + + String _displayLabel(Vendor vendor) { + final code = vendor.vendorCode.isEmpty ? '' : ' (${vendor.vendorCode})'; + return '${vendor.vendorName}$code'; + } + + @override + Widget build(BuildContext context) { + if (_error != null && _baseOptions.isEmpty && _suggestions.isEmpty) { + return ShadInput( + readOnly: true, + enabled: false, + placeholder: Text(_error!), + ); + } + + return RawAutocomplete( + textEditingController: _controller, + focusNode: _focusNode, + optionsBuilder: (textEditingValue) { + if (textEditingValue.text.trim().isEmpty) { + if (_suggestions.isEmpty) { + return _baseOptions; + } + return _baseOptions; + } + return _suggestions; + }, + displayStringForOption: _displayLabel, + onSelected: (vendor) => _setSelection(vendor), + fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { + final placeholder = widget.placeholder ?? const Text('제조사를 선택하세요'); + return Stack( + alignment: Alignment.centerRight, + children: [ + ShadInput( + controller: textController, + focusNode: focusNode, + enabled: widget.enabled, + readOnly: !widget.enabled, + placeholder: placeholder, + ), + if (_isSearching) + const Padding( + padding: EdgeInsets.only(right: 12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ); + }, + optionsViewBuilder: (context, onSelected, options) { + if (options.isEmpty) { + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, minWidth: 260), + child: ShadCard( + padding: const EdgeInsets.symmetric(vertical: 12), + child: const Center(child: Text('검색 결과가 없습니다.')), + ), + ), + ); + } + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240, minWidth: 260), + child: ShadCard( + padding: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final vendor = options.elementAt(index); + return InkWell( + onTap: () => onSelected(vendor), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Text(_displayLabel(vendor)), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/masters/uom/data/repositories/uom_repository_remote.dart b/lib/features/masters/uom/data/repositories/uom_repository_remote.dart index 8b4a15f..6fe3223 100644 --- a/lib/features/masters/uom/data/repositories/uom_repository_remote.dart +++ b/lib/features/masters/uom/data/repositories/uom_repository_remote.dart @@ -29,7 +29,7 @@ class UomRepositoryRemote implements UomRepository { 'page': page, 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, }, options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/masters/user/data/repositories/user_repository_remote.dart b/lib/features/masters/user/data/repositories/user_repository_remote.dart index e982013..f9fdcb0 100644 --- a/lib/features/masters/user/data/repositories/user_repository_remote.dart +++ b/lib/features/masters/user/data/repositories/user_repository_remote.dart @@ -31,7 +31,7 @@ class UserRepositoryRemote implements UserRepository { 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, if (groupId != null) 'group_id': groupId, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, 'include': 'group', }, options: Options(responseType: ResponseType.json), diff --git a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart index 15645b4..a183746 100644 --- a/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart +++ b/lib/features/masters/vendor/data/repositories/vendor_repository_remote.dart @@ -29,7 +29,7 @@ class VendorRepositoryRemote implements VendorRepository { 'page': page, 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, }, options: Options(responseType: ResponseType.json), ); diff --git a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart index 7fb03d1..de0ffb8 100644 --- a/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart +++ b/lib/features/masters/warehouse/data/repositories/warehouse_repository_remote.dart @@ -30,7 +30,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository { 'page': page, 'page_size': pageSize, if (query != null && query.isNotEmpty) 'q': query, - if (isActive != null) 'is_active': isActive, + if (isActive != null) 'active': isActive, if (includeZipcode) 'include': 'zipcode', }, options: Options(responseType: ResponseType.json), diff --git a/lib/features/reporting/data/repositories/reporting_repository_remote.dart b/lib/features/reporting/data/repositories/reporting_repository_remote.dart index d0158bc..9cd55da 100644 --- a/lib/features/reporting/data/repositories/reporting_repository_remote.dart +++ b/lib/features/reporting/data/repositories/reporting_repository_remote.dart @@ -26,7 +26,7 @@ class ReportingRepositoryRemote implements ReportingRepository { ) async { final response = await _api.get( _transactionsPath, - query: _buildQuery(request), + query: _buildTransactionQuery(request), options: Options(responseType: ResponseType.bytes), ); return _mapResponse(response, format: request.format); @@ -38,16 +38,16 @@ class ReportingRepositoryRemote implements ReportingRepository { ) async { final response = await _api.get( _approvalsPath, - query: _buildQuery(request), + query: _buildApprovalQuery(request), options: Options(responseType: ResponseType.bytes), ); return _mapResponse(response, format: request.format); } - Map _buildQuery(ReportExportRequest request) { + Map _buildTransactionQuery(ReportExportRequest request) { return { - 'from': request.from.toIso8601String(), - 'to': request.to.toIso8601String(), + 'date_from': _formatDateOnly(request.from), + 'date_to': _formatDateOnly(request.to), 'format': request.format.apiValue, if (request.transactionStatusId != null) 'transaction_status_id': request.transactionStatusId, @@ -58,6 +58,28 @@ class ReportingRepositoryRemote implements ReportingRepository { }; } + Map _buildApprovalQuery(ReportExportRequest request) { + return { + 'from': request.from.toIso8601String(), + 'to': request.to.toIso8601String(), + 'format': request.format.apiValue, + if (request.approvalStatusId != null) + 'approval_status_id': request.approvalStatusId, + if (request.requestedById != null) + 'requested_by_id': request.requestedById, + }; + } + + /// 백엔드 트랜잭션 보고서가 요구하는 `yyyy-MM-dd` 형식으로 변환한다. + String _formatDateOnly(DateTime value) { + final iso = value.toIso8601String(); + final separatorIndex = iso.indexOf('T'); + if (separatorIndex == -1) { + return iso; + } + return iso.substring(0, separatorIndex); + } + ReportDownloadResult _mapResponse( Response response, { required ReportExportFormat format, diff --git a/test/features/approvals/approval_page_permission_test.dart b/test/features/approvals/approval_page_permission_test.dart index 291c6e9..714975b 100644 --- a/test/features/approvals/approval_page_permission_test.dart +++ b/test/features/approvals/approval_page_permission_test.dart @@ -197,10 +197,9 @@ class _StubApprovalRepository implements ApprovalRepository { Future> list({ int page = 1, int pageSize = 20, - String? query, - String? status, - DateTime? from, - DateTime? to, + int? transactionId, + int? approvalStatusId, + int? requestedById, bool includeHistories = false, bool includeSteps = false, }) async { diff --git a/test/features/approvals/data/approval_repository_remote_test.dart b/test/features/approvals/data/approval_repository_remote_test.dart index 134e3bf..32d3d02 100644 --- a/test/features/approvals/data/approval_repository_remote_test.dart +++ b/test/features/approvals/data/approval_repository_remote_test.dart @@ -22,6 +22,52 @@ void main() { repository = ApprovalRepositoryRemote(apiClient: apiClient); }); + test('list는 신규 필터 파라미터를 전달한다', () async { + const path = '/api/v1/approvals'; + when( + () => apiClient.get>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: {'items': const [], 'page': 1, 'page_size': 20, 'total': 0}, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + await repository.list( + page: 2, + pageSize: 50, + transactionId: 10, + approvalStatusId: 5, + requestedById: 7, + includeSteps: true, + includeHistories: true, + ); + + final captured = verify( + () => apiClient.get>( + captureAny(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured; + + expect(captured.first, equals(path)); + final query = captured[1] as Map; + expect(query['page'], 2); + expect(query['page_size'], 50); + expect(query['transaction_id'], 10); + expect(query['approval_status_id'], 5); + expect(query['requested_by_id'], 7); + expect(query['include'], 'steps,histories'); + }); + Map buildStep({ required int id, required int order, @@ -147,4 +193,52 @@ void main() { expect(result.histories.length, 2); expect(result.histories.last.id, 91001); }); + + test('fetchDetail은 include 파라미터를 steps,histories로 조합한다', () async { + const path = '/api/v1/approvals/1'; + when( + () => apiClient.get>( + path, + query: any(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).thenAnswer( + (_) async => Response>( + data: { + 'data': { + 'id': 1, + 'approval_no': 'AP-1', + 'status': {'id': 1, 'status_name': '대기'}, + 'current_step': {'id': 2, 'step_order': 1}, + 'requester': { + 'id': 3, + 'employee_no': 'EMP-3', + 'employee_name': '신청자', + }, + 'requested_at': '2024-01-01T00:00:00Z', + 'steps': const [], + 'histories': const [], + }, + }, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ), + ); + + await repository.fetchDetail(1, includeSteps: true, includeHistories: true); + + final query = + verify( + () => apiClient.get>( + any(), + query: captureAny(named: 'query'), + options: any(named: 'options'), + cancelToken: any(named: 'cancelToken'), + ), + ).captured.first + as Map; + + expect(query['include'], 'steps,histories'); + }); } diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart index 8d85c07..b411de2 100644 --- a/test/features/approvals/presentation/controllers/approval_controller_test.dart +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -8,6 +8,8 @@ import 'package:superport_v2/features/approvals/domain/entities/approval_proceed import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; +import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; /// ApprovalRepository 모킹 클래스. class _MockApprovalRepository extends Mock implements ApprovalRepository {} @@ -26,6 +28,9 @@ class _MockApprovalTemplateRepository extends Mock class _FakeStepAssignmentInput extends Fake implements ApprovalStepAssignmentInput {} +class _MockInventoryLookupRepository extends Mock + implements InventoryLookupRepository {} + void main() { late ApprovalController controller; late _MockApprovalRepository repository; @@ -90,10 +95,9 @@ void main() { () => repository.list( page: any(named: 'page'), pageSize: any(named: 'pageSize'), - query: any(named: 'query'), - status: any(named: 'status'), - from: any(named: 'from'), - to: any(named: 'to'), + transactionId: any(named: 'transactionId'), + approvalStatusId: any(named: 'approvalStatusId'), + requestedById: any(named: 'requestedById'), includeHistories: any(named: 'includeHistories'), includeSteps: any(named: 'includeSteps'), ), @@ -110,11 +114,13 @@ void main() { // 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다. test('필터 전달을 검증한다', () async { - controller.updateQuery('TRX'); + controller.updateTransactionFilter(55); controller.updateStatusFilter(ApprovalStatusFilter.approved); - final from = DateTime(2024, 4, 1); - final to = DateTime(2024, 4, 30); - controller.updateDateRange(from, to); + controller.updateRequestedByFilter( + id: 77, + name: '상신자', + employeeNo: 'EMP077', + ); await controller.fetch(page: 3); @@ -122,10 +128,9 @@ void main() { () => repository.list( page: 3, pageSize: 20, - query: 'TRX', - status: 'approved', - from: from, - to: to, + transactionId: 55, + approvalStatusId: null, + requestedById: 77, includeHistories: false, includeSteps: false, ), @@ -138,10 +143,9 @@ void main() { () => repository.list( page: any(named: 'page'), pageSize: any(named: 'pageSize'), - query: any(named: 'query'), - status: any(named: 'status'), - from: any(named: 'from'), - to: any(named: 'to'), + transactionId: any(named: 'transactionId'), + approvalStatusId: any(named: 'approvalStatusId'), + requestedById: any(named: 'requestedById'), includeHistories: any(named: 'includeHistories'), includeSteps: any(named: 'includeSteps'), ), @@ -151,6 +155,46 @@ void main() { expect(controller.errorMessage, isNotNull); }); + + test('상태 룩업 로드 후 status ID를 전달한다', () async { + final lookupRepository = _MockInventoryLookupRepository(); + final ctrl = ApprovalController( + approvalRepository: repository, + templateRepository: templateRepository, + lookupRepository: lookupRepository, + ); + when( + () => lookupRepository.fetchApprovalStatuses( + activeOnly: any(named: 'activeOnly'), + ), + ).thenAnswer( + (_) async => [ + LookupItem( + id: 5, + name: '승인완료', + code: 'approved', + isDefault: false, + isActive: true, + ), + ], + ); + + await ctrl.loadStatusLookups(); + ctrl.updateStatusFilter(ApprovalStatusFilter.approved); + await ctrl.fetch(); + + verify( + () => repository.list( + page: 1, + pageSize: 20, + transactionId: null, + approvalStatusId: 5, + requestedById: null, + includeHistories: false, + includeSteps: false, + ), + ).called(1); + }); }); group('selectApproval', () { @@ -304,10 +348,9 @@ void main() { () => repository.list( page: any(named: 'page'), pageSize: any(named: 'pageSize'), - query: any(named: 'query'), - status: any(named: 'status'), - from: any(named: 'from'), - to: any(named: 'to'), + transactionId: any(named: 'transactionId'), + approvalStatusId: any(named: 'approvalStatusId'), + requestedById: any(named: 'requestedById'), includeHistories: any(named: 'includeHistories'), includeSteps: any(named: 'includeSteps'), ), @@ -471,10 +514,9 @@ void main() { () => repository.list( page: any(named: 'page'), pageSize: any(named: 'pageSize'), - query: any(named: 'query'), - status: any(named: 'status'), - from: any(named: 'from'), - to: any(named: 'to'), + transactionId: any(named: 'transactionId'), + approvalStatusId: any(named: 'approvalStatusId'), + requestedById: any(named: 'requestedById'), includeHistories: any(named: 'includeHistories'), includeSteps: any(named: 'includeSteps'), ), @@ -571,15 +613,19 @@ void main() { }); test('필터 초기화', () { - controller.updateQuery('abc'); + controller.updateTransactionFilter(42); controller.updateStatusFilter(ApprovalStatusFilter.rejected); - controller.updateDateRange(DateTime(2024, 1, 1), DateTime(2024, 1, 31)); + controller.updateRequestedByFilter( + id: 11, + name: '요청자', + employeeNo: 'EMP011', + ); controller.clearFilters(); - expect(controller.query, isEmpty); + expect(controller.transactionIdFilter, isNull); expect(controller.statusFilter, ApprovalStatusFilter.all); - expect(controller.fromDate, isNull); - expect(controller.toDate, isNull); + expect(controller.requestedById, isNull); + expect(controller.requestedByName, isNull); }); } diff --git a/test/features/approvals/presentation/pages/approval_page_test.dart b/test/features/approvals/presentation/pages/approval_page_test.dart index afb3852..18c6ef6 100644 --- a/test/features/approvals/presentation/pages/approval_page_test.dart +++ b/test/features/approvals/presentation/pages/approval_page_test.dart @@ -105,10 +105,9 @@ void main() { () => repository.list( page: any(named: 'page'), pageSize: any(named: 'pageSize'), - query: any(named: 'query'), - status: any(named: 'status'), - from: any(named: 'from'), - to: any(named: 'to'), + transactionId: any(named: 'transactionId'), + approvalStatusId: any(named: 'approvalStatusId'), + requestedById: any(named: 'requestedById'), includeHistories: any(named: 'includeHistories'), includeSteps: any(named: 'includeSteps'), ), diff --git a/test/features/inventory/lookups/data/inventory_lookup_repository_remote_test.dart b/test/features/inventory/lookups/data/inventory_lookup_repository_remote_test.dart index c33a24c..d1b0b60 100644 --- a/test/features/inventory/lookups/data/inventory_lookup_repository_remote_test.dart +++ b/test/features/inventory/lookups/data/inventory_lookup_repository_remote_test.dart @@ -61,7 +61,7 @@ void main() { final query = captured[1] as Map; expect(query['page'], 1); expect(query['page_size'], 200); - expect(query['is_active'], true); + expect(query['active'], true); }); test('fetchApprovalActions는 is_active 파라미터 없이 호출한다', () async { @@ -80,6 +80,6 @@ void main() { ), ).captured[1] as Map; - expect(query.containsKey('is_active'), isFalse); + expect(query.containsKey('active'), isFalse); }); } diff --git a/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart index b789522..98d82b3 100644 --- a/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart +++ b/test/features/inventory/transactions/data/stock_transaction_repository_remote_test.dart @@ -91,8 +91,8 @@ void main() { expect(query['transaction_status_id'], 7); expect(query['warehouse_id'], 3); expect(query['customer_id'], 99); - expect(query['from'], '2024-01-01T00:00:00.000'); - expect(query['to'], '2024-01-31T00:00:00.000'); + expect(query['date_from'], '2024-01-01'); + expect(query['date_to'], '2024-01-31'); expect(query['sort'], 'transaction_date'); expect(query['order'], 'desc'); expect(query['include'], 'lines,approval'); diff --git a/test/features/masters/customer/data/customer_repository_remote_test.dart b/test/features/masters/customer/data/customer_repository_remote_test.dart index 62a5cf2..4da8def 100644 --- a/test/features/masters/customer/data/customer_repository_remote_test.dart +++ b/test/features/masters/customer/data/customer_repository_remote_test.dart @@ -72,7 +72,7 @@ void main() { expect(query['q'], 'sup'); expect(query['is_partner'], true); expect(query['is_general'], false); - expect(query['is_active'], true); + expect(query['active'], true); }); test('fetchDetail은 include=zipcode 파라미터를 전달한다', () async { diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart index 2a148d2..7d07225 100644 --- a/test/features/masters/customer/presentation/pages/customer_page_test.dart +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -177,7 +177,7 @@ void main() { await tester.tap(find.text('등록')); await tester.pump(); - expect(find.text('우편번호 검색으로 주소를 선택하세요.'), findsOneWidget); + expect(find.text('검색 버튼을 눌러 주소를 선택하세요.'), findsOneWidget); }); testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async { @@ -224,6 +224,7 @@ void main() { id: 2, customerCode: capturedInput!.customerCode, customerName: capturedInput!.customerName, + contactName: capturedInput!.contactName, isPartner: capturedInput!.isPartner, isGeneral: capturedInput!.isGeneral, ); @@ -245,8 +246,9 @@ void main() { await tester.enterText(editableTexts.at(0), 'C-100'); await tester.enterText(editableTexts.at(1), '신규 고객'); - await tester.enterText(editableTexts.at(2), 'new@superport.com'); - await tester.enterText(editableTexts.at(3), '02-0000-0000'); + await tester.enterText(editableTexts.at(2), '홍길동'); + await tester.enterText(editableTexts.at(3), 'new@superport.com'); + await tester.enterText(editableTexts.at(4), '02-0000-0000'); // 유형 체크박스: 기본값 partner=false, general=true. partner on 추가 await tester.tap(find.text('파트너')); @@ -257,6 +259,7 @@ void main() { expect(capturedInput, isNotNull); expect(capturedInput?.customerCode, 'C-100'); + expect(capturedInput?.contactName, '홍길동'); expect(find.byType(Dialog), findsNothing); expect(find.text('C-100'), findsOneWidget); verify(() => repository.create(any())).called(1); diff --git a/test/features/masters/product/data/product_repository_remote_test.dart b/test/features/masters/product/data/product_repository_remote_test.dart index 6e4bdac..cf086f3 100644 --- a/test/features/masters/product/data/product_repository_remote_test.dart +++ b/test/features/masters/product/data/product_repository_remote_test.dart @@ -78,7 +78,7 @@ void main() { expect(query['include'], 'vendor,uom'); expect(query['vendor_id'], 10); expect(query['uom_id'], 5); - expect(query['is_active'], false); + expect(query['active'], false); expect(query['page'], 3); expect(query['page_size'], 40); expect(query['q'], 'gear'); diff --git a/test/features/masters/product/presentation/pages/product_page_test.dart b/test/features/masters/product/presentation/pages/product_page_test.dart index ba6a5b8..b0893e3 100644 --- a/test/features/masters/product/presentation/pages/product_page_test.dart +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -295,7 +295,7 @@ void main() { await tester.tap(find.text('제조사를 선택하세요')); await tester.pumpAndSettle(); - await tester.tap(find.text('슈퍼벤더')); + await tester.tap(find.text('슈퍼벤더 (V-001)')); await tester.pumpAndSettle(); await tester.tap(find.text('단위를 선택하세요')); diff --git a/test/features/reporting/data/reporting_repository_remote_test.dart b/test/features/reporting/data/reporting_repository_remote_test.dart index b0e0f17..049ad2b 100644 --- a/test/features/reporting/data/reporting_repository_remote_test.dart +++ b/test/features/reporting/data/reporting_repository_remote_test.dart @@ -98,8 +98,8 @@ void main() { expect(captured.first, equals(path)); final query = captured[1] as Map; - expect(query['from'], request.from.toIso8601String()); - expect(query['to'], request.to.toIso8601String()); + expect(query['date_from'], '2024-01-01'); + expect(query['date_to'], '2024-01-31'); expect(query['format'], 'xlsx'); expect(query['transaction_status_id'], 3); expect(query['approval_status_id'], 7);