fix(inventory): 파트너 연동 및 상세 모달 동작 안정화

- 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출

- 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정

- 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장

- 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화

- 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일

- 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속)
This commit is contained in:
JiWoong Sul
2025-10-27 20:02:21 +09:00
parent 14624c4165
commit 259b056072
14 changed files with 1054 additions and 155 deletions

View File

@@ -0,0 +1,411 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/utils/pagination_utils.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart';
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
/// 파트너사를 나타내는 선택 옵션 모델.
class InventoryPartnerOption {
InventoryPartnerOption({
required this.id,
required this.name,
required this.code,
});
factory InventoryPartnerOption.fromCustomer(Customer customer) {
return InventoryPartnerOption(
id: customer.id ?? 0,
name: customer.customerName,
code: customer.customerCode,
);
}
final int id;
final String name;
final String code;
String get label => code.isEmpty ? name : '$name ($code)';
}
/// 파트너사 자동완성 입력 필드.
class InventoryPartnerSelectField extends StatefulWidget {
const InventoryPartnerSelectField({
super.key,
this.initialPartner,
required this.onChanged,
this.enabled = true,
this.placeholder,
});
final InventoryPartnerOption? initialPartner;
final ValueChanged<InventoryPartnerOption?> onChanged;
final bool enabled;
final Widget? placeholder;
@override
State<InventoryPartnerSelectField> createState() =>
_InventoryPartnerSelectFieldState();
}
class _InventoryPartnerSelectFieldState
extends State<InventoryPartnerSelectField> {
static const Duration _debounceDuration = Duration(milliseconds: 280);
CustomerRepository? get _repository =>
GetIt.I.isRegistered<CustomerRepository>()
? GetIt.I<CustomerRepository>()
: null;
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
final List<InventoryPartnerOption> _baseOptions = [];
final List<InventoryPartnerOption> _suggestions = [];
InventoryPartnerOption? _selected;
Timer? _debounce;
bool _isSearching = false;
bool _isLoadingInitial = false;
String? _error;
int _requestId = 0;
bool _isApplyingText = false;
@override
void initState() {
super.initState();
_selected = widget.initialPartner;
_controller.addListener(_handleTextChanged);
_focusNode.addListener(_handleFocusChange);
_loadInitialOptions();
}
@override
void didUpdateWidget(covariant InventoryPartnerSelectField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialPartner != oldWidget.initialPartner) {
if (widget.initialPartner != null) {
_setSelection(widget.initialPartner!, notify: false);
} else {
_clearSelection(notify: false);
}
}
}
@override
void dispose() {
_controller
..removeListener(_handleTextChanged)
..dispose();
_focusNode
..removeListener(_handleFocusChange)
..dispose();
_debounce?.cancel();
super.dispose();
}
Future<void> _loadInitialOptions() async {
final repository = _repository;
if (repository == null) {
setState(() {
_error = '파트너 데이터를 불러올 수 없습니다.';
});
return;
}
setState(() {
_isLoadingInitial = true;
_error = null;
});
try {
final partners = await fetchAllPaginatedItems<Customer>(
request: (page, pageSize) => repository.list(
page: page,
pageSize: pageSize,
isPartner: true,
isActive: true,
),
pageSize: 40,
);
if (!mounted) {
return;
}
final options = partners
.where((customer) => customer.id != null)
.map(InventoryPartnerOption.fromCustomer)
.toList(growable: false);
setState(() {
_baseOptions
..clear()
..addAll(options);
_suggestions
..clear()
..addAll(options);
_isLoadingInitial = false;
});
final initial = widget.initialPartner;
if (initial != null) {
_setSelection(initial, notify: false);
}
} catch (error) {
if (!mounted) {
return;
}
final failure = Failure.from(error);
setState(() {
_error = failure.describe();
_isLoadingInitial = false;
});
}
}
void _handleTextChanged() {
if (_isApplyingText) {
return;
}
final keyword = _controller.text.trim();
if (keyword.isEmpty) {
_debounce?.cancel();
setState(() {
_isSearching = false;
_suggestions
..clear()
..addAll(_baseOptions);
});
return;
}
if (_selected != null && keyword != _selected!.label) {
_selected = null;
widget.onChanged(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) {
return;
}
final request = ++_requestId;
setState(() {
_isSearching = true;
});
try {
final result = await repository.list(
page: 1,
pageSize: 20,
query: keyword,
isPartner: true,
isActive: true,
);
if (!mounted || request != _requestId) {
return;
}
final items = result.items
.where((customer) => customer.id != null)
.map(InventoryPartnerOption.fromCustomer)
.toList(growable: false);
setState(() {
_suggestions
..clear()
..addAll(items);
_isSearching = false;
});
} catch (_) {
if (!mounted || request != _requestId) {
return;
}
setState(() {
_suggestions
..clear()
..addAll(_baseOptions);
_isSearching = false;
});
}
}
void _setSelection(InventoryPartnerOption option, {bool notify = true}) {
setState(() {
_selected = option;
if (!_baseOptions.any((item) => item.id == option.id)) {
_baseOptions.add(option);
}
_applyControllerText(option.label);
});
if (notify) {
widget.onChanged(option);
}
}
void _clearSelection({bool notify = true}) {
setState(() {
_selected = null;
_applyControllerText('');
_suggestions
..clear()
..addAll(_baseOptions);
});
if (notify) {
widget.onChanged(null);
}
}
void _applyControllerText(String text) {
_isApplyingText = true;
_controller
..text = text
..selection = TextSelection.collapsed(offset: text.length);
_isApplyingText = false;
}
void _handleFocusChange() {
if (!_focusNode.hasFocus && _selected != null) {
_applyControllerText(_selected!.label);
}
}
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 (_isLoadingInitial) {
return _buildLoadingInput();
}
if (_error != null) {
return ShadInput(
readOnly: true,
enabled: false,
placeholder: Text(_error!),
);
}
return RawAutocomplete<InventoryPartnerOption>(
textEditingController: _controller,
focusNode: _focusNode,
optionsBuilder: (value) {
final keyword = value.text.trim();
if (keyword.isEmpty) {
return _suggestions.isEmpty ? _baseOptions : _suggestions;
}
final lower = keyword.toLowerCase();
final source = _suggestions.isEmpty ? _baseOptions : _suggestions;
return source.where((option) {
return option.name.toLowerCase().contains(lower) ||
option.code.toLowerCase().contains(lower);
});
},
displayStringForOption: (option) => option.label,
onSelected: (option) {
_setSelection(option);
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
assert(identical(controller, _controller));
final placeholder = widget.placeholder ?? const Text('파트너사를 선택하세요');
final input = ShadInput(
controller: controller,
focusNode: focusNode,
enabled: widget.enabled,
readOnly: !widget.enabled,
placeholder: placeholder,
onSubmitted: (_) => onSubmitted(),
);
if (!_isSearching) {
return input;
}
return Stack(
alignment: Alignment.centerRight,
children: [
input,
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: 220, minWidth: 260),
child: ShadCard(
padding: const EdgeInsets.symmetric(vertical: 16),
child: const Center(child: Text('검색 결과가 없습니다.')),
),
),
);
}
return Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 260, minWidth: 260),
child: ShadCard(
padding: EdgeInsets.zero,
child: ListView.builder(
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => onSelected(option),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.name,
style: ShadTheme.of(context).textTheme.p,
),
if (option.code.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
option.code,
style: ShadTheme.of(
context,
).textTheme.muted.copyWith(fontSize: 12),
),
],
],
),
),
);
},
),
),
),
);
},
);
}
}

View File

@@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/common/utils/pagination_utils.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
@@ -65,12 +68,17 @@ class _InventoryProductAutocompleteFieldState
extends State<InventoryProductAutocompleteField> {
static const Duration _debounceDuration = Duration(milliseconds: 280);
final List<InventoryProductSuggestion> _initialOptions =
<InventoryProductSuggestion>[];
final List<InventoryProductSuggestion> _suggestions =
<InventoryProductSuggestion>[];
InventoryProductSuggestion? _selected;
Timer? _debounce;
int _requestCounter = 0;
bool _isSearching = false;
bool _isLoadingInitial = false;
String? _error;
bool _isApplyingText = false;
ProductRepository? get _repository =>
GetIt.I.isRegistered<ProductRepository>()
@@ -82,13 +90,10 @@ class _InventoryProductAutocompleteFieldState
super.initState();
_selected = widget.initialSuggestion;
if (_selected != null) {
_applySuggestion(_selected!, updateProductField: false);
_applySuggestionSilently(_selected!);
}
widget.productController.addListener(_handleTextChanged);
if (widget.productController.text.trim().isNotEmpty &&
widget.initialSuggestion == null) {
_scheduleSearch(widget.productController.text.trim());
}
_loadInitialOptions();
}
@override
@@ -99,20 +104,108 @@ class _InventoryProductAutocompleteFieldState
widget.productController.addListener(_handleTextChanged);
_scheduleSearch(widget.productController.text.trim());
}
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
widget.initialSuggestion != null) {
_selected = widget.initialSuggestion;
_applySuggestion(widget.initialSuggestion!, updateProductField: false);
if (widget.initialSuggestion != oldWidget.initialSuggestion) {
if (widget.initialSuggestion != null) {
_selected = widget.initialSuggestion;
_applySuggestionSilently(widget.initialSuggestion!);
} else {
_selected = null;
}
}
}
Future<void> _loadInitialOptions() async {
final repository = _repository;
if (repository == null) {
setState(() {
_error = '제품 데이터 소스를 찾을 수 없습니다.';
_suggestions.clear();
_initialOptions.clear();
});
return;
}
setState(() {
_isLoadingInitial = true;
_error = null;
});
try {
final products = await fetchAllPaginatedItems<Product>(
request: (page, pageSize) =>
repository.list(page: page, pageSize: pageSize, isActive: true),
);
if (!mounted) {
return;
}
final options = products
.map(InventoryProductSuggestion.fromProduct)
.toList(growable: false);
setState(() {
_initialOptions
..clear()
..addAll(options);
if (_selected != null &&
!_initialOptions.any((item) => item.id == _selected!.id)) {
_initialOptions.add(_selected!);
}
_suggestions
..clear()
..addAll(_initialOptions);
_isLoadingInitial = false;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
final failure = Failure.from(error);
_error = failure.describe();
_initialOptions.clear();
_suggestions.clear();
_isLoadingInitial = false;
});
}
}
void _cacheSuggestion(InventoryProductSuggestion suggestion) {
if (!_initialOptions.any((item) => item.id == suggestion.id)) {
_initialOptions.add(suggestion);
}
if (!_suggestions.any((item) => item.id == suggestion.id)) {
_suggestions.add(suggestion);
}
}
void _syncExternalControllers(InventoryProductSuggestion suggestion) {
if (widget.manufacturerController.text != suggestion.vendorName) {
widget.manufacturerController.text = suggestion.vendorName;
}
if (widget.unitController.text != suggestion.unitName) {
widget.unitController.text = suggestion.unitName;
}
}
void _applySuggestionSilently(InventoryProductSuggestion suggestion) {
_selected = suggestion;
_cacheSuggestion(suggestion);
_updateProductFieldValue(suggestion.name);
_syncExternalControllers(suggestion);
}
/// 입력 변화에 따라 제안 검색을 예약한다.
void _handleTextChanged() {
if (_isApplyingText) {
return;
}
final text = widget.productController.text.trim();
if (text.isEmpty) {
_debounce?.cancel();
_clearSelection();
return;
}
if (_selected != null && text != _selected!.name) {
_selected = null;
widget.onSuggestionSelected(null);
}
_scheduleSearch(text);
widget.onChanged?.call();
}
@@ -148,10 +241,18 @@ class _InventoryProductAutocompleteFieldState
if (!mounted || currentRequest != _requestCounter) {
return;
}
final fetched = result.items
.map(InventoryProductSuggestion.fromProduct)
.toList(growable: false);
setState(() {
for (final option in fetched) {
if (!_initialOptions.any((item) => item.id == option.id)) {
_initialOptions.add(option);
}
}
_suggestions
..clear()
..addAll(result.items.map(InventoryProductSuggestion.fromProduct));
..addAll(fetched);
_isSearching = false;
});
} catch (_) {
@@ -159,7 +260,9 @@ class _InventoryProductAutocompleteFieldState
return;
}
setState(() {
_suggestions.clear();
_suggestions
..clear()
..addAll(_initialOptions);
_isSearching = false;
});
}
@@ -169,43 +272,73 @@ class _InventoryProductAutocompleteFieldState
void _applySuggestion(
InventoryProductSuggestion suggestion, {
bool updateProductField = true,
bool notify = true,
}) {
_debounce?.cancel();
setState(() {
_selected = suggestion;
_cacheSuggestion(suggestion);
});
widget.onSuggestionSelected(suggestion);
if (notify) {
widget.onSuggestionSelected(suggestion);
}
if (updateProductField) {
widget.productController.text = suggestion.name;
widget.productController.selection = TextSelection.collapsed(
offset: widget.productController.text.length,
_updateProductFieldValue(suggestion.name);
}
_syncExternalControllers(suggestion);
if (kDebugMode) {
debugPrint(
'[InventoryProductAutocomplete] selected "${suggestion.name}" '
'-> text="${widget.productController.text}"',
);
}
if (widget.manufacturerController.text != suggestion.vendorName) {
widget.manufacturerController.text = suggestion.vendorName;
}
if (widget.unitController.text != suggestion.unitName) {
widget.unitController.text = suggestion.unitName;
}
widget.onChanged?.call();
}
void _clearSelection() {
void _clearSelection({bool notify = true}) {
if (_selected == null &&
widget.manufacturerController.text.isEmpty &&
widget.unitController.text.isEmpty) {
widget.unitController.text.isEmpty &&
widget.productController.text.isEmpty) {
setState(() {
_suggestions
..clear()
..addAll(_initialOptions);
_isSearching = false;
});
return;
}
_debounce?.cancel();
setState(() {
_selected = null;
_suggestions.clear();
_suggestions
..clear()
..addAll(_initialOptions);
_isSearching = false;
});
widget.onSuggestionSelected(null);
if (notify) {
widget.onSuggestionSelected(null);
}
widget.manufacturerController.clear();
widget.unitController.clear();
if (widget.productController.text.isEmpty) {
_updateProductFieldValue('');
}
widget.onChanged?.call();
}
/// 제품 입력 필드에 텍스트와 커서를 안전하게 반영한다.
void _updateProductFieldValue(String text) {
final controller = widget.productController;
_isApplyingText = true;
controller.value = controller.value.copyWith(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange.empty,
);
_isApplyingText = false;
}
@override
void dispose() {
widget.productController.removeListener(_handleTextChanged);
@@ -213,19 +346,55 @@ class _InventoryProductAutocompleteFieldState
super.dispose();
}
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) {
final theme = ShadTheme.of(context);
if (_isLoadingInitial) {
return _buildLoadingInput();
}
if (_error != null) {
return ShadInput(
controller: widget.productController,
focusNode: widget.productFocusNode,
enabled: false,
readOnly: true,
placeholder: Text(_error!),
);
}
return LayoutBuilder(
builder: (context, constraints) {
return RawAutocomplete<InventoryProductSuggestion>(
textEditingController: widget.productController,
focusNode: widget.productFocusNode,
optionsBuilder: (textEditingValue) {
if (textEditingValue.text.trim().isEmpty) {
return const Iterable<InventoryProductSuggestion>.empty();
final keyword = textEditingValue.text.trim();
final base = _suggestions.isEmpty ? _initialOptions : _suggestions;
if (keyword.isEmpty) {
return base;
}
return _suggestions;
final lowerKeyword = keyword.toLowerCase();
return base.where((option) {
return option.name.toLowerCase().contains(lowerKeyword) ||
option.code.toLowerCase().contains(lowerKeyword) ||
option.vendorName.toLowerCase().contains(lowerKeyword);
});
},
displayStringForOption: (option) => option.name,
onSelected: (option) {
@@ -239,10 +408,6 @@ class _InventoryProductAutocompleteFieldState
placeholder: const Text('제품명 검색'),
onChanged: (_) => widget.onChanged?.call(),
onSubmitted: (_) => onFieldSubmitted(),
onPressedOutside: (event) {
// 포커스를 유지해 항목 선택 전 오버레이가 닫히지 않도록 한다.
focusNode.requestFocus();
},
);
if (!_isSearching) {
return input;
@@ -323,8 +488,9 @@ class _InventoryProductAutocompleteFieldState
itemCount: options.length,
itemBuilder: (context, index) {
final option = options.elementAt(index);
return InkWell(
onTap: () => onSelected(option),
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => onSelected(option),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,