결재 및 마스터 모듈을 v4 API 계약에 맞게 조정

This commit is contained in:
JiWoong Sul
2025-10-16 17:27:20 +09:00
parent d5c99627db
commit 9e2244f260
34 changed files with 1394 additions and 330 deletions

View File

@@ -25,24 +25,28 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
Future<PaginatedResult<Approval>> list({ Future<PaginatedResult<Approval>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
String? query, int? transactionId,
String? status, int? approvalStatusId,
DateTime? from, int? requestedById,
DateTime? to,
bool includeHistories = false, bool includeHistories = false,
bool includeSteps = false, bool includeSteps = false,
}) async { }) async {
final includeParts = <String>[];
if (includeSteps) {
includeParts.add('steps');
}
if (includeHistories) {
includeParts.add('histories');
}
final response = await _api.get<Map<String, dynamic>>( final response = await _api.get<Map<String, dynamic>>(
_basePath, _basePath,
query: { query: {
'page': page, 'page': page,
'page_size': pageSize, 'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query, if (transactionId != null) 'transaction_id': transactionId,
if (status != null && status.isNotEmpty) 'status': status, if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
if (from != null) 'from': from.toIso8601String(), if (requestedById != null) 'requested_by_id': requestedById,
if (to != null) 'to': to.toIso8601String(), if (includeParts.isNotEmpty) 'include': includeParts.join(','),
if (includeHistories) 'include_histories': true,
if (includeSteps) 'include_steps': true,
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),
); );
@@ -56,12 +60,16 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
bool includeSteps = true, bool includeSteps = true,
bool includeHistories = true, bool includeHistories = true,
}) async { }) async {
final includeParts = <String>[];
if (includeSteps) {
includeParts.add('steps');
}
if (includeHistories) {
includeParts.add('histories');
}
final response = await _api.get<Map<String, dynamic>>( final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id', '$_basePath/$id',
query: { query: {if (includeParts.isNotEmpty) 'include': includeParts.join(',')},
if (includeSteps) 'include_steps': true,
if (includeHistories) 'include_histories': true,
},
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),
); );
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {}; final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};

View File

@@ -11,10 +11,9 @@ abstract class ApprovalRepository {
Future<PaginatedResult<Approval>> list({ Future<PaginatedResult<Approval>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
String? query, int? transactionId,
String? status, int? approvalStatusId,
DateTime? from, int? requestedById,
DateTime? to,
bool includeHistories = false, bool includeHistories = false,
bool includeSteps = false, bool includeSteps = false,
}); });

View File

@@ -64,10 +64,11 @@ class ApprovalController extends ChangeNotifier {
int? _applyingTemplateId; int? _applyingTemplateId;
ApprovalProceedStatus? _proceedStatus; ApprovalProceedStatus? _proceedStatus;
String? _errorMessage; String? _errorMessage;
String _query = '';
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all; ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
DateTime? _fromDate; int? _transactionIdFilter;
DateTime? _toDate; int? _requestedById;
String? _requestedByName;
String? _requestedByEmployeeNo;
List<ApprovalAction> _actions = const []; List<ApprovalAction> _actions = const [];
List<ApprovalTemplate> _templates = const []; List<ApprovalTemplate> _templates = const [];
final Map<String, LookupItem> _statusLookup = {}; final Map<String, LookupItem> _statusLookup = {};
@@ -85,10 +86,11 @@ class ApprovalController extends ChangeNotifier {
bool get isPerformingAction => _isPerformingAction; bool get isPerformingAction => _isPerformingAction;
int? get processingStepId => _processingStepId; int? get processingStepId => _processingStepId;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
String get query => _query;
ApprovalStatusFilter get statusFilter => _statusFilter; ApprovalStatusFilter get statusFilter => _statusFilter;
DateTime? get fromDate => _fromDate; int? get transactionIdFilter => _transactionIdFilter;
DateTime? get toDate => _toDate; int? get requestedById => _requestedById;
String? get requestedByName => _requestedByName;
String? get requestedByEmployeeNo => _requestedByEmployeeNo;
List<ApprovalAction> get actionOptions => _actions; List<ApprovalAction> get actionOptions => _actions;
bool get hasActionOptions => _actions.isNotEmpty; bool get hasActionOptions => _actions.isNotEmpty;
List<ApprovalTemplate> get templates => _templates; List<ApprovalTemplate> get templates => _templates;
@@ -116,14 +118,13 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
try { try {
final statusParam = _statusCodeFor(_statusFilter); final statusId = _statusIdFor(_statusFilter);
final response = await _repository.list( final response = await _repository.list(
page: page, page: page,
pageSize: _result?.pageSize ?? 20, pageSize: _result?.pageSize ?? 20,
query: _query.isEmpty ? null : _query, transactionId: _transactionIdFilter,
status: statusParam, approvalStatusId: statusId,
from: _fromDate, requestedById: _requestedById,
to: _toDate,
includeSteps: false, includeSteps: false,
includeHistories: false, includeHistories: false,
); );
@@ -176,18 +177,29 @@ class ApprovalController extends ChangeNotifier {
_statusLookup _statusLookup
..clear() ..clear()
..addEntries( ..addEntries(
items.map( items.expand((item) {
(item) => MapEntry( final keys = <String>{};
(item.code ?? item.name).toLowerCase(), final code = item.code?.trim();
item, 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) { for (final entry in _defaultStatusCodes.entries) {
final code = entry.value.toLowerCase(); final defaultCode = entry.value;
final lookup = _statusLookup[code]; final normalized = defaultCode.toLowerCase();
final lookup = _statusLookup[normalized];
if (lookup != null) { if (lookup != null) {
_statusCodeAliases[entry.value] = lookup.code?.toLowerCase() ?? code; final alias = lookup.code?.toLowerCase() ?? normalized;
_statusCodeAliases[defaultCode] = alias;
} else {
_statusCodeAliases[defaultCode] = defaultCode;
} }
} }
notifyListeners(); notifyListeners();
@@ -229,6 +241,15 @@ class ApprovalController extends ChangeNotifier {
return _statusCodeAliases[defaultCode] ?? defaultCode; 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를 다시 호출한다. /// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
@@ -325,8 +346,7 @@ class ApprovalController extends ChangeNotifier {
final proceedStatus = await _repository.canProceed(approvalId); final proceedStatus = await _repository.canProceed(approvalId);
_proceedStatus = proceedStatus; _proceedStatus = proceedStatus;
if (!proceedStatus.canProceed) { if (!proceedStatus.canProceed) {
_errorMessage = proceedStatus.reason ?? _errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.';
'결재 단계가 현재 상태에서 진행될 수 없습니다.';
return false; return false;
} }
@@ -421,31 +441,33 @@ class ApprovalController extends ChangeNotifier {
} }
} }
/// 검색 키워드를 변경하고 UI 갱신을 유도한다.
void updateQuery(String value) {
_query = value;
notifyListeners();
}
/// 상태 필터 값을 변경한다. /// 상태 필터 값을 변경한다.
void updateStatusFilter(ApprovalStatusFilter filter) { void updateStatusFilter(ApprovalStatusFilter filter) {
_statusFilter = filter; _statusFilter = filter;
notifyListeners(); notifyListeners();
} }
/// 조회 기간을 설정한다. 두 값 모두 `null`이면 기간 조건을 제한다. /// 트랜잭션 ID 필터를 갱신한다. null이면 조건을 제한다.
void updateDateRange(DateTime? from, DateTime? to) { void updateTransactionFilter(int? transactionId) {
_fromDate = from; _transactionIdFilter = transactionId;
_toDate = to;
notifyListeners(); notifyListeners();
} }
/// 검색어/상태/기간 등의 필터 조건을 초기화한다. /// 상신자(요청자) 필터를 갱신한다. null 값을 전달하면 조건을 제거한다.
void updateRequestedByFilter({int? id, String? name, String? employeeNo}) {
_requestedById = id;
_requestedByName = name;
_requestedByEmployeeNo = employeeNo;
notifyListeners();
}
/// 상태/트랜잭션/상신자 필터를 초기값으로 되돌린다.
void clearFilters() { void clearFilters() {
_query = '';
_statusFilter = ApprovalStatusFilter.all; _statusFilter = ApprovalStatusFilter.all;
_fromDate = null; _transactionIdFilter = null;
_toDate = null; _requestedById = null;
_requestedByName = null;
_requestedByEmployeeNo = null;
notifyListeners(); notifyListeners();
} }

View File

@@ -11,7 +11,6 @@ import '../../../../core/permissions/permission_resources.dart';
import '../../../../widgets/app_layout.dart'; import '../../../../widgets/app_layout.dart';
import '../../../../widgets/components/feedback.dart'; import '../../../../widgets/components/feedback.dart';
import '../../../../widgets/components/filter_bar.dart'; import '../../../../widgets/components/filter_bar.dart';
import '../../../../widgets/components/superport_date_picker.dart';
import '../../../../widgets/components/superport_dialog.dart'; import '../../../../widgets/components/superport_dialog.dart';
import '../../../../widgets/components/superport_table.dart'; import '../../../../widgets/components/superport_table.dart';
import '../../../../widgets/components/feature_disabled_placeholder.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_repository.dart';
import '../../domain/repositories/approval_template_repository.dart'; import '../../domain/repositories/approval_template_repository.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
import '../controllers/approval_controller.dart'; import '../controllers/approval_controller.dart';
const _approvalsResourcePath = PermissionResources.approvals; const _approvalsResourcePath = PermissionResources.approvals;
@@ -73,11 +73,11 @@ class _ApprovalEnabledPage extends StatefulWidget {
class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
late final ApprovalController _controller; late final ApprovalController _controller;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _transactionController = TextEditingController();
final FocusNode _searchFocus = FocusNode(); final TextEditingController _requesterController = TextEditingController();
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd'); final FocusNode _transactionFocus = FocusNode();
InventoryEmployeeSuggestion? _selectedRequester;
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
DateTimeRange? _dateRange;
String? _lastError; String? _lastError;
int? _selectedTemplateId; int? _selectedTemplateId;
@@ -114,8 +114,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
void dispose() { void dispose() {
_controller.removeListener(_handleControllerUpdate); _controller.removeListener(_handleControllerUpdate);
_controller.dispose(); _controller.dispose();
_searchController.dispose(); _transactionController.dispose();
_searchFocus.dispose(); _requesterController.dispose();
_transactionFocus.dispose();
super.dispose(); super.dispose();
} }
@@ -197,16 +198,39 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
), ),
children: [ children: [
SizedBox( SizedBox(
width: 260, width: 180,
child: ShadInput( child: ShadInput(
controller: _searchController, controller: _transactionController,
focusNode: _searchFocus, focusNode: _transactionFocus,
placeholder: const Text('결재번호, 트랜잭션번호, 상신자 검색'), enabled: !_controller.isLoadingList,
leading: const Icon(lucide.LucideIcons.search, size: 16), keyboardType: TextInputType.number,
placeholder: const Text('트랜잭션 ID 입력'),
leading: const Icon(lucide.LucideIcons.hash, size: 16),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
onSubmitted: (_) => _applyFilters(), 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( SizedBox(
width: 200, width: 200,
child: ShadSelect<ApprovalStatusFilter>( child: ShadSelect<ApprovalStatusFilter>(
@@ -229,37 +253,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
.toList(), .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( child: Column(
@@ -476,25 +469,48 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
} }
void _applyFilters() { void _applyFilters() {
_controller.updateQuery(_searchController.text.trim()); final transactionText = _transactionController.text.trim();
if (_dateRange != null) { if (transactionText.isNotEmpty) {
_controller.updateDateRange(_dateRange!.start, _dateRange!.end); 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); _controller.fetch(page: 1);
} }
void _resetFilters() { void _resetFilters() {
_searchController.clear(); _transactionController.clear();
_searchFocus.requestFocus(); _requesterController.clear();
_dateRange = null; setState(() => _selectedRequester = null);
_transactionFocus.requestFocus();
_controller.clearFilters(); _controller.clearFilters();
_controller.fetch(page: 1); _controller.fetch(page: 1);
} }
bool _hasFilters() { bool _hasFilters() {
return _searchController.text.isNotEmpty || return _transactionController.text.trim().isNotEmpty ||
_controller.statusFilter != ApprovalStatusFilter.all || _requesterController.text.trim().isNotEmpty ||
_dateRange != null; _selectedRequester != null ||
_controller.transactionIdFilter != null ||
_controller.requestedById != null ||
_controller.statusFilter != ApprovalStatusFilter.all;
} }
Future<void> _handleStepAction( Future<void> _handleStepAction(

View File

@@ -39,22 +39,22 @@ class InventoryLookupRepositoryRemote implements InventoryLookupRepository {
return _fetchList( return _fetchList(
_approvalActionsPath, _approvalActionsPath,
activeOnly: activeOnly, activeOnly: activeOnly,
// Approval actions는 is_active 필터가 없을 수 있어 조건적으로 전달. // Approval actions는 active 필터가 없을 수 있어 조건적으로 전달한다.
includeIsActive: false, includeActiveFilter: false,
); );
} }
Future<List<LookupItem>> _fetchList( Future<List<LookupItem>> _fetchList(
String path, { String path, {
required bool activeOnly, required bool activeOnly,
bool includeIsActive = true, bool includeActiveFilter = true,
}) async { }) async {
final response = await _api.get<Map<String, dynamic>>( final response = await _api.get<Map<String, dynamic>>(
path, path,
query: { query: {
'page': 1, 'page': 1,
'page_size': 200, 'page_size': 200,
if (includeIsActive && activeOnly) 'is_active': true, if (includeActiveFilter && activeOnly) 'active': true,
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),
); );

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
@@ -59,31 +61,53 @@ class InventoryWarehouseSelectField extends StatefulWidget {
class _InventoryWarehouseSelectFieldState class _InventoryWarehouseSelectFieldState
extends State<InventoryWarehouseSelectField> { extends State<InventoryWarehouseSelectField> {
static const Duration _debounceDuration = Duration(milliseconds: 250);
WarehouseRepository? get _repository => WarehouseRepository? get _repository =>
GetIt.I.isRegistered<WarehouseRepository>() GetIt.I.isRegistered<WarehouseRepository>()
? GetIt.I<WarehouseRepository>() ? GetIt.I<WarehouseRepository>()
: null; : null;
List<InventoryWarehouseOption> _options = const []; final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
final List<InventoryWarehouseOption> _initialOptions = [];
final List<InventoryWarehouseOption> _suggestions = [];
InventoryWarehouseOption? _selected; InventoryWarehouseOption? _selected;
bool _isLoading = false; bool _isLoadingInitial = false;
bool _isSearching = false;
String? _error; String? _error;
Timer? _debounce;
int _requestId = 0;
bool _isApplyingText = false;
InventoryWarehouseOption get _allOption =>
InventoryWarehouseOption(id: -1, code: 'ALL', name: widget.allLabel);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadWarehouses(); _controller.addListener(_handleTextChanged);
_loadInitialOptions();
} }
@override @override
void didUpdateWidget(covariant InventoryWarehouseSelectField oldWidget) { void didUpdateWidget(covariant InventoryWarehouseSelectField oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.initialWarehouseId != oldWidget.initialWarehouseId) { 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; final repository = _repository;
if (repository == null) { if (repository == null) {
setState(() { setState(() {
@@ -92,7 +116,7 @@ class _InventoryWarehouseSelectFieldState
return; return;
} }
setState(() { setState(() {
_isLoading = true; _isLoadingInitial = true;
_error = null; _error = null;
}); });
try { try {
@@ -105,58 +129,91 @@ class _InventoryWarehouseSelectFieldState
final options = result.items final options = result.items
.map(InventoryWarehouseOption.fromWarehouse) .map(InventoryWarehouseOption.fromWarehouse)
.toList(growable: false); .toList(growable: false);
final selected = _findOptionById(options, widget.initialWarehouseId);
setState(() { setState(() {
_options = options; _initialOptions
_selected = selected; ..clear()
_isLoading = false; ..addAll(options);
_suggestions
..clear()
..addAll(_availableOptions(options));
_isLoadingInitial = false;
}); });
if (selected != null) { await _applyInitialSelection(options: options, allowFetch: true);
widget.onChanged(selected);
}
} catch (error) { } catch (error) {
setState(() { setState(() {
final failure = Failure.from(error); final failure = Failure.from(error);
_error = failure.describe(); _error = failure.describe();
_isLoading = false; _isLoadingInitial = false;
}); });
} }
} }
void _syncSelection(int? warehouseId) { List<InventoryWarehouseOption> _availableOptions(
if (_options.isEmpty) { List<InventoryWarehouseOption> base,
if (warehouseId == null && _selected != null) {
setState(() {
_selected = null;
});
widget.onChanged(null);
}
return;
}
final next = _findOptionById(_options, warehouseId);
if (warehouseId == null && _selected != null) {
setState(() {
_selected = null;
});
widget.onChanged(null);
return;
}
if (!identical(next, _selected)) {
setState(() {
_selected = next;
});
widget.onChanged(next);
}
}
InventoryWarehouseOption? _findOptionById(
List<InventoryWarehouseOption> options,
int? id,
) { ) {
if (id == null) { if (!widget.includeAllOption) {
return null; 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 source = options ?? _initialOptions;
final match = _findById(source, id);
if (match != null) {
_setSelection(match, notify: true);
return;
}
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(() {
_suggestions
..clear()
..addAll(_availableOptions(_initialOptions));
});
_setSelection(option, notify: true);
} catch (_) {
// 초기 선정을 찾지 못하면 기본값 유지.
}
}
InventoryWarehouseOption? _findById(
List<InventoryWarehouseOption> options,
int id,
) {
for (final option in options) { for (final option in options) {
if (option.id == id) { if (option.id == id) {
return option; return option;
@@ -165,9 +222,116 @@ class _InventoryWarehouseSelectFieldState
return null; return null;
} }
@override void _handleTextChanged() {
Widget build(BuildContext context) { if (_isApplyingText) {
if (_isLoading) { 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( return Stack(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
children: const [ children: const [
@@ -184,6 +348,12 @@ class _InventoryWarehouseSelectFieldState
); );
} }
@override
Widget build(BuildContext context) {
if (_isLoadingInitial) {
return _buildLoadingInput();
}
if (_error != null) { if (_error != null) {
return ShadInput( return ShadInput(
readOnly: true, readOnly: true,
@@ -192,58 +362,96 @@ class _InventoryWarehouseSelectFieldState
); );
} }
if (_options.isEmpty) { return RawAutocomplete<InventoryWarehouseOption>(
return ShadInput( textEditingController: _controller,
readOnly: true, focusNode: _focusNode,
enabled: false, optionsBuilder: (textEditingValue) {
placeholder: const Text('선택 가능한 창고가 없습니다'), if (textEditingValue.text.trim().isEmpty) {
); if (_suggestions.isEmpty) {
return _availableOptions(_initialOptions);
} }
return _suggestions;
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})'); return _suggestions;
}, },
onChanged: (value) { displayStringForOption: (option) =>
if (value == null) { option.id == -1 ? widget.allLabel : _displayLabel(option),
if (_selected != null) { onSelected: (option) {
setState(() { if (!mounted) {
_selected = null;
});
widget.onChanged(null);
}
return; return;
} }
final option = _options.firstWhere( if (option.id == -1) {
(item) => item.id == value, _setSelection(_allOption);
orElse: () => _options.first, } else {
); _setSelection(option);
setState(() { }
_selected = option;
});
widget.onChanged(option);
}, },
options: [ fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) {
if (widget.includeAllOption) final placeholder = widget.placeholder ?? const Text('창고 선택');
ShadOption<int?>(value: null, child: Text(widget.allLabel)), return Stack(
for (final option in _options) alignment: Alignment.centerRight,
ShadOption<int?>( children: [
value: option.id, ShadInput(
child: Text('${option.name} · ${option.code}'), 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),
),
);
},
),
),
),
);
},
);
} }
} }

View File

@@ -195,13 +195,23 @@ class StockTransactionListFilter {
'transaction_status_id': transactionStatusId, 'transaction_status_id': transactionStatusId,
if (warehouseId != null) 'warehouse_id': warehouseId, if (warehouseId != null) 'warehouse_id': warehouseId,
if (customerId != null) 'customer_id': customerId, if (customerId != null) 'customer_id': customerId,
if (from != null) 'from': from!.toIso8601String(), if (from != null) 'date_from': _formatDate(from!),
if (to != null) 'to': to!.toIso8601String(), if (to != null) 'date_to': _formatDate(to!),
if (sort != null && sort!.trim().isNotEmpty) 'sort': sort, if (sort != null && sort!.trim().isNotEmpty) 'sort': sort,
if (order != null && order!.trim().isNotEmpty) 'order': order, if (order != null && order!.trim().isNotEmpty) 'order': order,
if (include.isNotEmpty) 'include': include.join(','), 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) 정보를 담는 입력 모델. /// 재고 트랜잭션 생성 시 결재(Approval) 정보를 담는 입력 모델.

View File

@@ -9,6 +9,7 @@ class CustomerDto {
this.id, this.id,
required this.customerCode, required this.customerCode,
required this.customerName, required this.customerName,
this.contactName,
this.isPartner = false, this.isPartner = false,
this.isGeneral = true, this.isGeneral = true,
this.email, this.email,
@@ -25,6 +26,7 @@ class CustomerDto {
final int? id; final int? id;
final String customerCode; final String customerCode;
final String customerName; final String customerName;
final String? contactName;
final bool isPartner; final bool isPartner;
final bool isGeneral; final bool isGeneral;
final String? email; final String? email;
@@ -43,6 +45,7 @@ class CustomerDto {
id: json['id'] as int?, id: json['id'] as int?,
customerCode: json['customer_code'] as String, customerCode: json['customer_code'] as String,
customerName: json['customer_name'] as String, customerName: json['customer_name'] as String,
contactName: json['contact_name'] as String?,
isPartner: (json['is_partner'] as bool?) ?? false, isPartner: (json['is_partner'] as bool?) ?? false,
isGeneral: (json['is_general'] as bool?) ?? true, isGeneral: (json['is_general'] as bool?) ?? true,
email: json['email'] as String?, email: json['email'] as String?,
@@ -65,6 +68,7 @@ class CustomerDto {
if (id != null) 'id': id, if (id != null) 'id': id,
'customer_code': customerCode, 'customer_code': customerCode,
'customer_name': customerName, 'customer_name': customerName,
'contact_name': contactName,
'is_partner': isPartner, 'is_partner': isPartner,
'is_general': isGeneral, 'is_general': isGeneral,
'email': email, 'email': email,
@@ -84,6 +88,7 @@ class CustomerDto {
id: id, id: id,
customerCode: customerCode, customerCode: customerCode,
customerName: customerName, customerName: customerName,
contactName: contactName,
isPartner: isPartner, isPartner: isPartner,
isGeneral: isGeneral, isGeneral: isGeneral,
email: email, email: email,

View File

@@ -33,7 +33,7 @@ class CustomerRepositoryRemote implements CustomerRepository {
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (isPartner != null) 'is_partner': isPartner, if (isPartner != null) 'is_partner': isPartner,
if (isGeneral != null) 'is_general': isGeneral, if (isGeneral != null) 'is_general': isGeneral,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),
); );

View File

@@ -4,6 +4,7 @@ class Customer {
this.id, this.id,
required this.customerCode, required this.customerCode,
required this.customerName, required this.customerName,
this.contactName,
this.isPartner = false, this.isPartner = false,
this.isGeneral = true, this.isGeneral = true,
this.email, this.email,
@@ -20,6 +21,7 @@ class Customer {
final int? id; final int? id;
final String customerCode; final String customerCode;
final String customerName; final String customerName;
final String? contactName;
final bool isPartner; final bool isPartner;
final bool isGeneral; final bool isGeneral;
final String? email; final String? email;
@@ -37,6 +39,7 @@ class Customer {
int? id, int? id,
String? customerCode, String? customerCode,
String? customerName, String? customerName,
String? contactName,
bool? isPartner, bool? isPartner,
bool? isGeneral, bool? isGeneral,
String? email, String? email,
@@ -53,6 +56,7 @@ class Customer {
id: id ?? this.id, id: id ?? this.id,
customerCode: customerCode ?? this.customerCode, customerCode: customerCode ?? this.customerCode,
customerName: customerName ?? this.customerName, customerName: customerName ?? this.customerName,
contactName: contactName ?? this.contactName,
isPartner: isPartner ?? this.isPartner, isPartner: isPartner ?? this.isPartner,
isGeneral: isGeneral ?? this.isGeneral, isGeneral: isGeneral ?? this.isGeneral,
email: email ?? this.email, email: email ?? this.email,
@@ -88,6 +92,7 @@ class CustomerInput {
CustomerInput({ CustomerInput({
required this.customerCode, required this.customerCode,
required this.customerName, required this.customerName,
this.contactName,
required this.isPartner, required this.isPartner,
required this.isGeneral, required this.isGeneral,
this.email, this.email,
@@ -100,6 +105,7 @@ class CustomerInput {
final String customerCode; final String customerCode;
final String customerName; final String customerName;
final String? contactName;
final bool isPartner; final bool isPartner;
final bool isGeneral; final bool isGeneral;
final String? email; final String? email;
@@ -114,6 +120,7 @@ class CustomerInput {
return { return {
'customer_code': customerCode, 'customer_code': customerCode,
'customer_name': customerName, 'customer_name': customerName,
'contact_name': contactName,
'is_partner': isPartner, 'is_partner': isPartner,
'is_general': isGeneral, 'is_general': isGeneral,
'email': email, 'email': email,

View File

@@ -496,6 +496,9 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
final nameController = TextEditingController( final nameController = TextEditingController(
text: existing?.customerName ?? '', text: existing?.customerName ?? '',
); );
final contactController = TextEditingController(
text: existing?.contactName ?? '',
);
final emailController = TextEditingController(text: existing?.email ?? ''); final emailController = TextEditingController(text: existing?.email ?? '');
final mobileController = TextEditingController( final mobileController = TextEditingController(
text: existing?.mobileNo ?? '', text: existing?.mobileNo ?? '',
@@ -597,6 +600,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
: () async { : () async {
final code = codeController.text.trim(); final code = codeController.text.trim();
final name = nameController.text.trim(); final name = nameController.text.trim();
final contact = contactController.text.trim();
final email = emailController.text.trim(); final email = emailController.text.trim();
final mobile = mobileController.text.trim(); final mobile = mobileController.text.trim();
final zipcode = zipcodeController.text.trim(); final zipcode = zipcodeController.text.trim();
@@ -633,6 +637,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
final input = CustomerInput( final input = CustomerInput(
customerCode: code, customerCode: code,
customerName: name, customerName: name,
contactName: contact.isEmpty ? null : contact,
isPartner: partner, isPartner: partner,
isGeneral: general, isGeneral: general,
email: email.isEmpty ? null : email, email: email.isEmpty ? null : email,
@@ -748,6 +753,11 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_FormField(
label: '담당자',
child: ShadInput(controller: contactController),
),
const SizedBox(height: 16),
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: partnerNotifier, valueListenable: partnerNotifier,
builder: (_, partner, __) { builder: (_, partner, __) {
@@ -949,6 +959,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
codeController.dispose(); codeController.dispose();
nameController.dispose(); nameController.dispose();
contactController.dispose();
emailController.dispose(); emailController.dispose();
mobileController.dispose(); mobileController.dispose();
zipcodeController.dispose(); zipcodeController.dispose();
@@ -1047,6 +1058,7 @@ class _CustomerTable extends StatelessWidget {
'ID', 'ID',
'고객사코드', '고객사코드',
'고객사명', '고객사명',
'담당자',
'유형', '유형',
'이메일', '이메일',
'연락처', '연락처',
@@ -1072,6 +1084,7 @@ class _CustomerTable extends StatelessWidget {
customer.id?.toString() ?? '-', customer.id?.toString() ?? '-',
customer.customerCode, customer.customerCode,
customer.customerName, customer.customerName,
customer.contactName?.isEmpty ?? true ? '-' : customer.contactName!,
resolveType(customer), resolveType(customer),
customer.email?.isEmpty ?? true ? '-' : customer.email!, customer.email?.isEmpty ?? true ? '-' : customer.email!,
customer.mobileNo?.isEmpty ?? true ? '-' : customer.mobileNo!, customer.mobileNo?.isEmpty ?? true ? '-' : customer.mobileNo!,

View File

@@ -40,7 +40,7 @@ class GroupRepositoryRemote implements GroupRepository {
'page_size': pageSize, 'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (isDefault != null) 'is_default': isDefault, if (isDefault != null) 'is_default': isDefault,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
if (includeParts.isNotEmpty) 'include': includeParts.join(','), if (includeParts.isNotEmpty) 'include': includeParts.join(','),
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),

View File

@@ -33,7 +33,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
'page_size': pageSize, 'page_size': pageSize,
if (groupId != null) 'group_id': groupId, if (groupId != null) 'group_id': groupId,
if (menuId != null) 'menu_id': menuId, if (menuId != null) 'menu_id': menuId,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
if (includeDeleted) 'include_deleted': true, if (includeDeleted) 'include_deleted': true,
'include': 'group,menu', 'include': 'group,menu',
}, },

View File

@@ -32,7 +32,7 @@ class MenuRepositoryRemote implements MenuRepository {
'page_size': pageSize, 'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (parentId != null) 'parent_id': parentId, if (parentId != null) 'parent_id': parentId,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
if (includeDeleted) 'include_deleted': true, if (includeDeleted) 'include_deleted': true,
'include': 'parent', 'include': 'parent',
}, },

View File

@@ -33,7 +33,7 @@ class ProductRepositoryRemote implements ProductRepository {
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (vendorId != null) 'vendor_id': vendorId, if (vendorId != null) 'vendor_id': vendorId,
if (uomId != null) 'uom_id': uomId, if (uomId != null) 'uom_id': uomId,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
'include': 'vendor,uom', 'include': 'vendor,uom',
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),

View File

@@ -18,6 +18,8 @@ import '../../../vendor/domain/repositories/vendor_repository.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart'; import '../../domain/repositories/product_repository.dart';
import '../controllers/product_controller.dart'; import '../controllers/product_controller.dart';
import '../widgets/uom_autocomplete_field.dart';
import '../widgets/vendor_autocomplete_field.dart';
/// 제품 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다. /// 제품 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
class ProductPage extends StatelessWidget { class ProductPage extends StatelessWidget {
@@ -686,42 +688,27 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
return ValueListenableBuilder<String?>( return ValueListenableBuilder<String?>(
valueListenable: vendorError, valueListenable: vendorError,
builder: (_, errorText, __) { builder: (_, errorText, __) {
final vendorLabel = () {
final vendor = existing?.vendor;
if (vendor == null) {
return null;
}
return '${vendor.vendorName} (${vendor.vendorCode})';
}();
return _FormField( return _FormField(
label: '제조사', label: '제조사',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ShadSelect<int?>( VendorAutocompleteField(
initialValue: value, initialOptions: _controller.vendorOptions,
onChanged: saving.value selectedVendorId: value,
? null initialLabel: vendorLabel,
: (next) { enabled: !isSaving,
vendorNotifier.value = next;
vendorError.value = null;
},
options: _controller.vendorOptions
.map(
(vendor) => ShadOption<int?>(
value: vendor.id,
child: Text(vendor.vendorName),
),
)
.toList(),
placeholder: const Text('제조사를 선택하세요'), placeholder: const Text('제조사를 선택하세요'),
selectedOptionBuilder: (context, selected) { onSelected: (id) {
if (selected == null) { vendorNotifier.value = id;
return const Text('제조사를 선택하세요'); vendorError.value = null;
}
final vendor = _controller.vendorOptions
.firstWhere(
(v) => v.id == selected,
orElse: () => Vendor(
id: selected,
vendorCode: '',
vendorName: '',
),
);
return Text(vendor.vendorName);
}, },
), ),
if (errorText != null) if (errorText != null)
@@ -748,39 +735,21 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
return ValueListenableBuilder<String?>( return ValueListenableBuilder<String?>(
valueListenable: uomError, valueListenable: uomError,
builder: (_, errorText, __) { builder: (_, errorText, __) {
final uomLabel = existing?.uom?.uomName;
return _FormField( return _FormField(
label: '단위', label: '단위',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ShadSelect<int?>( UomAutocompleteField(
initialValue: value, initialOptions: _controller.uomOptions,
onChanged: saving.value selectedUomId: value,
? null initialLabel: uomLabel,
: (next) { enabled: !isSaving,
uomNotifier.value = next;
uomError.value = null;
},
options: _controller.uomOptions
.map(
(uom) => ShadOption<int?>(
value: uom.id,
child: Text(uom.uomName),
),
)
.toList(),
placeholder: const Text('단위를 선택하세요'), placeholder: const Text('단위를 선택하세요'),
selectedOptionBuilder: (context, selected) { onSelected: (id) {
if (selected == null) { uomNotifier.value = id;
return const Text('단위를 선택하세요'); uomError.value = null;
}
final uom = _controller.uomOptions
.firstWhere(
(u) => u.id == selected,
orElse: () =>
Uom(id: selected, uomName: ''),
);
return Text(uom.uomName);
}, },
), ),
if (errorText != null) if (errorText != null)

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ class UomRepositoryRemote implements UomRepository {
'page': page, 'page': page,
'page_size': pageSize, 'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),
); );

View File

@@ -31,7 +31,7 @@ class UserRepositoryRemote implements UserRepository {
'page_size': pageSize, 'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (groupId != null) 'group_id': groupId, if (groupId != null) 'group_id': groupId,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
'include': 'group', 'include': 'group',
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),

View File

@@ -29,7 +29,7 @@ class VendorRepositoryRemote implements VendorRepository {
'page': page, 'page': page,
'page_size': pageSize, 'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),
); );

View File

@@ -30,7 +30,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
'page': page, 'page': page,
'page_size': pageSize, 'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query, if (query != null && query.isNotEmpty) 'q': query,
if (isActive != null) 'is_active': isActive, if (isActive != null) 'active': isActive,
if (includeZipcode) 'include': 'zipcode', if (includeZipcode) 'include': 'zipcode',
}, },
options: Options(responseType: ResponseType.json), options: Options(responseType: ResponseType.json),

View File

@@ -26,7 +26,7 @@ class ReportingRepositoryRemote implements ReportingRepository {
) async { ) async {
final response = await _api.get<Uint8List>( final response = await _api.get<Uint8List>(
_transactionsPath, _transactionsPath,
query: _buildQuery(request), query: _buildTransactionQuery(request),
options: Options(responseType: ResponseType.bytes), options: Options(responseType: ResponseType.bytes),
); );
return _mapResponse(response, format: request.format); return _mapResponse(response, format: request.format);
@@ -38,16 +38,16 @@ class ReportingRepositoryRemote implements ReportingRepository {
) async { ) async {
final response = await _api.get<Uint8List>( final response = await _api.get<Uint8List>(
_approvalsPath, _approvalsPath,
query: _buildQuery(request), query: _buildApprovalQuery(request),
options: Options(responseType: ResponseType.bytes), options: Options(responseType: ResponseType.bytes),
); );
return _mapResponse(response, format: request.format); return _mapResponse(response, format: request.format);
} }
Map<String, dynamic> _buildQuery(ReportExportRequest request) { Map<String, dynamic> _buildTransactionQuery(ReportExportRequest request) {
return { return {
'from': request.from.toIso8601String(), 'date_from': _formatDateOnly(request.from),
'to': request.to.toIso8601String(), 'date_to': _formatDateOnly(request.to),
'format': request.format.apiValue, 'format': request.format.apiValue,
if (request.transactionStatusId != null) if (request.transactionStatusId != null)
'transaction_status_id': request.transactionStatusId, '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( ReportDownloadResult _mapResponse(
Response<Uint8List> response, { Response<Uint8List> response, {
required ReportExportFormat format, required ReportExportFormat format,

View File

@@ -197,10 +197,9 @@ class _StubApprovalRepository implements ApprovalRepository {
Future<PaginatedResult<Approval>> list({ Future<PaginatedResult<Approval>> list({
int page = 1, int page = 1,
int pageSize = 20, int pageSize = 20,
String? query, int? transactionId,
String? status, int? approvalStatusId,
DateTime? from, int? requestedById,
DateTime? to,
bool includeHistories = false, bool includeHistories = false,
bool includeSteps = false, bool includeSteps = false,
}) async { }) async {

View File

@@ -22,6 +22,52 @@ void main() {
repository = ApprovalRepositoryRemote(apiClient: apiClient); 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({ Map<String, dynamic> buildStep({
required int id, required int id,
required int order, required int order,
@@ -147,4 +193,52 @@ void main() {
expect(result.histories.length, 2); expect(result.histories.length, 2);
expect(result.histories.last.id, 91001); 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');
});
} }

View File

@@ -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_repository.dart';
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_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/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 모킹 클래스. /// ApprovalRepository 모킹 클래스.
class _MockApprovalRepository extends Mock implements ApprovalRepository {} class _MockApprovalRepository extends Mock implements ApprovalRepository {}
@@ -26,6 +28,9 @@ class _MockApprovalTemplateRepository extends Mock
class _FakeStepAssignmentInput extends Fake class _FakeStepAssignmentInput extends Fake
implements ApprovalStepAssignmentInput {} implements ApprovalStepAssignmentInput {}
class _MockInventoryLookupRepository extends Mock
implements InventoryLookupRepository {}
void main() { void main() {
late ApprovalController controller; late ApprovalController controller;
late _MockApprovalRepository repository; late _MockApprovalRepository repository;
@@ -90,10 +95,9 @@ void main() {
() => repository.list( () => repository.list(
page: any(named: 'page'), page: any(named: 'page'),
pageSize: any(named: 'pageSize'), pageSize: any(named: 'pageSize'),
query: any(named: 'query'), transactionId: any(named: 'transactionId'),
status: any(named: 'status'), approvalStatusId: any(named: 'approvalStatusId'),
from: any(named: 'from'), requestedById: any(named: 'requestedById'),
to: any(named: 'to'),
includeHistories: any(named: 'includeHistories'), includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'), includeSteps: any(named: 'includeSteps'),
), ),
@@ -110,11 +114,13 @@ void main() {
// 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다. // 검색어/상태/기간 필터가 Repository 호출에 반영되는지 확인한다.
test('필터 전달을 검증한다', () async { test('필터 전달을 검증한다', () async {
controller.updateQuery('TRX'); controller.updateTransactionFilter(55);
controller.updateStatusFilter(ApprovalStatusFilter.approved); controller.updateStatusFilter(ApprovalStatusFilter.approved);
final from = DateTime(2024, 4, 1); controller.updateRequestedByFilter(
final to = DateTime(2024, 4, 30); id: 77,
controller.updateDateRange(from, to); name: '상신자',
employeeNo: 'EMP077',
);
await controller.fetch(page: 3); await controller.fetch(page: 3);
@@ -122,10 +128,9 @@ void main() {
() => repository.list( () => repository.list(
page: 3, page: 3,
pageSize: 20, pageSize: 20,
query: 'TRX', transactionId: 55,
status: 'approved', approvalStatusId: null,
from: from, requestedById: 77,
to: to,
includeHistories: false, includeHistories: false,
includeSteps: false, includeSteps: false,
), ),
@@ -138,10 +143,9 @@ void main() {
() => repository.list( () => repository.list(
page: any(named: 'page'), page: any(named: 'page'),
pageSize: any(named: 'pageSize'), pageSize: any(named: 'pageSize'),
query: any(named: 'query'), transactionId: any(named: 'transactionId'),
status: any(named: 'status'), approvalStatusId: any(named: 'approvalStatusId'),
from: any(named: 'from'), requestedById: any(named: 'requestedById'),
to: any(named: 'to'),
includeHistories: any(named: 'includeHistories'), includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'), includeSteps: any(named: 'includeSteps'),
), ),
@@ -151,6 +155,46 @@ void main() {
expect(controller.errorMessage, isNotNull); 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', () { group('selectApproval', () {
@@ -304,10 +348,9 @@ void main() {
() => repository.list( () => repository.list(
page: any(named: 'page'), page: any(named: 'page'),
pageSize: any(named: 'pageSize'), pageSize: any(named: 'pageSize'),
query: any(named: 'query'), transactionId: any(named: 'transactionId'),
status: any(named: 'status'), approvalStatusId: any(named: 'approvalStatusId'),
from: any(named: 'from'), requestedById: any(named: 'requestedById'),
to: any(named: 'to'),
includeHistories: any(named: 'includeHistories'), includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'), includeSteps: any(named: 'includeSteps'),
), ),
@@ -471,10 +514,9 @@ void main() {
() => repository.list( () => repository.list(
page: any(named: 'page'), page: any(named: 'page'),
pageSize: any(named: 'pageSize'), pageSize: any(named: 'pageSize'),
query: any(named: 'query'), transactionId: any(named: 'transactionId'),
status: any(named: 'status'), approvalStatusId: any(named: 'approvalStatusId'),
from: any(named: 'from'), requestedById: any(named: 'requestedById'),
to: any(named: 'to'),
includeHistories: any(named: 'includeHistories'), includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'), includeSteps: any(named: 'includeSteps'),
), ),
@@ -571,15 +613,19 @@ void main() {
}); });
test('필터 초기화', () { test('필터 초기화', () {
controller.updateQuery('abc'); controller.updateTransactionFilter(42);
controller.updateStatusFilter(ApprovalStatusFilter.rejected); controller.updateStatusFilter(ApprovalStatusFilter.rejected);
controller.updateDateRange(DateTime(2024, 1, 1), DateTime(2024, 1, 31)); controller.updateRequestedByFilter(
id: 11,
name: '요청자',
employeeNo: 'EMP011',
);
controller.clearFilters(); controller.clearFilters();
expect(controller.query, isEmpty); expect(controller.transactionIdFilter, isNull);
expect(controller.statusFilter, ApprovalStatusFilter.all); expect(controller.statusFilter, ApprovalStatusFilter.all);
expect(controller.fromDate, isNull); expect(controller.requestedById, isNull);
expect(controller.toDate, isNull); expect(controller.requestedByName, isNull);
}); });
} }

View File

@@ -105,10 +105,9 @@ void main() {
() => repository.list( () => repository.list(
page: any(named: 'page'), page: any(named: 'page'),
pageSize: any(named: 'pageSize'), pageSize: any(named: 'pageSize'),
query: any(named: 'query'), transactionId: any(named: 'transactionId'),
status: any(named: 'status'), approvalStatusId: any(named: 'approvalStatusId'),
from: any(named: 'from'), requestedById: any(named: 'requestedById'),
to: any(named: 'to'),
includeHistories: any(named: 'includeHistories'), includeHistories: any(named: 'includeHistories'),
includeSteps: any(named: 'includeSteps'), includeSteps: any(named: 'includeSteps'),
), ),

View File

@@ -61,7 +61,7 @@ void main() {
final query = captured[1] as Map<String, dynamic>; final query = captured[1] as Map<String, dynamic>;
expect(query['page'], 1); expect(query['page'], 1);
expect(query['page_size'], 200); expect(query['page_size'], 200);
expect(query['is_active'], true); expect(query['active'], true);
}); });
test('fetchApprovalActions는 is_active 파라미터 없이 호출한다', () async { test('fetchApprovalActions는 is_active 파라미터 없이 호출한다', () async {
@@ -80,6 +80,6 @@ void main() {
), ),
).captured[1] ).captured[1]
as Map<String, dynamic>; as Map<String, dynamic>;
expect(query.containsKey('is_active'), isFalse); expect(query.containsKey('active'), isFalse);
}); });
} }

View File

@@ -91,8 +91,8 @@ void main() {
expect(query['transaction_status_id'], 7); expect(query['transaction_status_id'], 7);
expect(query['warehouse_id'], 3); expect(query['warehouse_id'], 3);
expect(query['customer_id'], 99); expect(query['customer_id'], 99);
expect(query['from'], '2024-01-01T00:00:00.000'); expect(query['date_from'], '2024-01-01');
expect(query['to'], '2024-01-31T00:00:00.000'); expect(query['date_to'], '2024-01-31');
expect(query['sort'], 'transaction_date'); expect(query['sort'], 'transaction_date');
expect(query['order'], 'desc'); expect(query['order'], 'desc');
expect(query['include'], 'lines,approval'); expect(query['include'], 'lines,approval');

View File

@@ -72,7 +72,7 @@ void main() {
expect(query['q'], 'sup'); expect(query['q'], 'sup');
expect(query['is_partner'], true); expect(query['is_partner'], true);
expect(query['is_general'], false); expect(query['is_general'], false);
expect(query['is_active'], true); expect(query['active'], true);
}); });
test('fetchDetail은 include=zipcode 파라미터를 전달한다', () async { test('fetchDetail은 include=zipcode 파라미터를 전달한다', () async {

View File

@@ -177,7 +177,7 @@ void main() {
await tester.tap(find.text('등록')); await tester.tap(find.text('등록'));
await tester.pump(); await tester.pump();
expect(find.text('우편번호 검색으로 주소를 선택하세요.'), findsOneWidget); expect(find.text('검색 버튼을 눌러 주소를 선택하세요.'), findsOneWidget);
}); });
testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async { testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async {
@@ -224,6 +224,7 @@ void main() {
id: 2, id: 2,
customerCode: capturedInput!.customerCode, customerCode: capturedInput!.customerCode,
customerName: capturedInput!.customerName, customerName: capturedInput!.customerName,
contactName: capturedInput!.contactName,
isPartner: capturedInput!.isPartner, isPartner: capturedInput!.isPartner,
isGeneral: capturedInput!.isGeneral, isGeneral: capturedInput!.isGeneral,
); );
@@ -245,8 +246,9 @@ void main() {
await tester.enterText(editableTexts.at(0), 'C-100'); await tester.enterText(editableTexts.at(0), 'C-100');
await tester.enterText(editableTexts.at(1), '신규 고객'); await tester.enterText(editableTexts.at(1), '신규 고객');
await tester.enterText(editableTexts.at(2), 'new@superport.com'); await tester.enterText(editableTexts.at(2), '홍길동');
await tester.enterText(editableTexts.at(3), '02-0000-0000'); await tester.enterText(editableTexts.at(3), 'new@superport.com');
await tester.enterText(editableTexts.at(4), '02-0000-0000');
// 유형 체크박스: 기본값 partner=false, general=true. partner on 추가 // 유형 체크박스: 기본값 partner=false, general=true. partner on 추가
await tester.tap(find.text('파트너')); await tester.tap(find.text('파트너'));
@@ -257,6 +259,7 @@ void main() {
expect(capturedInput, isNotNull); expect(capturedInput, isNotNull);
expect(capturedInput?.customerCode, 'C-100'); expect(capturedInput?.customerCode, 'C-100');
expect(capturedInput?.contactName, '홍길동');
expect(find.byType(Dialog), findsNothing); expect(find.byType(Dialog), findsNothing);
expect(find.text('C-100'), findsOneWidget); expect(find.text('C-100'), findsOneWidget);
verify(() => repository.create(any())).called(1); verify(() => repository.create(any())).called(1);

View File

@@ -78,7 +78,7 @@ void main() {
expect(query['include'], 'vendor,uom'); expect(query['include'], 'vendor,uom');
expect(query['vendor_id'], 10); expect(query['vendor_id'], 10);
expect(query['uom_id'], 5); expect(query['uom_id'], 5);
expect(query['is_active'], false); expect(query['active'], false);
expect(query['page'], 3); expect(query['page'], 3);
expect(query['page_size'], 40); expect(query['page_size'], 40);
expect(query['q'], 'gear'); expect(query['q'], 'gear');

View File

@@ -295,7 +295,7 @@ void main() {
await tester.tap(find.text('제조사를 선택하세요')); await tester.tap(find.text('제조사를 선택하세요'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('슈퍼벤더')); await tester.tap(find.text('슈퍼벤더 (V-001)'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('단위를 선택하세요')); await tester.tap(find.text('단위를 선택하세요'));

View File

@@ -98,8 +98,8 @@ void main() {
expect(captured.first, equals(path)); expect(captured.first, equals(path));
final query = captured[1] as Map<String, dynamic>; final query = captured[1] as Map<String, dynamic>;
expect(query['from'], request.from.toIso8601String()); expect(query['date_from'], '2024-01-01');
expect(query['to'], request.to.toIso8601String()); expect(query['date_to'], '2024-01-31');
expect(query['format'], 'xlsx'); expect(query['format'], 'xlsx');
expect(query['transaction_status_id'], 3); expect(query['transaction_status_id'], 3);
expect(query['approval_status_id'], 7); expect(query['approval_status_id'], 7);