결재 및 마스터 모듈을 v4 API 계약에 맞게 조정

This commit is contained in:
JiWoong Sul
2025-10-16 17:27:20 +09:00
parent d5c99627db
commit 9e2244f260
34 changed files with 1394 additions and 330 deletions

View File

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

View File

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

View File

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