결재 및 마스터 모듈을 v4 API 계약에 맞게 조정
This commit is contained in:
@@ -9,6 +9,7 @@ class CustomerDto {
|
||||
this.id,
|
||||
required this.customerCode,
|
||||
required this.customerName,
|
||||
this.contactName,
|
||||
this.isPartner = false,
|
||||
this.isGeneral = true,
|
||||
this.email,
|
||||
@@ -25,6 +26,7 @@ class CustomerDto {
|
||||
final int? id;
|
||||
final String customerCode;
|
||||
final String customerName;
|
||||
final String? contactName;
|
||||
final bool isPartner;
|
||||
final bool isGeneral;
|
||||
final String? email;
|
||||
@@ -43,6 +45,7 @@ class CustomerDto {
|
||||
id: json['id'] as int?,
|
||||
customerCode: json['customer_code'] as String,
|
||||
customerName: json['customer_name'] as String,
|
||||
contactName: json['contact_name'] as String?,
|
||||
isPartner: (json['is_partner'] as bool?) ?? false,
|
||||
isGeneral: (json['is_general'] as bool?) ?? true,
|
||||
email: json['email'] as String?,
|
||||
@@ -65,6 +68,7 @@ class CustomerDto {
|
||||
if (id != null) 'id': id,
|
||||
'customer_code': customerCode,
|
||||
'customer_name': customerName,
|
||||
'contact_name': contactName,
|
||||
'is_partner': isPartner,
|
||||
'is_general': isGeneral,
|
||||
'email': email,
|
||||
@@ -84,6 +88,7 @@ class CustomerDto {
|
||||
id: id,
|
||||
customerCode: customerCode,
|
||||
customerName: customerName,
|
||||
contactName: contactName,
|
||||
isPartner: isPartner,
|
||||
isGeneral: isGeneral,
|
||||
email: email,
|
||||
|
||||
@@ -33,7 +33,7 @@ class CustomerRepositoryRemote implements CustomerRepository {
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (isPartner != null) 'is_partner': isPartner,
|
||||
if (isGeneral != null) 'is_general': isGeneral,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ class Customer {
|
||||
this.id,
|
||||
required this.customerCode,
|
||||
required this.customerName,
|
||||
this.contactName,
|
||||
this.isPartner = false,
|
||||
this.isGeneral = true,
|
||||
this.email,
|
||||
@@ -20,6 +21,7 @@ class Customer {
|
||||
final int? id;
|
||||
final String customerCode;
|
||||
final String customerName;
|
||||
final String? contactName;
|
||||
final bool isPartner;
|
||||
final bool isGeneral;
|
||||
final String? email;
|
||||
@@ -37,6 +39,7 @@ class Customer {
|
||||
int? id,
|
||||
String? customerCode,
|
||||
String? customerName,
|
||||
String? contactName,
|
||||
bool? isPartner,
|
||||
bool? isGeneral,
|
||||
String? email,
|
||||
@@ -53,6 +56,7 @@ class Customer {
|
||||
id: id ?? this.id,
|
||||
customerCode: customerCode ?? this.customerCode,
|
||||
customerName: customerName ?? this.customerName,
|
||||
contactName: contactName ?? this.contactName,
|
||||
isPartner: isPartner ?? this.isPartner,
|
||||
isGeneral: isGeneral ?? this.isGeneral,
|
||||
email: email ?? this.email,
|
||||
@@ -88,6 +92,7 @@ class CustomerInput {
|
||||
CustomerInput({
|
||||
required this.customerCode,
|
||||
required this.customerName,
|
||||
this.contactName,
|
||||
required this.isPartner,
|
||||
required this.isGeneral,
|
||||
this.email,
|
||||
@@ -100,6 +105,7 @@ class CustomerInput {
|
||||
|
||||
final String customerCode;
|
||||
final String customerName;
|
||||
final String? contactName;
|
||||
final bool isPartner;
|
||||
final bool isGeneral;
|
||||
final String? email;
|
||||
@@ -114,6 +120,7 @@ class CustomerInput {
|
||||
return {
|
||||
'customer_code': customerCode,
|
||||
'customer_name': customerName,
|
||||
'contact_name': contactName,
|
||||
'is_partner': isPartner,
|
||||
'is_general': isGeneral,
|
||||
'email': email,
|
||||
|
||||
@@ -496,6 +496,9 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
final nameController = TextEditingController(
|
||||
text: existing?.customerName ?? '',
|
||||
);
|
||||
final contactController = TextEditingController(
|
||||
text: existing?.contactName ?? '',
|
||||
);
|
||||
final emailController = TextEditingController(text: existing?.email ?? '');
|
||||
final mobileController = TextEditingController(
|
||||
text: existing?.mobileNo ?? '',
|
||||
@@ -597,6 +600,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
: () async {
|
||||
final code = codeController.text.trim();
|
||||
final name = nameController.text.trim();
|
||||
final contact = contactController.text.trim();
|
||||
final email = emailController.text.trim();
|
||||
final mobile = mobileController.text.trim();
|
||||
final zipcode = zipcodeController.text.trim();
|
||||
@@ -633,6 +637,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
final input = CustomerInput(
|
||||
customerCode: code,
|
||||
customerName: name,
|
||||
contactName: contact.isEmpty ? null : contact,
|
||||
isPartner: partner,
|
||||
isGeneral: general,
|
||||
email: email.isEmpty ? null : email,
|
||||
@@ -748,6 +753,11 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '담당자',
|
||||
child: ShadInput(controller: contactController),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: partnerNotifier,
|
||||
builder: (_, partner, __) {
|
||||
@@ -949,6 +959,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
|
||||
|
||||
codeController.dispose();
|
||||
nameController.dispose();
|
||||
contactController.dispose();
|
||||
emailController.dispose();
|
||||
mobileController.dispose();
|
||||
zipcodeController.dispose();
|
||||
@@ -1047,6 +1058,7 @@ class _CustomerTable extends StatelessWidget {
|
||||
'ID',
|
||||
'고객사코드',
|
||||
'고객사명',
|
||||
'담당자',
|
||||
'유형',
|
||||
'이메일',
|
||||
'연락처',
|
||||
@@ -1072,6 +1084,7 @@ class _CustomerTable extends StatelessWidget {
|
||||
customer.id?.toString() ?? '-',
|
||||
customer.customerCode,
|
||||
customer.customerName,
|
||||
customer.contactName?.isEmpty ?? true ? '-' : customer.contactName!,
|
||||
resolveType(customer),
|
||||
customer.email?.isEmpty ?? true ? '-' : customer.email!,
|
||||
customer.mobileNo?.isEmpty ?? true ? '-' : customer.mobileNo!,
|
||||
|
||||
@@ -40,7 +40,7 @@ class GroupRepositoryRemote implements GroupRepository {
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (isDefault != null) 'is_default': isDefault,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
if (includeParts.isNotEmpty) 'include': includeParts.join(','),
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
|
||||
@@ -33,7 +33,7 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
|
||||
'page_size': pageSize,
|
||||
if (groupId != null) 'group_id': groupId,
|
||||
if (menuId != null) 'menu_id': menuId,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
if (includeDeleted) 'include_deleted': true,
|
||||
'include': 'group,menu',
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ class MenuRepositoryRemote implements MenuRepository {
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (parentId != null) 'parent_id': parentId,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
if (includeDeleted) 'include_deleted': true,
|
||||
'include': 'parent',
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ class ProductRepositoryRemote implements ProductRepository {
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (vendorId != null) 'vendor_id': vendorId,
|
||||
if (uomId != null) 'uom_id': uomId,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
'include': 'vendor,uom',
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
|
||||
@@ -18,6 +18,8 @@ import '../../../vendor/domain/repositories/vendor_repository.dart';
|
||||
import '../../domain/entities/product.dart';
|
||||
import '../../domain/repositories/product_repository.dart';
|
||||
import '../controllers/product_controller.dart';
|
||||
import '../widgets/uom_autocomplete_field.dart';
|
||||
import '../widgets/vendor_autocomplete_field.dart';
|
||||
|
||||
/// 제품 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다.
|
||||
class ProductPage extends StatelessWidget {
|
||||
@@ -686,42 +688,27 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: vendorError,
|
||||
builder: (_, errorText, __) {
|
||||
final vendorLabel = () {
|
||||
final vendor = existing?.vendor;
|
||||
if (vendor == null) {
|
||||
return null;
|
||||
}
|
||||
return '${vendor.vendorName} (${vendor.vendorCode})';
|
||||
}();
|
||||
return _FormField(
|
||||
label: '제조사',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadSelect<int?>(
|
||||
initialValue: value,
|
||||
onChanged: saving.value
|
||||
? null
|
||||
: (next) {
|
||||
vendorNotifier.value = next;
|
||||
vendorError.value = null;
|
||||
},
|
||||
options: _controller.vendorOptions
|
||||
.map(
|
||||
(vendor) => ShadOption<int?>(
|
||||
value: vendor.id,
|
||||
child: Text(vendor.vendorName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
VendorAutocompleteField(
|
||||
initialOptions: _controller.vendorOptions,
|
||||
selectedVendorId: value,
|
||||
initialLabel: vendorLabel,
|
||||
enabled: !isSaving,
|
||||
placeholder: const Text('제조사를 선택하세요'),
|
||||
selectedOptionBuilder: (context, selected) {
|
||||
if (selected == null) {
|
||||
return const Text('제조사를 선택하세요');
|
||||
}
|
||||
final vendor = _controller.vendorOptions
|
||||
.firstWhere(
|
||||
(v) => v.id == selected,
|
||||
orElse: () => Vendor(
|
||||
id: selected,
|
||||
vendorCode: '',
|
||||
vendorName: '',
|
||||
),
|
||||
);
|
||||
return Text(vendor.vendorName);
|
||||
onSelected: (id) {
|
||||
vendorNotifier.value = id;
|
||||
vendorError.value = null;
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
@@ -748,39 +735,21 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: uomError,
|
||||
builder: (_, errorText, __) {
|
||||
final uomLabel = existing?.uom?.uomName;
|
||||
return _FormField(
|
||||
label: '단위',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadSelect<int?>(
|
||||
initialValue: value,
|
||||
onChanged: saving.value
|
||||
? null
|
||||
: (next) {
|
||||
uomNotifier.value = next;
|
||||
uomError.value = null;
|
||||
},
|
||||
options: _controller.uomOptions
|
||||
.map(
|
||||
(uom) => ShadOption<int?>(
|
||||
value: uom.id,
|
||||
child: Text(uom.uomName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
UomAutocompleteField(
|
||||
initialOptions: _controller.uomOptions,
|
||||
selectedUomId: value,
|
||||
initialLabel: uomLabel,
|
||||
enabled: !isSaving,
|
||||
placeholder: const Text('단위를 선택하세요'),
|
||||
selectedOptionBuilder: (context, selected) {
|
||||
if (selected == null) {
|
||||
return const Text('단위를 선택하세요');
|
||||
}
|
||||
final uom = _controller.uomOptions
|
||||
.firstWhere(
|
||||
(u) => u.id == selected,
|
||||
orElse: () =>
|
||||
Uom(id: selected, uomName: ''),
|
||||
);
|
||||
return Text(uom.uomName);
|
||||
onSelected: (id) {
|
||||
uomNotifier.value = id;
|
||||
uomError.value = null;
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../core/network/failure.dart';
|
||||
import '../../../uom/domain/entities/uom.dart';
|
||||
import '../../../uom/domain/repositories/uom_repository.dart';
|
||||
|
||||
/// 단위를 검색/선택할 수 있는 자동완성 입력 필드.
|
||||
class UomAutocompleteField extends StatefulWidget {
|
||||
const UomAutocompleteField({
|
||||
super.key,
|
||||
required this.initialOptions,
|
||||
this.selectedUomId,
|
||||
this.initialLabel,
|
||||
required this.onSelected,
|
||||
this.enabled = true,
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
final List<Uom> initialOptions;
|
||||
final int? selectedUomId;
|
||||
final String? initialLabel;
|
||||
final ValueChanged<int?> onSelected;
|
||||
final bool enabled;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<UomAutocompleteField> createState() => _UomAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _UomAutocompleteFieldState extends State<UomAutocompleteField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
UomRepository? get _repository =>
|
||||
GetIt.I.isRegistered<UomRepository>() ? GetIt.I<UomRepository>() : null;
|
||||
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<Uom> _baseOptions = [];
|
||||
final List<Uom> _suggestions = [];
|
||||
Uom? _selected;
|
||||
bool _isSearching = false;
|
||||
String? _error;
|
||||
Timer? _debounce;
|
||||
int _requestId = 0;
|
||||
bool _isApplyingText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_handleTextChanged);
|
||||
_initializeOptions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant UomAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(widget.initialOptions, oldWidget.initialOptions)) {
|
||||
_baseOptions
|
||||
..clear()
|
||||
..addAll(widget.initialOptions);
|
||||
if (_controller.text.trim().isEmpty) {
|
||||
_resetSuggestions();
|
||||
}
|
||||
}
|
||||
if (widget.selectedUomId != oldWidget.selectedUomId) {
|
||||
_applySelectionFromId(widget.selectedUomId, label: widget.initialLabel);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_handleTextChanged);
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeOptions() {
|
||||
_baseOptions
|
||||
..clear()
|
||||
..addAll(widget.initialOptions);
|
||||
_resetSuggestions();
|
||||
if (widget.selectedUomId != null) {
|
||||
_applySelectionFromId(widget.selectedUomId, label: widget.initialLabel);
|
||||
} else if (widget.initialLabel != null && widget.initialLabel!.isNotEmpty) {
|
||||
_isApplyingText = true;
|
||||
_controller
|
||||
..text = widget.initialLabel!
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: widget.initialLabel!.length,
|
||||
);
|
||||
_isApplyingText = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetSuggestions() {
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_baseOptions);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
if (_isApplyingText) {
|
||||
return;
|
||||
}
|
||||
final keyword = _controller.text.trim();
|
||||
if (keyword.isEmpty) {
|
||||
_debounce?.cancel();
|
||||
_resetSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selected != null && keyword != _selected!.uomName) {
|
||||
_selected = null;
|
||||
widget.onSelected(null);
|
||||
}
|
||||
_scheduleSearch(keyword);
|
||||
}
|
||||
|
||||
void _scheduleSearch(String keyword) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(_debounceDuration, () => _search(keyword));
|
||||
}
|
||||
|
||||
Future<void> _search(String keyword) async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
_error = '단위 데이터 소스를 찾을 수 없습니다.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final request = ++_requestId;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: keyword,
|
||||
isActive: true,
|
||||
);
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
final items = result.items.toList(growable: false);
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(items);
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
final failure = Failure.from(error);
|
||||
_error = failure.describe();
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applySelectionFromId(int? id, {String? label}) {
|
||||
if (id == null) {
|
||||
_setSelection(null, notify: true);
|
||||
return;
|
||||
}
|
||||
final match = _findById(_baseOptions, id) ?? _findById(_suggestions, id);
|
||||
if (match != null) {
|
||||
_setSelection(match, notify: false);
|
||||
return;
|
||||
}
|
||||
if (label != null && label.isNotEmpty) {
|
||||
_isApplyingText = true;
|
||||
_controller
|
||||
..text = label
|
||||
..selection = TextSelection.collapsed(offset: label.length);
|
||||
_isApplyingText = false;
|
||||
}
|
||||
}
|
||||
|
||||
Uom? _findById(List<Uom> options, int id) {
|
||||
for (final option in options) {
|
||||
if (option.id == id) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setSelection(Uom? uom, {bool notify = true}) {
|
||||
_selected = uom;
|
||||
if (uom != null && !_baseOptions.any((item) => item.id == uom.id)) {
|
||||
_baseOptions.add(uom);
|
||||
}
|
||||
_isApplyingText = true;
|
||||
if (uom == null) {
|
||||
_controller.clear();
|
||||
if (notify) {
|
||||
widget.onSelected(null);
|
||||
}
|
||||
} else {
|
||||
_controller
|
||||
..text = uom.uomName
|
||||
..selection = TextSelection.collapsed(offset: uom.uomName.length);
|
||||
if (notify) {
|
||||
widget.onSelected(uom.id);
|
||||
}
|
||||
}
|
||||
_isApplyingText = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null && _baseOptions.isEmpty && _suggestions.isEmpty) {
|
||||
return ShadInput(
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
placeholder: Text(_error!),
|
||||
);
|
||||
}
|
||||
|
||||
return RawAutocomplete<Uom>(
|
||||
textEditingController: _controller,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
if (textEditingValue.text.trim().isEmpty) {
|
||||
if (_suggestions.isEmpty) {
|
||||
return _baseOptions;
|
||||
}
|
||||
return _baseOptions;
|
||||
}
|
||||
return _suggestions;
|
||||
},
|
||||
displayStringForOption: (option) => option.uomName,
|
||||
onSelected: (uom) => _setSelection(uom),
|
||||
fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) {
|
||||
final placeholder = widget.placeholder ?? const Text('단위를 선택하세요');
|
||||
return Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
ShadInput(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
enabled: widget.enabled,
|
||||
readOnly: !widget.enabled,
|
||||
placeholder: placeholder,
|
||||
),
|
||||
if (_isSearching)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200, minWidth: 220),
|
||||
child: ShadCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: const Center(child: Text('검색 결과가 없습니다.')),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240, minWidth: 220),
|
||||
child: ShadCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final uom = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(uom),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Text(uom.uomName),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../core/network/failure.dart';
|
||||
import '../../../vendor/domain/entities/vendor.dart';
|
||||
import '../../../vendor/domain/repositories/vendor_repository.dart';
|
||||
|
||||
/// 공급업체를 검색해 선택할 수 있는 자동완성 입력 필드.
|
||||
class VendorAutocompleteField extends StatefulWidget {
|
||||
const VendorAutocompleteField({
|
||||
super.key,
|
||||
required this.initialOptions,
|
||||
this.selectedVendorId,
|
||||
this.initialLabel,
|
||||
required this.onSelected,
|
||||
this.enabled = true,
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
final List<Vendor> initialOptions;
|
||||
final int? selectedVendorId;
|
||||
final String? initialLabel;
|
||||
final ValueChanged<int?> onSelected;
|
||||
final bool enabled;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<VendorAutocompleteField> createState() =>
|
||||
_VendorAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _VendorAutocompleteFieldState extends State<VendorAutocompleteField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
VendorRepository? get _repository => GetIt.I.isRegistered<VendorRepository>()
|
||||
? GetIt.I<VendorRepository>()
|
||||
: null;
|
||||
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<Vendor> _baseOptions = [];
|
||||
final List<Vendor> _suggestions = [];
|
||||
Vendor? _selected;
|
||||
bool _isSearching = false;
|
||||
String? _error;
|
||||
Timer? _debounce;
|
||||
int _requestId = 0;
|
||||
bool _isApplyingText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_handleTextChanged);
|
||||
_initializeOptions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VendorAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(widget.initialOptions, oldWidget.initialOptions)) {
|
||||
_baseOptions
|
||||
..clear()
|
||||
..addAll(widget.initialOptions);
|
||||
if (_controller.text.trim().isEmpty) {
|
||||
_resetSuggestions();
|
||||
}
|
||||
}
|
||||
if (widget.selectedVendorId != oldWidget.selectedVendorId) {
|
||||
_applySelectionFromId(
|
||||
widget.selectedVendorId,
|
||||
label: widget.initialLabel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_handleTextChanged);
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeOptions() {
|
||||
_baseOptions
|
||||
..clear()
|
||||
..addAll(widget.initialOptions);
|
||||
_resetSuggestions();
|
||||
if (widget.selectedVendorId != null) {
|
||||
_applySelectionFromId(
|
||||
widget.selectedVendorId,
|
||||
label: widget.initialLabel,
|
||||
);
|
||||
} else if (widget.initialLabel != null && widget.initialLabel!.isNotEmpty) {
|
||||
_isApplyingText = true;
|
||||
_controller
|
||||
..text = widget.initialLabel!
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: widget.initialLabel!.length,
|
||||
);
|
||||
_isApplyingText = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetSuggestions() {
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_baseOptions);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
if (_isApplyingText) {
|
||||
return;
|
||||
}
|
||||
final keyword = _controller.text.trim();
|
||||
if (keyword.isEmpty) {
|
||||
_debounce?.cancel();
|
||||
_resetSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selected != null && keyword != _displayLabel(_selected!)) {
|
||||
_selected = null;
|
||||
widget.onSelected(null);
|
||||
}
|
||||
_scheduleSearch(keyword);
|
||||
}
|
||||
|
||||
void _scheduleSearch(String keyword) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(_debounceDuration, () => _search(keyword));
|
||||
}
|
||||
|
||||
Future<void> _search(String keyword) async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
_error = '공급업체 데이터 소스를 찾을 수 없습니다.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final request = ++_requestId;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: keyword,
|
||||
isActive: true,
|
||||
);
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
final items = result.items.toList(growable: false);
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(items);
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
final failure = Failure.from(error);
|
||||
_error = failure.describe();
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applySelectionFromId(int? id, {String? label}) {
|
||||
if (id == null) {
|
||||
_setSelection(null, notify: true);
|
||||
return;
|
||||
}
|
||||
final match = _findById(_baseOptions, id) ?? _findById(_suggestions, id);
|
||||
if (match != null) {
|
||||
_setSelection(match, notify: false);
|
||||
return;
|
||||
}
|
||||
if (label != null && label.isNotEmpty) {
|
||||
_isApplyingText = true;
|
||||
_controller
|
||||
..text = label
|
||||
..selection = TextSelection.collapsed(offset: label.length);
|
||||
_isApplyingText = false;
|
||||
}
|
||||
}
|
||||
|
||||
Vendor? _findById(List<Vendor> options, int id) {
|
||||
for (final option in options) {
|
||||
if (option.id == id) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setSelection(Vendor? vendor, {bool notify = true}) {
|
||||
_selected = vendor;
|
||||
if (vendor != null && !_baseOptions.any((item) => item.id == vendor.id)) {
|
||||
_baseOptions.add(vendor);
|
||||
}
|
||||
_isApplyingText = true;
|
||||
if (vendor == null) {
|
||||
_controller.clear();
|
||||
if (notify) {
|
||||
widget.onSelected(null);
|
||||
}
|
||||
} else {
|
||||
final label = _displayLabel(vendor);
|
||||
_controller
|
||||
..text = label
|
||||
..selection = TextSelection.collapsed(offset: label.length);
|
||||
if (notify) {
|
||||
widget.onSelected(vendor.id);
|
||||
}
|
||||
}
|
||||
_isApplyingText = false;
|
||||
}
|
||||
|
||||
String _displayLabel(Vendor vendor) {
|
||||
final code = vendor.vendorCode.isEmpty ? '' : ' (${vendor.vendorCode})';
|
||||
return '${vendor.vendorName}$code';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null && _baseOptions.isEmpty && _suggestions.isEmpty) {
|
||||
return ShadInput(
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
placeholder: Text(_error!),
|
||||
);
|
||||
}
|
||||
|
||||
return RawAutocomplete<Vendor>(
|
||||
textEditingController: _controller,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
if (textEditingValue.text.trim().isEmpty) {
|
||||
if (_suggestions.isEmpty) {
|
||||
return _baseOptions;
|
||||
}
|
||||
return _baseOptions;
|
||||
}
|
||||
return _suggestions;
|
||||
},
|
||||
displayStringForOption: _displayLabel,
|
||||
onSelected: (vendor) => _setSelection(vendor),
|
||||
fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) {
|
||||
final placeholder = widget.placeholder ?? const Text('제조사를 선택하세요');
|
||||
return Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
ShadInput(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
enabled: widget.enabled,
|
||||
readOnly: !widget.enabled,
|
||||
placeholder: placeholder,
|
||||
),
|
||||
if (_isSearching)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200, minWidth: 260),
|
||||
child: ShadCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: const Center(child: Text('검색 결과가 없습니다.')),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240, minWidth: 260),
|
||||
child: ShadCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final vendor = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(vendor),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Text(_displayLabel(vendor)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class UomRepositoryRemote implements UomRepository {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ class UserRepositoryRemote implements UserRepository {
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (groupId != null) 'group_id': groupId,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
'include': 'group',
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
|
||||
@@ -29,7 +29,7 @@ class VendorRepositoryRemote implements VendorRepository {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ class WarehouseRepositoryRemote implements WarehouseRepository {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (query != null && query.isNotEmpty) 'q': query,
|
||||
if (isActive != null) 'is_active': isActive,
|
||||
if (isActive != null) 'active': isActive,
|
||||
if (includeZipcode) 'include': 'zipcode',
|
||||
},
|
||||
options: Options(responseType: ResponseType.json),
|
||||
|
||||
Reference in New Issue
Block a user