결재 및 마스터 모듈을 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

@@ -39,22 +39,22 @@ class InventoryLookupRepositoryRemote implements InventoryLookupRepository {
return _fetchList(
_approvalActionsPath,
activeOnly: activeOnly,
// Approval actions는 is_active 필터가 없을 수 있어 조건적으로 전달.
includeIsActive: false,
// Approval actions는 active 필터가 없을 수 있어 조건적으로 전달한다.
includeActiveFilter: false,
);
}
Future<List<LookupItem>> _fetchList(
String path, {
required bool activeOnly,
bool includeIsActive = true,
bool includeActiveFilter = true,
}) async {
final response = await _api.get<Map<String, dynamic>>(
path,
query: {
'page': 1,
'page_size': 200,
if (includeIsActive && activeOnly) 'is_active': true,
if (includeActiveFilter && activeOnly) 'active': true,
},
options: Options(responseType: ResponseType.json),
);

View File

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

View File

@@ -195,13 +195,23 @@ class StockTransactionListFilter {
'transaction_status_id': transactionStatusId,
if (warehouseId != null) 'warehouse_id': warehouseId,
if (customerId != null) 'customer_id': customerId,
if (from != null) 'from': from!.toIso8601String(),
if (to != null) 'to': to!.toIso8601String(),
if (from != null) 'date_from': _formatDate(from!),
if (to != null) 'date_to': _formatDate(to!),
if (sort != null && sort!.trim().isNotEmpty) 'sort': sort,
if (order != null && order!.trim().isNotEmpty) 'order': order,
if (include.isNotEmpty) 'include': include.join(','),
};
}
/// 백엔드가 요구하는 `yyyy-MM-dd`(NaiveDate) 형식으로 변환한다.
String _formatDate(DateTime value) {
final iso = value.toIso8601String();
final separatorIndex = iso.indexOf('T');
if (separatorIndex == -1) {
return iso;
}
return iso.substring(0, separatorIndex);
}
}
/// 재고 트랜잭션 생성 시 결재(Approval) 정보를 담는 입력 모델.