refactor: 인벤토리 테이블 스펙과 도메인 계층 정비

This commit is contained in:
JiWoong Sul
2025-10-14 18:09:26 +09:00
parent 8d3b2c1e20
commit 1325109fba
32 changed files with 5550 additions and 290 deletions

View File

@@ -1,232 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 인벤토리 폼에서 공유하는 제품 카탈로그 항목.
class InventoryProductCatalogItem {
const InventoryProductCatalogItem({
required this.code,
required this.name,
required this.manufacturer,
required this.unit,
});
final String code;
final String name;
final String manufacturer;
final String unit;
}
String _normalizeText(String value) {
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
}
/// 제품 카탈로그 유틸리티.
class InventoryProductCatalog {
static final List<InventoryProductCatalogItem> items = List.unmodifiable([
const InventoryProductCatalogItem(
code: 'P-100',
name: 'XR-5000',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-101',
name: 'XR-5001',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-102',
name: 'Eco-200',
manufacturer: '그린텍',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-201',
name: 'Delta-One',
manufacturer: '델타',
unit: 'SET',
),
const InventoryProductCatalogItem(
code: 'P-210',
name: 'SmartGauge A1',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-305',
name: 'PowerPack Mini',
manufacturer: '에이치솔루션',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-320',
name: 'Hydra-Flow 2',
manufacturer: '블루하이드',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-401',
name: 'SolarEdge Pro',
manufacturer: '그린텍',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-430',
name: 'Alpha-Kit 12',
manufacturer: '테크솔루션',
unit: 'SET',
),
const InventoryProductCatalogItem(
code: 'P-501',
name: 'LogiSense 5',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
]);
static final Map<String, InventoryProductCatalogItem> _byKey = {
for (final item in items) _normalizeText(item.name): item,
};
static InventoryProductCatalogItem? match(String value) {
if (value.isEmpty) return null;
return _byKey[_normalizeText(value)];
}
static List<InventoryProductCatalogItem> filter(String query) {
final normalized = _normalizeText(query.trim());
if (normalized.isEmpty) {
return items.take(12).toList();
}
final lower = query.trim().toLowerCase();
return [
for (final item in items)
if (_normalizeText(item.name).contains(normalized) ||
item.code.toLowerCase().contains(lower))
item,
];
}
}
/// 고객 카탈로그 항목.
class InventoryCustomerCatalogItem {
const InventoryCustomerCatalogItem({
required this.code,
required this.name,
required this.industry,
required this.region,
});
final String code;
final String name;
final String industry;
final String region;
}
/// 고객 카탈로그 유틸리티.
class InventoryCustomerCatalog {
static final List<InventoryCustomerCatalogItem> items = List.unmodifiable([
const InventoryCustomerCatalogItem(
code: 'C-1001',
name: '슈퍼포트 파트너',
industry: '물류',
region: '서울',
),
const InventoryCustomerCatalogItem(
code: 'C-1002',
name: '그린에너지',
industry: '에너지',
region: '대전',
),
const InventoryCustomerCatalogItem(
code: 'C-1003',
name: '테크솔루션',
industry: 'IT 서비스',
region: '부산',
),
const InventoryCustomerCatalogItem(
code: 'C-1004',
name: '에이치솔루션',
industry: '제조',
region: '인천',
),
const InventoryCustomerCatalogItem(
code: 'C-1005',
name: '블루하이드',
industry: '해양장비',
region: '울산',
),
const InventoryCustomerCatalogItem(
code: 'C-1010',
name: '넥스트파워',
industry: '발전설비',
region: '광주',
),
const InventoryCustomerCatalogItem(
code: 'C-1011',
name: '씨에스테크',
industry: '반도체',
region: '수원',
),
const InventoryCustomerCatalogItem(
code: 'C-1012',
name: '알파시스템',
industry: '장비임대',
region: '대구',
),
const InventoryCustomerCatalogItem(
code: 'C-1013',
name: '스타트랩',
industry: '연구개발',
region: '세종',
),
const InventoryCustomerCatalogItem(
code: 'C-1014',
name: '메가스틸',
industry: '철강',
region: '포항',
),
]);
static final Map<String, InventoryCustomerCatalogItem> _byName = {
for (final item in items) item.name: item,
};
static InventoryCustomerCatalogItem? byName(String name) => _byName[name];
static List<InventoryCustomerCatalogItem> filter(String query) {
final normalized = _normalizeText(query.trim());
if (normalized.isEmpty) {
return items;
}
final lower = query.trim().toLowerCase();
return [
for (final item in items)
if (_normalizeText(item.name).contains(normalized) ||
item.code.toLowerCase().contains(lower) ||
_normalizeText(item.industry).contains(normalized) ||
_normalizeText(item.region).contains(normalized))
item,
];
}
static String displayLabel(String name) {
final item = byName(name);
if (item == null) {
return name;
}
return '${item.name} (${item.code})';
}
}
/// 검색 결과가 없을 때 노출할 기본 위젯.
Widget buildEmptySearchResult(
ShadTextTheme textTheme, {
String message = '검색 결과가 없습니다.',
}) {
return Padding(
padding: const EdgeInsets.all(12),
child: Text(message, style: textTheme.muted),
);
}

View File

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

View File

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

View File

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

View File

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