결재 및 마스터 모듈을 v4 API 계약에 맞게 조정
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -59,31 +61,53 @@ class InventoryWarehouseSelectField extends StatefulWidget {
|
||||
|
||||
class _InventoryWarehouseSelectFieldState
|
||||
extends State<InventoryWarehouseSelectField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 250);
|
||||
|
||||
WarehouseRepository? get _repository =>
|
||||
GetIt.I.isRegistered<WarehouseRepository>()
|
||||
? GetIt.I<WarehouseRepository>()
|
||||
: null;
|
||||
|
||||
List<InventoryWarehouseOption> _options = const [];
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<InventoryWarehouseOption> _initialOptions = [];
|
||||
final List<InventoryWarehouseOption> _suggestions = [];
|
||||
InventoryWarehouseOption? _selected;
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingInitial = false;
|
||||
bool _isSearching = false;
|
||||
String? _error;
|
||||
Timer? _debounce;
|
||||
int _requestId = 0;
|
||||
bool _isApplyingText = false;
|
||||
|
||||
InventoryWarehouseOption get _allOption =>
|
||||
InventoryWarehouseOption(id: -1, code: 'ALL', name: widget.allLabel);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWarehouses();
|
||||
_controller.addListener(_handleTextChanged);
|
||||
_loadInitialOptions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InventoryWarehouseSelectField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialWarehouseId != oldWidget.initialWarehouseId) {
|
||||
_syncSelection(widget.initialWarehouseId);
|
||||
_applyInitialSelection(allowFetch: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadWarehouses() async {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_handleTextChanged);
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialOptions() async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
@@ -92,7 +116,7 @@ class _InventoryWarehouseSelectFieldState
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isLoadingInitial = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
@@ -105,58 +129,91 @@ class _InventoryWarehouseSelectFieldState
|
||||
final options = result.items
|
||||
.map(InventoryWarehouseOption.fromWarehouse)
|
||||
.toList(growable: false);
|
||||
final selected = _findOptionById(options, widget.initialWarehouseId);
|
||||
setState(() {
|
||||
_options = options;
|
||||
_selected = selected;
|
||||
_isLoading = false;
|
||||
_initialOptions
|
||||
..clear()
|
||||
..addAll(options);
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_availableOptions(options));
|
||||
_isLoadingInitial = false;
|
||||
});
|
||||
if (selected != null) {
|
||||
widget.onChanged(selected);
|
||||
}
|
||||
await _applyInitialSelection(options: options, allowFetch: true);
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
final failure = Failure.from(error);
|
||||
_error = failure.describe();
|
||||
_isLoading = false;
|
||||
_isLoadingInitial = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _syncSelection(int? warehouseId) {
|
||||
if (_options.isEmpty) {
|
||||
if (warehouseId == null && _selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
widget.onChanged(null);
|
||||
List<InventoryWarehouseOption> _availableOptions(
|
||||
List<InventoryWarehouseOption> base,
|
||||
) {
|
||||
if (!widget.includeAllOption) {
|
||||
return List<InventoryWarehouseOption>.from(base);
|
||||
}
|
||||
return <InventoryWarehouseOption>[_allOption, ...base];
|
||||
}
|
||||
|
||||
Future<void> _applyInitialSelection({
|
||||
List<InventoryWarehouseOption>? options,
|
||||
bool allowFetch = false,
|
||||
}) async {
|
||||
final id = widget.initialWarehouseId;
|
||||
if (id == null) {
|
||||
if (_selected != null) {
|
||||
_setSelection(null, notify: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final next = _findOptionById(_options, warehouseId);
|
||||
if (warehouseId == null && _selected != null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
});
|
||||
widget.onChanged(null);
|
||||
final source = options ?? _initialOptions;
|
||||
final match = _findById(source, id);
|
||||
if (match != null) {
|
||||
_setSelection(match, notify: true);
|
||||
return;
|
||||
}
|
||||
if (!identical(next, _selected)) {
|
||||
if (!allowFetch) {
|
||||
return;
|
||||
}
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: id.toString(),
|
||||
isActive: true,
|
||||
includeZipcode: false,
|
||||
);
|
||||
final fetched = result.items
|
||||
.map(InventoryWarehouseOption.fromWarehouse)
|
||||
.toList(growable: false);
|
||||
final option = _findById(fetched, id);
|
||||
if (option == null) {
|
||||
return;
|
||||
}
|
||||
if (!_initialOptions.any((item) => item.id == option.id)) {
|
||||
_initialOptions.add(option);
|
||||
}
|
||||
setState(() {
|
||||
_selected = next;
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_availableOptions(_initialOptions));
|
||||
});
|
||||
widget.onChanged(next);
|
||||
_setSelection(option, notify: true);
|
||||
} catch (_) {
|
||||
// 초기 선정을 찾지 못하면 기본값 유지.
|
||||
}
|
||||
}
|
||||
|
||||
InventoryWarehouseOption? _findOptionById(
|
||||
InventoryWarehouseOption? _findById(
|
||||
List<InventoryWarehouseOption> options,
|
||||
int? id,
|
||||
int id,
|
||||
) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
for (final option in options) {
|
||||
if (option.id == id) {
|
||||
return option;
|
||||
@@ -165,23 +222,136 @@ class _InventoryWarehouseSelectFieldState
|
||||
return null;
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
if (_isApplyingText) {
|
||||
return;
|
||||
}
|
||||
final keyword = _controller.text.trim();
|
||||
if (keyword.isEmpty) {
|
||||
_debounce?.cancel();
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_availableOptions(_initialOptions));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final label = _selected == null
|
||||
? null
|
||||
: _selected!.id == -1
|
||||
? widget.allLabel
|
||||
: _displayLabel(_selected!);
|
||||
if (label != null && keyword != label) {
|
||||
_selected = null;
|
||||
widget.onChanged(null);
|
||||
}
|
||||
_scheduleSearch(keyword);
|
||||
}
|
||||
|
||||
void _scheduleSearch(String keyword) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(_debounceDuration, () => _performSearch(keyword));
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String keyword) async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
final request = ++_requestId;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: keyword,
|
||||
isActive: true,
|
||||
includeZipcode: false,
|
||||
);
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
final items = result.items
|
||||
.map(InventoryWarehouseOption.fromWarehouse)
|
||||
.toList(growable: false);
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(items);
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setSelection(InventoryWarehouseOption? option, {bool notify = true}) {
|
||||
if (option != null &&
|
||||
option.id != -1 &&
|
||||
!_initialOptions.any((item) => item.id == option.id)) {
|
||||
_initialOptions.add(option);
|
||||
}
|
||||
_selected = option;
|
||||
_isApplyingText = true;
|
||||
if (option == null) {
|
||||
_controller.clear();
|
||||
if (notify) {
|
||||
widget.onChanged(null);
|
||||
}
|
||||
} else if (option.id == -1) {
|
||||
_controller
|
||||
..text = widget.allLabel
|
||||
..selection = TextSelection.collapsed(offset: widget.allLabel.length);
|
||||
if (notify) {
|
||||
widget.onChanged(null);
|
||||
}
|
||||
} else {
|
||||
final label = _displayLabel(option);
|
||||
_controller
|
||||
..text = label
|
||||
..selection = TextSelection.collapsed(offset: label.length);
|
||||
if (notify) {
|
||||
widget.onChanged(option);
|
||||
}
|
||||
}
|
||||
_isApplyingText = false;
|
||||
}
|
||||
|
||||
String _displayLabel(InventoryWarehouseOption option) {
|
||||
return '${option.name} (${option.code})';
|
||||
}
|
||||
|
||||
Widget _buildLoadingInput() {
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: const [
|
||||
ShadInput(readOnly: true),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_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 (_isLoadingInitial) {
|
||||
return _buildLoadingInput();
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
@@ -192,58 +362,96 @@ class _InventoryWarehouseSelectFieldState
|
||||
);
|
||||
}
|
||||
|
||||
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 RawAutocomplete<InventoryWarehouseOption>(
|
||||
textEditingController: _controller,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
if (textEditingValue.text.trim().isEmpty) {
|
||||
if (_suggestions.isEmpty) {
|
||||
return _availableOptions(_initialOptions);
|
||||
}
|
||||
return _suggestions;
|
||||
}
|
||||
return _suggestions;
|
||||
},
|
||||
displayStringForOption: (option) =>
|
||||
option.id == -1 ? widget.allLabel : _displayLabel(option),
|
||||
onSelected: (option) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final option = _options.firstWhere(
|
||||
(item) => item.id == value,
|
||||
orElse: () => _options.first,
|
||||
);
|
||||
setState(() {
|
||||
_selected = option;
|
||||
});
|
||||
widget.onChanged(option);
|
||||
if (option.id == -1) {
|
||||
_setSelection(_allOption);
|
||||
} else {
|
||||
_setSelection(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}'),
|
||||
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 option = options.elementAt(index);
|
||||
final isAll = option.id == -1;
|
||||
final label = isAll ? widget.allLabel : _displayLabel(option);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Text(label),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user