refactor: 인벤토리 테이블 스펙과 도메인 계층 정비
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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/features/masters/customer/domain/entities/customer.dart';
|
||||
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
|
||||
|
||||
/// 고객 검색 결과 모델.
|
||||
class InventoryCustomerOption {
|
||||
InventoryCustomerOption({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.industry,
|
||||
required this.region,
|
||||
});
|
||||
|
||||
factory InventoryCustomerOption.fromCustomer(Customer customer) {
|
||||
return InventoryCustomerOption(
|
||||
id: customer.id ?? 0,
|
||||
code: customer.customerCode,
|
||||
name: customer.customerName,
|
||||
industry: customer.note ?? '-',
|
||||
region: customer.zipcode?.sido ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final String industry;
|
||||
final String region;
|
||||
|
||||
@override
|
||||
String toString() => '$name ($code)';
|
||||
}
|
||||
|
||||
/// 여러 고객을 원격 검색으로 선택하는 필드.
|
||||
class InventoryCustomerMultiSelectField extends StatefulWidget {
|
||||
const InventoryCustomerMultiSelectField({
|
||||
super.key,
|
||||
this.initialCustomerIds = const <int>{},
|
||||
required this.onChanged,
|
||||
this.enabled = true,
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
final Set<int> initialCustomerIds;
|
||||
final ValueChanged<List<InventoryCustomerOption>> onChanged;
|
||||
final bool enabled;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<InventoryCustomerMultiSelectField> createState() =>
|
||||
_InventoryCustomerMultiSelectFieldState();
|
||||
}
|
||||
|
||||
class _InventoryCustomerMultiSelectFieldState
|
||||
extends State<InventoryCustomerMultiSelectField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 300);
|
||||
|
||||
final ShadSelectController<int> _controller = ShadSelectController<int>(
|
||||
initialValue: <int>{},
|
||||
);
|
||||
final Map<int, InventoryCustomerOption> _selectedOptions = {};
|
||||
final List<InventoryCustomerOption> _suggestions = [];
|
||||
Timer? _debounce;
|
||||
int _requestId = 0;
|
||||
bool _isSearching = false;
|
||||
String _searchKeyword = '';
|
||||
|
||||
CustomerRepository? get _repository =>
|
||||
GetIt.I.isRegistered<CustomerRepository>()
|
||||
? GetIt.I<CustomerRepository>()
|
||||
: null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialCustomerIds.isNotEmpty) {
|
||||
_controller.value = widget.initialCustomerIds.toSet();
|
||||
_prefetchInitialOptions(widget.initialCustomerIds);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prefetchInitialOptions(Set<int> ids) async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
for (final id in ids) {
|
||||
try {
|
||||
final detail = await repository.fetchDetail(id, includeZipcode: false);
|
||||
_selectedOptions[id] = InventoryCustomerOption.fromCustomer(detail);
|
||||
} catch (_) {
|
||||
// 무시하고 다음 ID로 진행한다.
|
||||
}
|
||||
}
|
||||
_notifySelection();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
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.isEmpty ? null : keyword,
|
||||
isActive: true,
|
||||
);
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(result.items.map(InventoryCustomerOption.fromCustomer));
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _notifySelection() {
|
||||
widget.onChanged(_selectedOptions.values.toList(growable: false));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholder = widget.placeholder ?? const Text('고객사 선택');
|
||||
final selector = ShadSelect<int>.multipleWithSearch(
|
||||
controller: _controller,
|
||||
placeholder: placeholder,
|
||||
searchPlaceholder: const Text('고객사 이름 또는 코드 검색'),
|
||||
searchInputLeading: const Icon(LucideIcons.search, size: 16),
|
||||
clearSearchOnClose: false,
|
||||
closeOnSelect: false,
|
||||
onChanged: (values) {
|
||||
final sanitized = values.where((value) => value >= 0).toSet();
|
||||
if (!setEquals(sanitized, values)) {
|
||||
_controller.value = sanitized;
|
||||
}
|
||||
// 선택된 값이 deselect 되었을 때 map에서 제거한다.
|
||||
_selectedOptions.removeWhere((key, _) => !sanitized.contains(key));
|
||||
for (final value in sanitized) {
|
||||
final existing = _selectedOptions[value];
|
||||
if (existing != null) {
|
||||
continue;
|
||||
}
|
||||
final match = _suggestions.firstWhere(
|
||||
(option) => option.id == value,
|
||||
orElse: () => InventoryCustomerOption(
|
||||
id: value,
|
||||
code: 'ID-$value',
|
||||
name: '고객 $value',
|
||||
industry: '-',
|
||||
region: '-',
|
||||
),
|
||||
);
|
||||
_selectedOptions[value] = match;
|
||||
}
|
||||
_notifySelection();
|
||||
setState(() {});
|
||||
},
|
||||
onSearchChanged: (keyword) {
|
||||
setState(() {
|
||||
_searchKeyword = keyword;
|
||||
});
|
||||
_scheduleSearch(keyword);
|
||||
},
|
||||
selectedOptionsBuilder: (context, values) {
|
||||
if (values.isEmpty) {
|
||||
return const Text('선택된 고객사가 없습니다');
|
||||
}
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final value in values)
|
||||
ShadBadge(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text(_selectedOptions[value]?.name ?? '고객 $value'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
options: [
|
||||
if (_isSearching)
|
||||
const ShadOption(
|
||||
value: -1,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('검색 중...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final option in _suggestions)
|
||||
ShadOption(
|
||||
value: option.id,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(option.name),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${option.code} · ${option.industry} · ${option.region}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_isSearching && _suggestions.isEmpty && _searchKeyword.isNotEmpty)
|
||||
const ShadOption(value: -2, child: Text('검색 결과가 없습니다.')),
|
||||
],
|
||||
);
|
||||
if (!widget.enabled) {
|
||||
return Opacity(
|
||||
opacity: 0.6,
|
||||
child: IgnorePointer(ignoring: true, child: selector),
|
||||
);
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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/features/masters/user/domain/entities/user.dart';
|
||||
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
|
||||
|
||||
/// 작성자(직원) 검색 결과 모델.
|
||||
class InventoryEmployeeSuggestion {
|
||||
InventoryEmployeeSuggestion({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory InventoryEmployeeSuggestion.fromUser(UserAccount account) {
|
||||
return InventoryEmployeeSuggestion(
|
||||
id: account.id ?? 0,
|
||||
employeeNo: account.employeeNo,
|
||||
name: account.employeeName,
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 작성자를 자동완성으로 선택하는 입력 필드.
|
||||
class InventoryEmployeeAutocompleteField extends StatefulWidget {
|
||||
const InventoryEmployeeAutocompleteField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.initialSuggestion,
|
||||
required this.onSuggestionSelected,
|
||||
this.onChanged,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final InventoryEmployeeSuggestion? initialSuggestion;
|
||||
final ValueChanged<InventoryEmployeeSuggestion?> onSuggestionSelected;
|
||||
final VoidCallback? onChanged;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<InventoryEmployeeAutocompleteField> createState() =>
|
||||
_InventoryEmployeeAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _InventoryEmployeeAutocompleteFieldState
|
||||
extends State<InventoryEmployeeAutocompleteField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
final List<InventoryEmployeeSuggestion> _suggestions = [];
|
||||
InventoryEmployeeSuggestion? _selected;
|
||||
Timer? _debounce;
|
||||
int _requestId = 0;
|
||||
bool _isSearching = false;
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
UserRepository? get _repository =>
|
||||
GetIt.I.isRegistered<UserRepository>() ? GetIt.I<UserRepository>() : null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = widget.initialSuggestion;
|
||||
_focusNode = FocusNode();
|
||||
widget.controller.addListener(_handleChanged);
|
||||
if (_selected != null && widget.controller.text.isEmpty) {
|
||||
widget.controller.text = _displayLabel(_selected!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InventoryEmployeeAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.controller, widget.controller)) {
|
||||
oldWidget.controller.removeListener(_handleChanged);
|
||||
widget.controller.addListener(_handleChanged);
|
||||
}
|
||||
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
|
||||
widget.initialSuggestion != null) {
|
||||
_selected = widget.initialSuggestion;
|
||||
widget.controller.text = _displayLabel(widget.initialSuggestion!);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleChanged() {
|
||||
final text = widget.controller.text.trim();
|
||||
if (text.isEmpty) {
|
||||
_clearSelection();
|
||||
return;
|
||||
}
|
||||
_scheduleSearch(text);
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
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: 15,
|
||||
query: keyword,
|
||||
isActive: true,
|
||||
);
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(result.items.map(InventoryEmployeeSuggestion.fromUser));
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applySuggestion(InventoryEmployeeSuggestion suggestion) {
|
||||
setState(() {
|
||||
_selected = suggestion;
|
||||
});
|
||||
widget.controller.text = _displayLabel(suggestion);
|
||||
widget.onSuggestionSelected(suggestion);
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
if (_selected == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
widget.onSuggestionSelected(null);
|
||||
widget.controller.clear();
|
||||
}
|
||||
|
||||
String _displayLabel(InventoryEmployeeSuggestion suggestion) {
|
||||
return '${suggestion.name} (${suggestion.employeeNo})';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_handleChanged);
|
||||
_debounce?.cancel();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return RawAutocomplete<InventoryEmployeeSuggestion>(
|
||||
textEditingController: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
if (textEditingValue.text.trim().isEmpty) {
|
||||
return const Iterable<InventoryEmployeeSuggestion>.empty();
|
||||
}
|
||||
return _suggestions;
|
||||
},
|
||||
displayStringForOption: _displayLabel,
|
||||
onSelected: _applySuggestion,
|
||||
fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) {
|
||||
final input = ShadInput(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
enabled: widget.enabled,
|
||||
placeholder: const Text('작성자 이름 또는 사번 검색'),
|
||||
onChanged: (_) => widget.onChanged?.call(),
|
||||
onSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
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: AlignmentDirectional.topStart,
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text('일치하는 직원이 없습니다.', style: theme.textTheme.muted),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240, maxWidth: 360),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(suggestion),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(suggestion.name, style: theme.textTheme.p),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'ID ${suggestion.id} · ${suggestion.employeeNo}',
|
||||
style: theme.textTheme.muted.copyWith(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,40 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../catalogs.dart';
|
||||
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
|
||||
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
|
||||
|
||||
/// 제품명 입력 시 카탈로그 자동완성을 제공하는 필드.
|
||||
/// 제품 검색 결과를 표현하는 제안 모델.
|
||||
class InventoryProductSuggestion {
|
||||
InventoryProductSuggestion({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.vendorName,
|
||||
required this.unitName,
|
||||
});
|
||||
|
||||
factory InventoryProductSuggestion.fromProduct(Product product) {
|
||||
return InventoryProductSuggestion(
|
||||
id: product.id ?? 0,
|
||||
code: product.productCode,
|
||||
name: product.productName,
|
||||
vendorName: product.vendor?.vendorName ?? '-',
|
||||
unitName: product.uom?.uomName ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final String vendorName;
|
||||
final String unitName;
|
||||
}
|
||||
|
||||
/// 제품명 자동완성을 제공하는 입력 필드.
|
||||
class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
const InventoryProductAutocompleteField({
|
||||
super.key,
|
||||
@@ -11,7 +42,8 @@ class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
required this.productFocusNode,
|
||||
required this.manufacturerController,
|
||||
required this.unitController,
|
||||
required this.onCatalogMatched,
|
||||
required this.onSuggestionSelected,
|
||||
this.initialSuggestion,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@@ -19,7 +51,8 @@ class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
final FocusNode productFocusNode;
|
||||
final TextEditingController manufacturerController;
|
||||
final TextEditingController unitController;
|
||||
final ValueChanged<InventoryProductCatalogItem?> onCatalogMatched;
|
||||
final ValueChanged<InventoryProductSuggestion?> onSuggestionSelected;
|
||||
final InventoryProductSuggestion? initialSuggestion;
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
@override
|
||||
@@ -27,21 +60,35 @@ class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
_InventoryProductAutocompleteFieldState();
|
||||
}
|
||||
|
||||
/// 자동완성 로직을 구현한 상태 클래스.
|
||||
/// 제품 자동완성 위젯의 내부 상태를 관리한다.
|
||||
class _InventoryProductAutocompleteFieldState
|
||||
extends State<InventoryProductAutocompleteField> {
|
||||
InventoryProductCatalogItem? _catalogMatch;
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 280);
|
||||
|
||||
final List<InventoryProductSuggestion> _suggestions =
|
||||
<InventoryProductSuggestion>[];
|
||||
InventoryProductSuggestion? _selected;
|
||||
Timer? _debounce;
|
||||
int _requestCounter = 0;
|
||||
bool _isSearching = false;
|
||||
|
||||
ProductRepository? get _repository =>
|
||||
GetIt.I.isRegistered<ProductRepository>()
|
||||
? GetIt.I<ProductRepository>()
|
||||
: null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
_selected = widget.initialSuggestion;
|
||||
if (_selected != null) {
|
||||
_applySuggestion(_selected!, updateProductField: false);
|
||||
}
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
if (widget.productController.text.trim().isNotEmpty &&
|
||||
widget.initialSuggestion == null) {
|
||||
_scheduleSearch(widget.productController.text.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -50,70 +97,119 @@ class _InventoryProductAutocompleteFieldState
|
||||
if (!identical(oldWidget.productController, widget.productController)) {
|
||||
oldWidget.productController.removeListener(_handleTextChanged);
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
_scheduleSearch(widget.productController.text.trim());
|
||||
}
|
||||
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
|
||||
widget.initialSuggestion != null) {
|
||||
_selected = widget.initialSuggestion;
|
||||
_applySuggestion(widget.initialSuggestion!, updateProductField: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 텍스트 입력 변화를 감지해 자동완성 결과를 적용한다.
|
||||
/// 입력 변화에 따라 제안 검색을 예약한다.
|
||||
void _handleTextChanged() {
|
||||
final text = widget.productController.text.trim();
|
||||
final match = InventoryProductCatalog.match(text);
|
||||
if (match != null) {
|
||||
_applyCatalog(match);
|
||||
if (text.isEmpty) {
|
||||
_clearSelection();
|
||||
return;
|
||||
}
|
||||
if (_catalogMatch != null) {
|
||||
setState(() {
|
||||
_catalogMatch = null;
|
||||
});
|
||||
widget.onCatalogMatched(null);
|
||||
if (widget.manufacturerController.text.isNotEmpty) {
|
||||
widget.manufacturerController.clear();
|
||||
}
|
||||
if (widget.unitController.text.isNotEmpty) {
|
||||
widget.unitController.clear();
|
||||
}
|
||||
}
|
||||
_scheduleSearch(text);
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
/// 선택된 카탈로그 정보를 관련 필드에 적용한다.
|
||||
void _applyCatalog(
|
||||
InventoryProductCatalogItem match, {
|
||||
bool updateProduct = true,
|
||||
void _scheduleSearch(String keyword) {
|
||||
_debounce?.cancel();
|
||||
if (keyword.isEmpty) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_suggestions.clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
_debounce = Timer(_debounceDuration, () => _fetchSuggestions(keyword));
|
||||
}
|
||||
|
||||
Future<void> _fetchSuggestions(String keyword) async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
final currentRequest = ++_requestCounter;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
query: keyword,
|
||||
isActive: true,
|
||||
);
|
||||
if (!mounted || currentRequest != _requestCounter) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(result.items.map(InventoryProductSuggestion.fromProduct));
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted || currentRequest != _requestCounter) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 선택된 제안 정보를 외부 필드에 반영한다.
|
||||
void _applySuggestion(
|
||||
InventoryProductSuggestion suggestion, {
|
||||
bool updateProductField = true,
|
||||
}) {
|
||||
setState(() {
|
||||
_catalogMatch = match;
|
||||
_selected = suggestion;
|
||||
});
|
||||
widget.onCatalogMatched(match);
|
||||
if (updateProduct && widget.productController.text != match.name) {
|
||||
widget.productController.text = match.name;
|
||||
widget.onSuggestionSelected(suggestion);
|
||||
if (updateProductField) {
|
||||
widget.productController.text = suggestion.name;
|
||||
widget.productController.selection = TextSelection.collapsed(
|
||||
offset: widget.productController.text.length,
|
||||
);
|
||||
}
|
||||
if (widget.manufacturerController.text != match.manufacturer) {
|
||||
widget.manufacturerController.text = match.manufacturer;
|
||||
if (widget.manufacturerController.text != suggestion.vendorName) {
|
||||
widget.manufacturerController.text = suggestion.vendorName;
|
||||
}
|
||||
if (widget.unitController.text != match.unit) {
|
||||
widget.unitController.text = match.unit;
|
||||
if (widget.unitController.text != suggestion.unitName) {
|
||||
widget.unitController.text = suggestion.unitName;
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
/// 주어진 검색어에 매칭되는 제품 목록을 반환한다.
|
||||
Iterable<InventoryProductCatalogItem> _options(String query) {
|
||||
return InventoryProductCatalog.filter(query);
|
||||
void _clearSelection() {
|
||||
if (_selected == null &&
|
||||
widget.manufacturerController.text.isEmpty &&
|
||||
widget.unitController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
widget.onSuggestionSelected(null);
|
||||
widget.manufacturerController.clear();
|
||||
widget.unitController.clear();
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.productController.removeListener(_handleTextChanged);
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -122,25 +218,45 @@ class _InventoryProductAutocompleteFieldState
|
||||
final theme = ShadTheme.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<InventoryProductCatalogItem>(
|
||||
return RawAutocomplete<InventoryProductSuggestion>(
|
||||
textEditingController: widget.productController,
|
||||
focusNode: widget.productFocusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
return _options(textEditingValue.text);
|
||||
if (textEditingValue.text.trim().isEmpty) {
|
||||
return const Iterable<InventoryProductSuggestion>.empty();
|
||||
}
|
||||
return _suggestions;
|
||||
},
|
||||
displayStringForOption: (option) => option.name,
|
||||
onSelected: (option) {
|
||||
_applyCatalog(option);
|
||||
_applySuggestion(option);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return ShadInput(
|
||||
controller: textEditingController,
|
||||
(context, textController, focusNode, onFieldSubmitted) {
|
||||
final input = ShadInput(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
placeholder: const Text('제품명'),
|
||||
placeholder: const Text('제품명 검색'),
|
||||
onChanged: (_) => widget.onChanged?.call(),
|
||||
onSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
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) {
|
||||
@@ -149,7 +265,7 @@ class _InventoryProductAutocompleteFieldState
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 240,
|
||||
maxHeight: 220,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
@@ -158,7 +274,15 @@ class _InventoryProductAutocompleteFieldState
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: buildEmptySearchResult(theme.textTheme),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text(
|
||||
'검색 결과가 없습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -195,7 +319,7 @@ class _InventoryProductAutocompleteFieldState
|
||||
Text(option.name, style: theme.textTheme.p),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${option.code} · ${option.manufacturer} · ${option.unit}',
|
||||
'${option.code} · ${option.vendorName} · ${option.unitName}',
|
||||
style: theme.textTheme.muted.copyWith(
|
||||
fontSize: 12,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
|
||||
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
|
||||
|
||||
/// 창고 선택 옵션 모델.
|
||||
class InventoryWarehouseOption {
|
||||
InventoryWarehouseOption({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory InventoryWarehouseOption.fromWarehouse(Warehouse warehouse) {
|
||||
return InventoryWarehouseOption(
|
||||
id: warehouse.id ?? 0,
|
||||
code: warehouse.warehouseCode,
|
||||
name: warehouse.warehouseName,
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
|
||||
@override
|
||||
String toString() => '$name ($code)';
|
||||
}
|
||||
|
||||
/// 창고 목록을 불러와 `ShadSelect`로 노출하는 위젯.
|
||||
///
|
||||
/// - 서버에서 활성화된 창고를 조회해 옵션으로 구성한다.
|
||||
/// - `includeAllOption`을 사용하면 '전체' 선택지를 함께 제공한다.
|
||||
class InventoryWarehouseSelectField extends StatefulWidget {
|
||||
const InventoryWarehouseSelectField({
|
||||
super.key,
|
||||
this.initialWarehouseId,
|
||||
required this.onChanged,
|
||||
this.enabled = true,
|
||||
this.placeholder,
|
||||
this.includeAllOption = false,
|
||||
this.allLabel = '전체 창고',
|
||||
});
|
||||
|
||||
final int? initialWarehouseId;
|
||||
final ValueChanged<InventoryWarehouseOption?> onChanged;
|
||||
final bool enabled;
|
||||
final Widget? placeholder;
|
||||
final bool includeAllOption;
|
||||
final String allLabel;
|
||||
|
||||
@override
|
||||
State<InventoryWarehouseSelectField> createState() =>
|
||||
_InventoryWarehouseSelectFieldState();
|
||||
}
|
||||
|
||||
class _InventoryWarehouseSelectFieldState
|
||||
extends State<InventoryWarehouseSelectField> {
|
||||
WarehouseRepository? get _repository =>
|
||||
GetIt.I.isRegistered<WarehouseRepository>()
|
||||
? GetIt.I<WarehouseRepository>()
|
||||
: null;
|
||||
|
||||
List<InventoryWarehouseOption> _options = const [];
|
||||
InventoryWarehouseOption? _selected;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWarehouses();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InventoryWarehouseSelectField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialWarehouseId != oldWidget.initialWarehouseId) {
|
||||
_syncSelection(widget.initialWarehouseId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadWarehouses() async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
_error = '창고 데이터 소스를 찾을 수 없습니다.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
isActive: true,
|
||||
includeZipcode: false,
|
||||
);
|
||||
final options = result.items
|
||||
.map(InventoryWarehouseOption.fromWarehouse)
|
||||
.toList(growable: false);
|
||||
final selected = _findOptionById(options, widget.initialWarehouseId);
|
||||
setState(() {
|
||||
_options = options;
|
||||
_selected = selected;
|
||||
_isLoading = false;
|
||||
});
|
||||
if (selected != null) {
|
||||
widget.onChanged(selected);
|
||||
}
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
final failure = Failure.from(error);
|
||||
_error = failure.describe();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _syncSelection(int? warehouseId) {
|
||||
if (_options.isEmpty) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
for (final option in options) {
|
||||
if (option.id == id) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: const [
|
||||
ShadInput(readOnly: true),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return ShadInput(
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
placeholder: Text(_error!),
|
||||
);
|
||||
}
|
||||
|
||||
if (_options.isEmpty) {
|
||||
return ShadInput(
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
placeholder: const Text('선택 가능한 창고가 없습니다'),
|
||||
);
|
||||
}
|
||||
|
||||
return ShadSelect<int?>(
|
||||
enabled: widget.enabled,
|
||||
initialValue: _selected?.id,
|
||||
placeholder: widget.placeholder ?? const Text('창고 선택'),
|
||||
selectedOptionBuilder: (context, value) {
|
||||
final option = value == null
|
||||
? _selected
|
||||
: _options.firstWhere(
|
||||
(item) => item.id == value,
|
||||
orElse: () => _selected ?? _options.first,
|
||||
);
|
||||
if (option == null) {
|
||||
return widget.placeholder ?? const Text('창고 선택');
|
||||
}
|
||||
return Text('${option.name} (${option.code})');
|
||||
},
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
if (_selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
widget.onChanged(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final option = _options.firstWhere(
|
||||
(item) => item.id == value,
|
||||
orElse: () => _options.first,
|
||||
);
|
||||
setState(() {
|
||||
_selected = option;
|
||||
});
|
||||
widget.onChanged(option);
|
||||
},
|
||||
options: [
|
||||
if (widget.includeAllOption)
|
||||
ShadOption<int?>(value: null, child: Text(widget.allLabel)),
|
||||
for (final option in _options)
|
||||
ShadOption<int?>(
|
||||
value: option.id,
|
||||
child: Text('${option.name} · ${option.code}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user