결재 및 마스터 모듈을 v4 API 계약에 맞게 조정
This commit is contained in:
@@ -25,24 +25,28 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
Future<PaginatedResult<Approval>> 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 = <String>[];
|
||||
if (includeSteps) {
|
||||
includeParts.add('steps');
|
||||
}
|
||||
if (includeHistories) {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_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 = <String>[];
|
||||
if (includeSteps) {
|
||||
includeParts.add('steps');
|
||||
}
|
||||
if (includeHistories) {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'$_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<String, dynamic>?) ?? {};
|
||||
|
||||
@@ -11,10 +11,9 @@ abstract class ApprovalRepository {
|
||||
Future<PaginatedResult<Approval>> 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,
|
||||
});
|
||||
|
||||
@@ -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<ApprovalAction> _actions = const [];
|
||||
List<ApprovalTemplate> _templates = const [];
|
||||
final Map<String, LookupItem> _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<ApprovalAction> get actionOptions => _actions;
|
||||
bool get hasActionOptions => _actions.isNotEmpty;
|
||||
List<ApprovalTemplate> 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 = <String>{};
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApprovalStatusFilter>(
|
||||
@@ -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<void> _handleStepAction(
|
||||
|
||||
@@ -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<List<LookupItem>> _fetchList(
|
||||
String path, {
|
||||
required bool activeOnly,
|
||||
bool includeIsActive = true,
|
||||
bool includeActiveFilter = true,
|
||||
}) async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
path,
|
||||
query: {
|
||||
'page': 1,
|
||||
'page_size': 200,
|
||||
if (includeIsActive && activeOnly) 'is_active': true,
|
||||
if (includeActiveFilter && activeOnly) 'active': true,
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
@@ -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<InventoryWarehouseSelectField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
WarehouseRepository? get _repository =>
|
||||
GetIt.I.isRegistered<WarehouseRepository>()
|
||||
? GetIt.I<WarehouseRepository>()
|
||||
: null;
|
||||
|
||||
List<InventoryWarehouseOption> _options = const [];
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<InventoryWarehouseOption> _initialOptions = [];
|
||||
final List<InventoryWarehouseOption> _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<void> _loadWarehouses() async {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_handleTextChanged);
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<InventoryWarehouseOption> _availableOptions(
|
||||
List<InventoryWarehouseOption> base,
|
||||
) {
|
||||
if (!widget.includeAllOption) {
|
||||
return List<InventoryWarehouseOption>.from(base);
|
||||
}
|
||||
return <InventoryWarehouseOption>[_allOption, ...base];
|
||||
}
|
||||
|
||||
Future<void> _applyInitialSelection({
|
||||
List<InventoryWarehouseOption>? 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<InventoryWarehouseOption> 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<void> _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<int?>(
|
||||
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<InventoryWarehouseOption>(
|
||||
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<int?>(value: null, child: Text(widget.allLabel)),
|
||||
for (final option in _options)
|
||||
ShadOption<int?>(
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) 정보를 담는 입력 모델.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bool>(
|
||||
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!,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String?>(
|
||||
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<int?>(
|
||||
initialValue: value,
|
||||
onChanged: saving.value
|
||||
? null
|
||||
: (next) {
|
||||
vendorNotifier.value = next;
|
||||
vendorError.value = null;
|
||||
},
|
||||
options: _controller.vendorOptions
|
||||
.map(
|
||||
(vendor) => ShadOption<int?>(
|
||||
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<String?>(
|
||||
valueListenable: uomError,
|
||||
builder: (_, errorText, __) {
|
||||
final uomLabel = existing?.uom?.uomName;
|
||||
return _FormField(
|
||||
label: '단위',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadSelect<int?>(
|
||||
initialValue: value,
|
||||
onChanged: saving.value
|
||||
? null
|
||||
: (next) {
|
||||
uomNotifier.value = next;
|
||||
uomError.value = null;
|
||||
},
|
||||
options: _controller.uomOptions
|
||||
.map(
|
||||
(uom) => ShadOption<int?>(
|
||||
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)
|
||||
|
||||
@@ -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<Uom> initialOptions;
|
||||
final int? selectedUomId;
|
||||
final String? initialLabel;
|
||||
final ValueChanged<int?> onSelected;
|
||||
final bool enabled;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<UomAutocompleteField> createState() => _UomAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _UomAutocompleteFieldState extends State<UomAutocompleteField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
UomRepository? get _repository =>
|
||||
GetIt.I.isRegistered<UomRepository>() ? GetIt.I<UomRepository>() : null;
|
||||
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<Uom> _baseOptions = [];
|
||||
final List<Uom> _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<void> _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<Uom> 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<Uom>(
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Vendor> initialOptions;
|
||||
final int? selectedVendorId;
|
||||
final String? initialLabel;
|
||||
final ValueChanged<int?> onSelected;
|
||||
final bool enabled;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<VendorAutocompleteField> createState() =>
|
||||
_VendorAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _VendorAutocompleteFieldState extends State<VendorAutocompleteField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
VendorRepository? get _repository => GetIt.I.isRegistered<VendorRepository>()
|
||||
? GetIt.I<VendorRepository>()
|
||||
: null;
|
||||
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<Vendor> _baseOptions = [];
|
||||
final List<Vendor> _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<void> _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<Vendor> 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<Vendor>(
|
||||
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)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -26,7 +26,7 @@ class ReportingRepositoryRemote implements ReportingRepository {
|
||||
) async {
|
||||
final response = await _api.get<Uint8List>(
|
||||
_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<Uint8List>(
|
||||
_approvalsPath,
|
||||
query: _buildQuery(request),
|
||||
query: _buildApprovalQuery(request),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return _mapResponse(response, format: request.format);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildQuery(ReportExportRequest request) {
|
||||
Map<String, dynamic> _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<String, dynamic> _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<Uint8List> response, {
|
||||
required ReportExportFormat format,
|
||||
|
||||
@@ -197,10 +197,9 @@ class _StubApprovalRepository implements ApprovalRepository {
|
||||
Future<PaginatedResult<Approval>> 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 {
|
||||
|
||||
@@ -22,6 +22,52 @@ void main() {
|
||||
repository = ApprovalRepositoryRemote(apiClient: apiClient);
|
||||
});
|
||||
|
||||
test('list는 신규 필터 파라미터를 전달한다', () async {
|
||||
const path = '/api/v1/approvals';
|
||||
when(
|
||||
() => apiClient.get<Map<String, dynamic>>(
|
||||
path,
|
||||
query: any(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => Response<Map<String, dynamic>>(
|
||||
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<Map<String, dynamic>>(
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic> 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<Map<String, dynamic>>(
|
||||
path,
|
||||
query: any(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => Response<Map<String, dynamic>>(
|
||||
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<Map<String, dynamic>>(
|
||||
any(),
|
||||
query: captureAny(named: 'query'),
|
||||
options: any(named: 'options'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
),
|
||||
).captured.first
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect(query['include'], 'steps,histories');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -61,7 +61,7 @@ void main() {
|
||||
final query = captured[1] as Map<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
expect(query.containsKey('is_active'), isFalse);
|
||||
expect(query.containsKey('active'), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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('단위를 선택하세요'));
|
||||
|
||||
@@ -98,8 +98,8 @@ void main() {
|
||||
|
||||
expect(captured.first, equals(path));
|
||||
final query = captured[1] as Map<String, dynamic>;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user