fix(inventory): 파트너 연동 및 상세 모달 동작 안정화
- 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출 - 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정 - 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장 - 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화 - 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일 - 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속)
This commit is contained in:
@@ -16,11 +16,13 @@ class InboundController extends ChangeNotifier {
|
|||||||
InboundController({
|
InboundController({
|
||||||
required StockTransactionRepository transactionRepository,
|
required StockTransactionRepository transactionRepository,
|
||||||
required TransactionLineRepository lineRepository,
|
required TransactionLineRepository lineRepository,
|
||||||
|
required TransactionCustomerRepository customerRepository,
|
||||||
required InventoryLookupRepository lookupRepository,
|
required InventoryLookupRepository lookupRepository,
|
||||||
List<String> fallbackStatusOptions = const ['작성중', '승인대기', '승인완료'],
|
List<String> fallbackStatusOptions = const ['작성중', '승인대기', '승인완료'],
|
||||||
List<String> transactionTypeKeywords = const ['입고', 'inbound'],
|
List<String> transactionTypeKeywords = const ['입고', 'inbound'],
|
||||||
}) : _transactionRepository = transactionRepository,
|
}) : _transactionRepository = transactionRepository,
|
||||||
_lineRepository = lineRepository,
|
_lineRepository = lineRepository,
|
||||||
|
_customerRepository = customerRepository,
|
||||||
_lookupRepository = lookupRepository,
|
_lookupRepository = lookupRepository,
|
||||||
_fallbackStatusOptions = List<String>.unmodifiable(
|
_fallbackStatusOptions = List<String>.unmodifiable(
|
||||||
fallbackStatusOptions,
|
fallbackStatusOptions,
|
||||||
@@ -33,6 +35,7 @@ class InboundController extends ChangeNotifier {
|
|||||||
|
|
||||||
final StockTransactionRepository _transactionRepository;
|
final StockTransactionRepository _transactionRepository;
|
||||||
final TransactionLineRepository _lineRepository;
|
final TransactionLineRepository _lineRepository;
|
||||||
|
final TransactionCustomerRepository _customerRepository;
|
||||||
final InventoryLookupRepository _lookupRepository;
|
final InventoryLookupRepository _lookupRepository;
|
||||||
final List<String> _fallbackStatusOptions;
|
final List<String> _fallbackStatusOptions;
|
||||||
final List<String> _transactionTypeKeywords;
|
final List<String> _transactionTypeKeywords;
|
||||||
@@ -46,6 +49,8 @@ class InboundController extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
StockTransactionListFilter? _lastFilter;
|
StockTransactionListFilter? _lastFilter;
|
||||||
final Set<int> _processingTransactionIds = <int>{};
|
final Set<int> _processingTransactionIds = <int>{};
|
||||||
|
List<LookupItem> _approvalStatuses = const [];
|
||||||
|
LookupItem? _defaultApprovalStatus;
|
||||||
|
|
||||||
UnmodifiableListView<String> get statusOptions =>
|
UnmodifiableListView<String> get statusOptions =>
|
||||||
UnmodifiableListView(_statusOptions);
|
UnmodifiableListView(_statusOptions);
|
||||||
@@ -54,6 +59,9 @@ class InboundController extends ChangeNotifier {
|
|||||||
UnmodifiableMapView(_statusLookup);
|
UnmodifiableMapView(_statusLookup);
|
||||||
|
|
||||||
LookupItem? get transactionType => _transactionType;
|
LookupItem? get transactionType => _transactionType;
|
||||||
|
LookupItem? get defaultApprovalStatus => _defaultApprovalStatus;
|
||||||
|
UnmodifiableListView<LookupItem> get approvalStatuses =>
|
||||||
|
UnmodifiableListView(_approvalStatuses);
|
||||||
|
|
||||||
PaginatedResult<StockTransaction>? get result => _result;
|
PaginatedResult<StockTransaction>? get result => _result;
|
||||||
|
|
||||||
@@ -97,6 +105,21 @@ class InboundController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 결재 상태 목록을 조회한다.
|
||||||
|
Future<void> loadApprovalStatuses() async {
|
||||||
|
try {
|
||||||
|
final items = await _lookupRepository.fetchApprovalStatuses();
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_approvalStatuses = items;
|
||||||
|
_defaultApprovalStatus = _resolveDefaultApprovalStatus(items);
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {
|
||||||
|
// 오류 시 기존 상태 유지.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 입고 트랜잭션 타입을 조회한다.
|
/// 입고 트랜잭션 타입을 조회한다.
|
||||||
Future<bool> resolveTransactionType() async {
|
Future<bool> resolveTransactionType() async {
|
||||||
if (_transactionType != null) {
|
if (_transactionType != null) {
|
||||||
@@ -156,6 +179,15 @@ class InboundController extends ChangeNotifier {
|
|||||||
await fetchTransactions(filter: target);
|
await fetchTransactions(filter: target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LookupItem? _resolveDefaultApprovalStatus(List<LookupItem> items) {
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.isDefault) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.isEmpty ? null : items.first;
|
||||||
|
}
|
||||||
|
|
||||||
/// 재고 트랜잭션을 생성하고 필요 시 목록을 갱신한다.
|
/// 재고 트랜잭션을 생성하고 필요 시 목록을 갱신한다.
|
||||||
Future<InboundRecord> createTransaction(
|
Future<InboundRecord> createTransaction(
|
||||||
StockTransactionCreateInput input, {
|
StockTransactionCreateInput input, {
|
||||||
@@ -448,4 +480,29 @@ class InboundController extends ChangeNotifier {
|
|||||||
await _lineRepository.addLines(transactionId, plan.createdLines);
|
await _lineRepository.addLines(transactionId, plan.createdLines);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 고객 연결 변경 계획을 실행하여 파트너사 정보를 동기화한다.
|
||||||
|
Future<void> syncTransactionCustomers(
|
||||||
|
int transactionId,
|
||||||
|
TransactionCustomerSyncPlan plan,
|
||||||
|
) async {
|
||||||
|
if (!plan.hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (plan.updatedCustomers.isNotEmpty) {
|
||||||
|
await _customerRepository.updateCustomers(
|
||||||
|
transactionId,
|
||||||
|
plan.updatedCustomers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final linkId in plan.deletedCustomerIds) {
|
||||||
|
await _customerRepository.deleteCustomer(linkId);
|
||||||
|
}
|
||||||
|
if (plan.createdCustomers.isNotEmpty) {
|
||||||
|
await _customerRepository.addCustomers(
|
||||||
|
transactionId,
|
||||||
|
plan.createdCustomers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,18 @@ class InboundRecord {
|
|||||||
required this.items,
|
required this.items,
|
||||||
this.expectedReturnDate,
|
this.expectedReturnDate,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
|
this.partnerLinkId,
|
||||||
|
this.partnerId,
|
||||||
|
this.partnerCode,
|
||||||
|
this.partnerName,
|
||||||
this.raw,
|
this.raw,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory InboundRecord.fromTransaction(StockTransaction transaction) {
|
factory InboundRecord.fromTransaction(StockTransaction transaction) {
|
||||||
|
final partnerLink = transaction.customers.isNotEmpty
|
||||||
|
? transaction.customers.first
|
||||||
|
: null;
|
||||||
|
final partnerSummary = partnerLink?.customer;
|
||||||
return InboundRecord(
|
return InboundRecord(
|
||||||
id: transaction.id,
|
id: transaction.id,
|
||||||
number: transaction.transactionNo,
|
number: transaction.transactionNo,
|
||||||
@@ -48,6 +56,10 @@ class InboundRecord {
|
|||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
expectedReturnDate: transaction.expectedReturnDate,
|
expectedReturnDate: transaction.expectedReturnDate,
|
||||||
isActive: transaction.isActive,
|
isActive: transaction.isActive,
|
||||||
|
partnerLinkId: partnerLink?.id,
|
||||||
|
partnerId: partnerSummary?.id,
|
||||||
|
partnerCode: partnerSummary?.code,
|
||||||
|
partnerName: partnerSummary?.name,
|
||||||
raw: transaction,
|
raw: transaction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,6 +83,10 @@ class InboundRecord {
|
|||||||
final List<InboundLineItem> items;
|
final List<InboundLineItem> items;
|
||||||
final DateTime? expectedReturnDate;
|
final DateTime? expectedReturnDate;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
final int? partnerLinkId;
|
||||||
|
final int? partnerId;
|
||||||
|
final String? partnerCode;
|
||||||
|
final String? partnerName;
|
||||||
final StockTransaction? raw;
|
final StockTransaction? raw;
|
||||||
|
|
||||||
int get itemCount => items.length;
|
int get itemCount => items.length;
|
||||||
@@ -80,6 +96,42 @@ class InboundRecord {
|
|||||||
|
|
||||||
double get totalAmount =>
|
double get totalAmount =>
|
||||||
items.fold<double>(0, (sum, item) => sum + (item.price * item.quantity));
|
items.fold<double>(0, (sum, item) => sum + (item.price * item.quantity));
|
||||||
|
|
||||||
|
/// 파트너사 정보가 존재하는지 여부를 반환한다.
|
||||||
|
bool get hasPartner =>
|
||||||
|
partnerId != null && (partnerName?.trim().isNotEmpty ?? false);
|
||||||
|
|
||||||
|
/// 현재 레코드에 연결된 파트너사 고객 정보를 반환한다.
|
||||||
|
StockTransactionCustomer? get primaryCustomer {
|
||||||
|
final rawCustomers = raw?.customers;
|
||||||
|
if (rawCustomers != null && rawCustomers.isNotEmpty) {
|
||||||
|
return rawCustomers.first;
|
||||||
|
}
|
||||||
|
if (!hasPartner) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return StockTransactionCustomer(
|
||||||
|
id: partnerLinkId,
|
||||||
|
customer: StockTransactionCustomerSummary(
|
||||||
|
id: partnerId!,
|
||||||
|
code: partnerCode ?? '',
|
||||||
|
name: partnerName ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 현재 파트너사 고객 목록을 반환한다. `raw` 정보가 없을 경우 보조 필드를 활용한다.
|
||||||
|
List<StockTransactionCustomer> get customers {
|
||||||
|
final rawCustomers = raw?.customers;
|
||||||
|
if (rawCustomers != null && rawCustomers.isNotEmpty) {
|
||||||
|
return rawCustomers;
|
||||||
|
}
|
||||||
|
final fallback = primaryCustomer;
|
||||||
|
if (fallback == null) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return [fallback];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 입고 상세 모달에서 사용하는 품목 정보.
|
/// 입고 상세 모달에서 사용하는 품목 정보.
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import 'package:superport_v2/widgets/components/empty_state.dart';
|
|||||||
import 'package:superport_v2/widgets/components/responsive.dart';
|
import 'package:superport_v2/widgets/components/responsive.dart';
|
||||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||||
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
|
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/shared/widgets/partner_select_field.dart';
|
||||||
import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart';
|
import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart';
|
||||||
import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart';
|
|
||||||
import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart';
|
import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart';
|
||||||
import 'package:superport_v2/core/config/environment.dart';
|
import 'package:superport_v2/core/config/environment.dart';
|
||||||
import 'package:superport_v2/core/network/failure.dart';
|
import 'package:superport_v2/core/network/failure.dart';
|
||||||
@@ -28,7 +28,10 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
|
|||||||
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||||
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||||
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
|
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
|
||||||
|
import 'package:superport_v2/features/auth/application/auth_service.dart';
|
||||||
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
|
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart'
|
||||||
|
show InventoryEmployeeSuggestion;
|
||||||
import '../../../lookups/domain/entities/lookup_item.dart';
|
import '../../../lookups/domain/entities/lookup_item.dart';
|
||||||
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
|
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||||
import '../widgets/inbound_detail_view.dart';
|
import '../widgets/inbound_detail_view.dart';
|
||||||
@@ -112,12 +115,14 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
final getIt = GetIt.I;
|
final getIt = GetIt.I;
|
||||||
if (!getIt.isRegistered<StockTransactionRepository>() ||
|
if (!getIt.isRegistered<StockTransactionRepository>() ||
|
||||||
!getIt.isRegistered<TransactionLineRepository>() ||
|
!getIt.isRegistered<TransactionLineRepository>() ||
|
||||||
|
!getIt.isRegistered<TransactionCustomerRepository>() ||
|
||||||
!getIt.isRegistered<InventoryLookupRepository>()) {
|
!getIt.isRegistered<InventoryLookupRepository>()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return InboundController(
|
return InboundController(
|
||||||
transactionRepository: getIt<StockTransactionRepository>(),
|
transactionRepository: getIt<StockTransactionRepository>(),
|
||||||
lineRepository: getIt<TransactionLineRepository>(),
|
lineRepository: getIt<TransactionLineRepository>(),
|
||||||
|
customerRepository: getIt<TransactionCustomerRepository>(),
|
||||||
lookupRepository: getIt<InventoryLookupRepository>(),
|
lookupRepository: getIt<InventoryLookupRepository>(),
|
||||||
fallbackStatusOptions: InboundTableSpec.fallbackStatusOptions,
|
fallbackStatusOptions: InboundTableSpec.fallbackStatusOptions,
|
||||||
transactionTypeKeywords: InboundTableSpec.transactionTypeKeywords,
|
transactionTypeKeywords: InboundTableSpec.transactionTypeKeywords,
|
||||||
@@ -131,6 +136,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
}
|
}
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await controller.loadStatusOptions();
|
await controller.loadStatusOptions();
|
||||||
|
await controller.loadApprovalStatuses();
|
||||||
final hasType = await controller.resolveTransactionType();
|
final hasType = await controller.resolveTransactionType();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -804,12 +810,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
actions.add(
|
actions.add(
|
||||||
ShadButton.outline(
|
ShadButton.outline(
|
||||||
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
|
||||||
onPressed: isProcessing
|
onPressed: isProcessing ? null : () => _openEditFromDetail(record),
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
Navigator.of(context).maybePop();
|
|
||||||
_handleEdit(record);
|
|
||||||
},
|
|
||||||
child: const Text('수정'),
|
child: const Text('수정'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -817,6 +818,17 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openEditFromDetail(InboundRecord record) {
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
navigator.pop();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _handleEdit(record, reopenOnCancel: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleCreate() async {
|
Future<void> _handleCreate() async {
|
||||||
final record = await _showInboundFormDialog();
|
final record = await _showInboundFormDialog();
|
||||||
if (record != null) {
|
if (record != null) {
|
||||||
@@ -824,10 +836,18 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleEdit(InboundRecord record) async {
|
Future<void> _handleEdit(
|
||||||
|
InboundRecord record, {
|
||||||
|
bool reopenOnCancel = false,
|
||||||
|
}) async {
|
||||||
final updated = await _showInboundFormDialog(initial: record);
|
final updated = await _showInboundFormDialog(initial: record);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (updated != null) {
|
if (updated != null) {
|
||||||
_selectRecord(updated, openDetail: true);
|
_selectRecord(updated, openDetail: true);
|
||||||
|
} else if (reopenOnCancel) {
|
||||||
|
_selectRecord(record, openDetail: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,6 +1384,26 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InventoryEmployeeSuggestion? _resolveCurrentWriter() {
|
||||||
|
final getIt = GetIt.I;
|
||||||
|
if (!getIt.isRegistered<AuthService>()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final session = getIt<AuthService>().session;
|
||||||
|
final user = session?.user;
|
||||||
|
if (user == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final employeeNo = (user.employeeNo ?? '').trim().isEmpty
|
||||||
|
? user.id.toString()
|
||||||
|
: user.employeeNo!.trim();
|
||||||
|
return InventoryEmployeeSuggestion(
|
||||||
|
id: user.id,
|
||||||
|
employeeNo: employeeNo,
|
||||||
|
name: user.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _calculateTotalPages(int totalItems) {
|
int _calculateTotalPages(int totalItems) {
|
||||||
if (totalItems <= 0) {
|
if (totalItems <= 0) {
|
||||||
return 1;
|
return 1;
|
||||||
@@ -1397,6 +1437,27 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
final statusValue = ValueNotifier<String>(
|
final statusValue = ValueNotifier<String>(
|
||||||
initial?.status ?? _statusOptions.first,
|
initial?.status ?? _statusOptions.first,
|
||||||
);
|
);
|
||||||
|
final initialPartnerOption = () {
|
||||||
|
final customers = initial?.raw?.customers;
|
||||||
|
if (customers != null && customers.isNotEmpty) {
|
||||||
|
final summary = customers.first.customer;
|
||||||
|
return InventoryPartnerOption(
|
||||||
|
id: summary.id,
|
||||||
|
name: summary.name,
|
||||||
|
code: summary.code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (initial?.partnerId != null &&
|
||||||
|
(initial?.partnerName?.trim().isNotEmpty ?? false)) {
|
||||||
|
return InventoryPartnerOption(
|
||||||
|
id: initial!.partnerId!,
|
||||||
|
name: initial.partnerName!.trim(),
|
||||||
|
code: (initial.partnerCode ?? '').trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}();
|
||||||
|
InventoryPartnerOption? partnerSelection = initialPartnerOption;
|
||||||
InventoryEmployeeSuggestion? writerSelection;
|
InventoryEmployeeSuggestion? writerSelection;
|
||||||
final initialWriter = initial?.raw?.createdBy;
|
final initialWriter = initial?.raw?.createdBy;
|
||||||
if (initialWriter != null) {
|
if (initialWriter != null) {
|
||||||
@@ -1412,6 +1473,8 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
employeeNo: initial.writer,
|
employeeNo: initial.writer,
|
||||||
name: initial.writer,
|
name: initial.writer,
|
||||||
);
|
);
|
||||||
|
} else if (initial == null) {
|
||||||
|
writerSelection = _resolveCurrentWriter();
|
||||||
}
|
}
|
||||||
String writerLabel(InventoryEmployeeSuggestion? suggestion) {
|
String writerLabel(InventoryEmployeeSuggestion? suggestion) {
|
||||||
if (suggestion == null) {
|
if (suggestion == null) {
|
||||||
@@ -1448,6 +1511,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
|
|
||||||
String? writerError;
|
String? writerError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
|
String? partnerError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
StateSetter? refreshForm;
|
StateSetter? refreshForm;
|
||||||
@@ -1457,7 +1521,7 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
|
|
||||||
InboundRecord? result;
|
InboundRecord? result;
|
||||||
|
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
|
||||||
void updateSaving(bool next) {
|
void updateSaving(bool next) {
|
||||||
isSaving = next;
|
isSaving = next;
|
||||||
@@ -1476,12 +1540,14 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
writerSelection: writerSelection,
|
writerSelection: writerSelection,
|
||||||
requireWriterSelection: initial == null,
|
requireWriterSelection: initial == null,
|
||||||
warehouseSelection: warehouseSelection,
|
warehouseSelection: warehouseSelection,
|
||||||
|
partnerSelection: partnerSelection,
|
||||||
statusValue: statusValue.value,
|
statusValue: statusValue.value,
|
||||||
drafts: drafts,
|
drafts: drafts,
|
||||||
lineErrors: lineErrors,
|
lineErrors: lineErrors,
|
||||||
);
|
);
|
||||||
writerError = validationResult.writerError;
|
writerError = validationResult.writerError;
|
||||||
warehouseError = validationResult.warehouseError;
|
warehouseError = validationResult.warehouseError;
|
||||||
|
partnerError = validationResult.partnerError;
|
||||||
statusError = validationResult.statusError;
|
statusError = validationResult.statusError;
|
||||||
headerNotice = validationResult.headerNotice;
|
headerNotice = validationResult.headerNotice;
|
||||||
refreshForm?.call(() {});
|
refreshForm?.call(() {});
|
||||||
@@ -1556,6 +1622,14 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final createCustomers = () {
|
||||||
|
final partner = partnerSelection;
|
||||||
|
if (partner == null) {
|
||||||
|
return const <TransactionCustomerCreateInput>[];
|
||||||
|
}
|
||||||
|
return [TransactionCustomerCreateInput(customerId: partner.id)];
|
||||||
|
}();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateSaving(true);
|
updateSaving(true);
|
||||||
|
|
||||||
@@ -1576,12 +1650,37 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
result = updated;
|
result = updated;
|
||||||
final currentLines =
|
final currentLines =
|
||||||
initialRecord.raw?.lines ?? const <StockTransactionLine>[];
|
initialRecord.raw?.lines ?? const <StockTransactionLine>[];
|
||||||
final plan = _detailSyncService.buildLinePlan(
|
final currentCustomers = initialRecord.customers;
|
||||||
|
final customerDrafts = () {
|
||||||
|
final partner = partnerSelection;
|
||||||
|
if (partner == null) {
|
||||||
|
return const <TransactionCustomerDraft>[];
|
||||||
|
}
|
||||||
|
final existing = initialRecord.primaryCustomer;
|
||||||
|
return [
|
||||||
|
TransactionCustomerDraft(
|
||||||
|
id: existing?.id ?? initialRecord.partnerLinkId,
|
||||||
|
customerId: partner.id,
|
||||||
|
note: existing?.note,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}();
|
||||||
|
final linePlan = _detailSyncService.buildLinePlan(
|
||||||
drafts: lineDrafts,
|
drafts: lineDrafts,
|
||||||
currentLines: currentLines,
|
currentLines: currentLines,
|
||||||
);
|
);
|
||||||
if (plan.hasChanges) {
|
final customerPlan = _detailSyncService.buildCustomerPlan(
|
||||||
await controller.syncTransactionLines(transactionId, plan);
|
drafts: customerDrafts,
|
||||||
|
currentCustomers: currentCustomers,
|
||||||
|
);
|
||||||
|
if (linePlan.hasChanges) {
|
||||||
|
await controller.syncTransactionLines(transactionId, linePlan);
|
||||||
|
}
|
||||||
|
if (customerPlan.hasChanges) {
|
||||||
|
await controller.syncTransactionCustomers(
|
||||||
|
transactionId,
|
||||||
|
customerPlan,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await controller.refresh();
|
await controller.refresh();
|
||||||
updateSaving(false);
|
updateSaving(false);
|
||||||
@@ -1602,6 +1701,23 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final approvalStatusId = () {
|
||||||
|
final defaultStatus = controller.defaultApprovalStatus;
|
||||||
|
if (defaultStatus != null) {
|
||||||
|
return defaultStatus.id;
|
||||||
|
}
|
||||||
|
final statuses = controller.approvalStatuses;
|
||||||
|
if (statuses.isNotEmpty) {
|
||||||
|
return statuses.first.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}();
|
||||||
|
if (approvalStatusId == null) {
|
||||||
|
updateSaving(false);
|
||||||
|
SuperportToast.error(context, '결재 상태 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final createLines = lineDrafts
|
final createLines = lineDrafts
|
||||||
.map(
|
.map(
|
||||||
(draft) => TransactionLineCreateInput(
|
(draft) => TransactionLineCreateInput(
|
||||||
@@ -1613,21 +1729,29 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
final created = await controller.createTransaction(
|
final createInput = StockTransactionCreateInput(
|
||||||
StockTransactionCreateInput(
|
transactionTypeId: transactionTypeLookup.id,
|
||||||
transactionTypeId: transactionTypeLookup.id,
|
transactionStatusId: statusItem.id,
|
||||||
transactionStatusId: statusItem.id,
|
warehouseId: warehouseId,
|
||||||
warehouseId: warehouseId,
|
transactionDate: processedAt.value,
|
||||||
transactionDate: processedAt.value,
|
createdById: createdById,
|
||||||
createdById: createdById,
|
note: remarkValue,
|
||||||
note: remarkValue,
|
lines: createLines,
|
||||||
lines: createLines,
|
customers: createCustomers,
|
||||||
approval: StockTransactionApprovalInput(
|
approval: StockTransactionApprovalInput(
|
||||||
requestedById: createdById,
|
requestedById: createdById,
|
||||||
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
|
approvalStatusId: approvalStatusId,
|
||||||
),
|
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
assert(() {
|
||||||
|
debugPrint(
|
||||||
|
'[InboundForm] POST /stock-transactions payload: '
|
||||||
|
'${createInput.toPayload()}',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
final created = await controller.createTransaction(createInput);
|
||||||
result = created;
|
result = created;
|
||||||
updateSaving(false);
|
updateSaving(false);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -1643,6 +1767,20 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
assert(() {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
debugPrint(
|
||||||
|
'[InboundForm] 저장 실패: status=${failure.statusCode} '
|
||||||
|
'message="${failure.describe()}"',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'[InboundForm] 요청 페이로드: '
|
||||||
|
'{transactionTypeId: ${transactionTypeLookup.id}, '
|
||||||
|
'statusId: ${statusItem.id}, warehouseId: $warehouseId, '
|
||||||
|
'createdById: $createdById, lineCount: ${lineDrafts.length}}',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
SuperportToast.error(
|
SuperportToast.error(
|
||||||
context,
|
context,
|
||||||
_failureMessage(error, '저장 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.'),
|
_failureMessage(error, '저장 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.'),
|
||||||
@@ -1734,6 +1872,26 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: SuperportFormField(
|
||||||
|
label: '파트너사',
|
||||||
|
required: true,
|
||||||
|
errorText: partnerError,
|
||||||
|
child: InventoryPartnerSelectField(
|
||||||
|
initialPartner: partnerSelection,
|
||||||
|
enabled: !isSaving,
|
||||||
|
onChanged: (option) {
|
||||||
|
partnerSelection = option;
|
||||||
|
setState(() {
|
||||||
|
if (partnerError != null) {
|
||||||
|
partnerError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 240,
|
width: 240,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
@@ -1808,35 +1966,10 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
label: '작성자',
|
label: '작성자',
|
||||||
required: true,
|
required: true,
|
||||||
errorText: writerError,
|
errorText: writerError,
|
||||||
child: InventoryEmployeeAutocompleteField(
|
child: ShadInput(
|
||||||
controller: writerController,
|
controller: writerController,
|
||||||
initialSuggestion: writerSelection,
|
readOnly: true,
|
||||||
enabled: initial == null,
|
enabled: false,
|
||||||
onSuggestionSelected: (suggestion) {
|
|
||||||
writerSelection = suggestion;
|
|
||||||
if (writerError != null) {
|
|
||||||
setState(() {
|
|
||||||
writerError = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onChanged: () {
|
|
||||||
if (initial == null) {
|
|
||||||
final currentText = writerController.text.trim();
|
|
||||||
final selectedLabel = writerLabel(
|
|
||||||
writerSelection,
|
|
||||||
);
|
|
||||||
if (currentText.isEmpty ||
|
|
||||||
currentText != selectedLabel) {
|
|
||||||
writerSelection = null;
|
|
||||||
}
|
|
||||||
if (writerError != null) {
|
|
||||||
setState(() {
|
|
||||||
writerError = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1927,32 +2060,39 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
for (final draft in drafts)
|
for (var index = 0; index < drafts.length; index++)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: _LineItemRow(
|
child: Builder(
|
||||||
draft: draft,
|
builder: (_) {
|
||||||
onRemove: drafts.length == 1
|
final draft = drafts[index];
|
||||||
? null
|
return _LineItemRow(
|
||||||
: () => setState(() {
|
draft: draft,
|
||||||
draft.dispose();
|
showLabels: index == 0,
|
||||||
drafts.remove(draft);
|
onRemove: drafts.length == 1
|
||||||
|
? null
|
||||||
|
: () => setState(() {
|
||||||
|
draft.dispose();
|
||||||
|
drafts.remove(draft);
|
||||||
|
headerNotice = null;
|
||||||
|
lineErrors.remove(draft);
|
||||||
|
}),
|
||||||
|
errors:
|
||||||
|
lineErrors[draft] ??
|
||||||
|
_LineItemFieldErrors.empty(),
|
||||||
|
onFieldChanged: (field) {
|
||||||
|
setState(() {
|
||||||
headerNotice = null;
|
headerNotice = null;
|
||||||
lineErrors.remove(draft);
|
final error = lineErrors[draft];
|
||||||
}),
|
if (error == null) {
|
||||||
errors:
|
lineErrors[draft] =
|
||||||
lineErrors[draft] ?? _LineItemFieldErrors.empty(),
|
_LineItemFieldErrors.empty();
|
||||||
onFieldChanged: (field) {
|
} else {
|
||||||
setState(() {
|
error.clearField(field);
|
||||||
headerNotice = null;
|
}
|
||||||
final error = lineErrors[draft];
|
});
|
||||||
if (error == null) {
|
},
|
||||||
lineErrors[draft] =
|
);
|
||||||
_LineItemFieldErrors.empty();
|
|
||||||
} else {
|
|
||||||
error.clearField(field);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1965,15 +2105,25 @@ class _InboundPageState extends State<InboundPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (final draft in drafts) {
|
final disposeDrafts = List<_LineItemDraft>.from(drafts);
|
||||||
draft.dispose();
|
final disposeStatus = statusValue;
|
||||||
}
|
final disposeWriterController = writerController;
|
||||||
statusValue.dispose();
|
final disposeRemarkController = remarkController;
|
||||||
writerController.dispose();
|
final disposeApprovalNoteController = approvalNoteController;
|
||||||
remarkController.dispose();
|
final disposeTransactionTypeController = transactionTypeController;
|
||||||
approvalNoteController.dispose();
|
final disposeProcessedAt = processedAt;
|
||||||
transactionTypeController.dispose();
|
|
||||||
processedAt.dispose();
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
for (final draft in disposeDrafts) {
|
||||||
|
draft.dispose();
|
||||||
|
}
|
||||||
|
disposeStatus.dispose();
|
||||||
|
disposeWriterController.dispose();
|
||||||
|
disposeRemarkController.dispose();
|
||||||
|
disposeApprovalNoteController.dispose();
|
||||||
|
disposeTransactionTypeController.dispose();
|
||||||
|
disposeProcessedAt.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -2315,6 +2465,7 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
required InventoryEmployeeSuggestion? writerSelection,
|
required InventoryEmployeeSuggestion? writerSelection,
|
||||||
required bool requireWriterSelection,
|
required bool requireWriterSelection,
|
||||||
required InventoryWarehouseOption? warehouseSelection,
|
required InventoryWarehouseOption? warehouseSelection,
|
||||||
|
required InventoryPartnerOption? partnerSelection,
|
||||||
required String statusValue,
|
required String statusValue,
|
||||||
required List<_LineItemDraft> drafts,
|
required List<_LineItemDraft> drafts,
|
||||||
required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors,
|
required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors,
|
||||||
@@ -2322,6 +2473,7 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
var isValid = true;
|
var isValid = true;
|
||||||
String? writerError;
|
String? writerError;
|
||||||
String? warehouseError;
|
String? warehouseError;
|
||||||
|
String? partnerError;
|
||||||
String? statusError;
|
String? statusError;
|
||||||
String? headerNotice;
|
String? headerNotice;
|
||||||
|
|
||||||
@@ -2341,6 +2493,11 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (partnerSelection == null) {
|
||||||
|
partnerError = '파트너사를 선택하세요.';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (statusValue.trim().isEmpty) {
|
if (statusValue.trim().isEmpty) {
|
||||||
statusError = '상태를 선택하세요.';
|
statusError = '상태를 선택하세요.';
|
||||||
isValid = false;
|
isValid = false;
|
||||||
@@ -2369,7 +2526,6 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
hasLineError = true;
|
hasLineError = true;
|
||||||
isValid = false;
|
isValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final quantity = int.tryParse(
|
final quantity = int.tryParse(
|
||||||
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
|
||||||
);
|
);
|
||||||
@@ -2395,6 +2551,7 @@ _InboundFormValidation _validateInboundForm({
|
|||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
writerError: writerError,
|
writerError: writerError,
|
||||||
warehouseError: warehouseError,
|
warehouseError: warehouseError,
|
||||||
|
partnerError: partnerError,
|
||||||
statusError: statusError,
|
statusError: statusError,
|
||||||
headerNotice: headerNotice,
|
headerNotice: headerNotice,
|
||||||
);
|
);
|
||||||
@@ -2410,6 +2567,7 @@ class _InboundFormValidation {
|
|||||||
required this.isValid,
|
required this.isValid,
|
||||||
this.writerError,
|
this.writerError,
|
||||||
this.warehouseError,
|
this.warehouseError,
|
||||||
|
this.partnerError,
|
||||||
this.statusError,
|
this.statusError,
|
||||||
this.headerNotice,
|
this.headerNotice,
|
||||||
});
|
});
|
||||||
@@ -2417,6 +2575,7 @@ class _InboundFormValidation {
|
|||||||
final bool isValid;
|
final bool isValid;
|
||||||
final String? writerError;
|
final String? writerError;
|
||||||
final String? warehouseError;
|
final String? warehouseError;
|
||||||
|
final String? partnerError;
|
||||||
final String? statusError;
|
final String? statusError;
|
||||||
final String? headerNotice;
|
final String? headerNotice;
|
||||||
}
|
}
|
||||||
@@ -2454,12 +2613,14 @@ enum _InboundSortField { processedAt, warehouse, status, writer }
|
|||||||
class _LineItemRow extends StatelessWidget {
|
class _LineItemRow extends StatelessWidget {
|
||||||
const _LineItemRow({
|
const _LineItemRow({
|
||||||
required this.draft,
|
required this.draft,
|
||||||
|
required this.showLabels,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
required this.errors,
|
required this.errors,
|
||||||
required this.onFieldChanged,
|
required this.onFieldChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final _LineItemDraft draft;
|
final _LineItemDraft draft;
|
||||||
|
final bool showLabels;
|
||||||
final VoidCallback? onRemove;
|
final VoidCallback? onRemove;
|
||||||
final _LineItemFieldErrors errors;
|
final _LineItemFieldErrors errors;
|
||||||
final void Function(_LineItemField field) onFieldChanged;
|
final void Function(_LineItemField field) onFieldChanged;
|
||||||
@@ -2471,8 +2632,8 @@ class _LineItemRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '제품',
|
label: showLabels ? '제품' : '',
|
||||||
required: true,
|
required: showLabels,
|
||||||
errorText: errors.product,
|
errorText: errors.product,
|
||||||
child: InventoryProductAutocompleteField(
|
child: InventoryProductAutocompleteField(
|
||||||
productController: draft.product,
|
productController: draft.product,
|
||||||
@@ -2492,8 +2653,8 @@ class _LineItemRow extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '제조사',
|
label: showLabels ? '제조사' : '',
|
||||||
caption: '제품 선택 시 자동 입력됩니다.',
|
caption: showLabels ? '제품 선택 시 자동 입력됩니다.' : null,
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: draft.manufacturer,
|
controller: draft.manufacturer,
|
||||||
placeholder: const Text('자동 입력'),
|
placeholder: const Text('자동 입력'),
|
||||||
@@ -2506,8 +2667,8 @@ class _LineItemRow extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 80,
|
width: 80,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '단위',
|
label: showLabels ? '단위' : '',
|
||||||
caption: '제품에 연결된 단위입니다.',
|
caption: showLabels ? '제품에 연결된 단위입니다.' : null,
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: draft.unit,
|
controller: draft.unit,
|
||||||
placeholder: const Text('자동'),
|
placeholder: const Text('자동'),
|
||||||
@@ -2520,8 +2681,8 @@ class _LineItemRow extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 100,
|
width: 100,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '수량',
|
label: showLabels ? '수량' : '',
|
||||||
required: true,
|
required: showLabels,
|
||||||
errorText: errors.quantity,
|
errorText: errors.quantity,
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: draft.quantity,
|
controller: draft.quantity,
|
||||||
@@ -2535,8 +2696,8 @@ class _LineItemRow extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 120,
|
width: 120,
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '단가',
|
label: showLabels ? '단가' : '',
|
||||||
required: true,
|
required: showLabels,
|
||||||
errorText: errors.price,
|
errorText: errors.price,
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: draft.price,
|
controller: draft.price,
|
||||||
@@ -2549,7 +2710,7 @@ class _LineItemRow extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SuperportFormField(
|
child: SuperportFormField(
|
||||||
label: '비고',
|
label: showLabels ? '비고' : '',
|
||||||
child: ShadInput(
|
child: ShadInput(
|
||||||
controller: draft.remark,
|
controller: draft.remark,
|
||||||
placeholder: const Text('비고'),
|
placeholder: const Text('비고'),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class InboundTableSpec {
|
|||||||
static const List<int> pageSizeOptions = [10, 20, 50];
|
static const List<int> pageSizeOptions = [10, 20, 50];
|
||||||
|
|
||||||
/// API include 파라미터 기본값.
|
/// API include 파라미터 기본값.
|
||||||
static const List<String> defaultIncludeOptions = ['lines'];
|
static const List<String> defaultIncludeOptions = ['lines', 'customers'];
|
||||||
|
|
||||||
/// 백엔드에서 상태 목록을 내려주지 않을 때 사용되는 기본 상태 라벨.
|
/// 백엔드에서 상태 목록을 내려주지 않을 때 사용되는 기본 상태 라벨.
|
||||||
static const List<String> fallbackStatusOptions = ['작성중', '승인대기', '승인완료'];
|
static const List<String> fallbackStatusOptions = ['작성중', '승인대기', '승인완료'];
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ class InboundDetailView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (!transitionsEnabled) ...[
|
if (!transitionsEnabled) ...[
|
||||||
ShadBadge.outline(
|
ShadBadge.outline(
|
||||||
child: Text(
|
child: Text('재고 상태 전이가 비활성화된 상태입니다.', style: theme.textTheme.small),
|
||||||
'재고 상태 전이가 비활성화된 상태입니다.',
|
|
||||||
style: theme.textTheme.small,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
@@ -55,6 +52,12 @@ class InboundDetailView extends StatelessWidget {
|
|||||||
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
|
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
|
||||||
_DetailChip(label: '상태', value: record.status),
|
_DetailChip(label: '상태', value: record.status),
|
||||||
_DetailChip(label: '작성자', value: record.writer),
|
_DetailChip(label: '작성자', value: record.writer),
|
||||||
|
if (record.partnerName != null &&
|
||||||
|
record.partnerName!.trim().isNotEmpty)
|
||||||
|
_DetailChip(label: '파트너사', value: record.partnerName!.trim()),
|
||||||
|
if (record.partnerCode != null &&
|
||||||
|
record.partnerCode!.trim().isNotEmpty)
|
||||||
|
_DetailChip(label: '파트너 코드', value: record.partnerCode!.trim()),
|
||||||
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
|
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
|
||||||
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
|
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
|
||||||
_DetailChip(
|
_DetailChip(
|
||||||
@@ -93,9 +96,7 @@ class InboundDetailView extends StatelessWidget {
|
|||||||
ShadTableCell(child: Text(item.manufacturer)),
|
ShadTableCell(child: Text(item.manufacturer)),
|
||||||
ShadTableCell(child: Text(item.unit)),
|
ShadTableCell(child: Text(item.unit)),
|
||||||
ShadTableCell(child: Text('${item.quantity}')),
|
ShadTableCell(child: Text('${item.quantity}')),
|
||||||
ShadTableCell(
|
ShadTableCell(child: Text(currencyFormatter.format(item.price))),
|
||||||
child: Text(currencyFormatter.format(item.price)),
|
|
||||||
),
|
|
||||||
ShadTableCell(
|
ShadTableCell(
|
||||||
child: Text(item.remark.isEmpty ? '-' : item.remark),
|
child: Text(item.remark.isEmpty ? '-' : item.remark),
|
||||||
),
|
),
|
||||||
@@ -128,13 +129,13 @@ class _DetailChip extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: theme.textTheme.small, textAlign: TextAlign.center),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
Text(
|
||||||
value,
|
label,
|
||||||
style: theme.textTheme.p,
|
style: theme.textTheme.small,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(value, style: theme.textTheme.p, textAlign: TextAlign.center),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1615,7 +1615,7 @@ class _OutboundPageState extends State<OutboundPage> {
|
|||||||
|
|
||||||
OutboundRecord? result;
|
OutboundRecord? result;
|
||||||
|
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
|
||||||
void updateSaving(bool next) {
|
void updateSaving(bool next) {
|
||||||
isSaving = next;
|
isSaving = next;
|
||||||
|
|||||||
@@ -1592,7 +1592,7 @@ class _RentalPageState extends State<RentalPage> {
|
|||||||
for (final draft in drafts) draft: _RentalLineItemErrors.empty(),
|
for (final draft in drafts) draft: _RentalLineItemErrors.empty(),
|
||||||
};
|
};
|
||||||
|
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
|
||||||
void updateSaving(bool next) {
|
void updateSaving(bool next) {
|
||||||
isSaving = next;
|
isSaving = next;
|
||||||
|
|||||||
411
lib/features/inventory/shared/widgets/partner_select_field.dart
Normal file
411
lib/features/inventory/shared/widgets/partner_select_field.dart
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
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/core/common/utils/pagination_utils.dart';
|
||||||
|
import 'package:superport_v2/core/network/failure.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 InventoryPartnerOption {
|
||||||
|
InventoryPartnerOption({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory InventoryPartnerOption.fromCustomer(Customer customer) {
|
||||||
|
return InventoryPartnerOption(
|
||||||
|
id: customer.id ?? 0,
|
||||||
|
name: customer.customerName,
|
||||||
|
code: customer.customerCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
String get label => code.isEmpty ? name : '$name ($code)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 파트너사 자동완성 입력 필드.
|
||||||
|
class InventoryPartnerSelectField extends StatefulWidget {
|
||||||
|
const InventoryPartnerSelectField({
|
||||||
|
super.key,
|
||||||
|
this.initialPartner,
|
||||||
|
required this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
this.placeholder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final InventoryPartnerOption? initialPartner;
|
||||||
|
final ValueChanged<InventoryPartnerOption?> onChanged;
|
||||||
|
final bool enabled;
|
||||||
|
final Widget? placeholder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InventoryPartnerSelectField> createState() =>
|
||||||
|
_InventoryPartnerSelectFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InventoryPartnerSelectFieldState
|
||||||
|
extends State<InventoryPartnerSelectField> {
|
||||||
|
static const Duration _debounceDuration = Duration(milliseconds: 280);
|
||||||
|
|
||||||
|
CustomerRepository? get _repository =>
|
||||||
|
GetIt.I.isRegistered<CustomerRepository>()
|
||||||
|
? GetIt.I<CustomerRepository>()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
final List<InventoryPartnerOption> _baseOptions = [];
|
||||||
|
final List<InventoryPartnerOption> _suggestions = [];
|
||||||
|
InventoryPartnerOption? _selected;
|
||||||
|
Timer? _debounce;
|
||||||
|
bool _isSearching = false;
|
||||||
|
bool _isLoadingInitial = false;
|
||||||
|
String? _error;
|
||||||
|
int _requestId = 0;
|
||||||
|
bool _isApplyingText = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selected = widget.initialPartner;
|
||||||
|
_controller.addListener(_handleTextChanged);
|
||||||
|
_focusNode.addListener(_handleFocusChange);
|
||||||
|
_loadInitialOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant InventoryPartnerSelectField oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.initialPartner != oldWidget.initialPartner) {
|
||||||
|
if (widget.initialPartner != null) {
|
||||||
|
_setSelection(widget.initialPartner!, notify: false);
|
||||||
|
} else {
|
||||||
|
_clearSelection(notify: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller
|
||||||
|
..removeListener(_handleTextChanged)
|
||||||
|
..dispose();
|
||||||
|
_focusNode
|
||||||
|
..removeListener(_handleFocusChange)
|
||||||
|
..dispose();
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialOptions() async {
|
||||||
|
final repository = _repository;
|
||||||
|
if (repository == null) {
|
||||||
|
setState(() {
|
||||||
|
_error = '파트너 데이터를 불러올 수 없습니다.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoadingInitial = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final partners = await fetchAllPaginatedItems<Customer>(
|
||||||
|
request: (page, pageSize) => repository.list(
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
isPartner: true,
|
||||||
|
isActive: true,
|
||||||
|
),
|
||||||
|
pageSize: 40,
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final options = partners
|
||||||
|
.where((customer) => customer.id != null)
|
||||||
|
.map(InventoryPartnerOption.fromCustomer)
|
||||||
|
.toList(growable: false);
|
||||||
|
setState(() {
|
||||||
|
_baseOptions
|
||||||
|
..clear()
|
||||||
|
..addAll(options);
|
||||||
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(options);
|
||||||
|
_isLoadingInitial = false;
|
||||||
|
});
|
||||||
|
final initial = widget.initialPartner;
|
||||||
|
if (initial != null) {
|
||||||
|
_setSelection(initial, notify: false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
setState(() {
|
||||||
|
_error = failure.describe();
|
||||||
|
_isLoadingInitial = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTextChanged() {
|
||||||
|
if (_isApplyingText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final keyword = _controller.text.trim();
|
||||||
|
if (keyword.isEmpty) {
|
||||||
|
_debounce?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_isSearching = false;
|
||||||
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(_baseOptions);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_selected != null && keyword != _selected!.label) {
|
||||||
|
_selected = null;
|
||||||
|
widget.onChanged(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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final request = ++_requestId;
|
||||||
|
setState(() {
|
||||||
|
_isSearching = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final result = await repository.list(
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
query: keyword,
|
||||||
|
isPartner: true,
|
||||||
|
isActive: true,
|
||||||
|
);
|
||||||
|
if (!mounted || request != _requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final items = result.items
|
||||||
|
.where((customer) => customer.id != null)
|
||||||
|
.map(InventoryPartnerOption.fromCustomer)
|
||||||
|
.toList(growable: false);
|
||||||
|
setState(() {
|
||||||
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(items);
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted || request != _requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(_baseOptions);
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setSelection(InventoryPartnerOption option, {bool notify = true}) {
|
||||||
|
setState(() {
|
||||||
|
_selected = option;
|
||||||
|
if (!_baseOptions.any((item) => item.id == option.id)) {
|
||||||
|
_baseOptions.add(option);
|
||||||
|
}
|
||||||
|
_applyControllerText(option.label);
|
||||||
|
});
|
||||||
|
if (notify) {
|
||||||
|
widget.onChanged(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection({bool notify = true}) {
|
||||||
|
setState(() {
|
||||||
|
_selected = null;
|
||||||
|
_applyControllerText('');
|
||||||
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(_baseOptions);
|
||||||
|
});
|
||||||
|
if (notify) {
|
||||||
|
widget.onChanged(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyControllerText(String text) {
|
||||||
|
_isApplyingText = true;
|
||||||
|
_controller
|
||||||
|
..text = text
|
||||||
|
..selection = TextSelection.collapsed(offset: text.length);
|
||||||
|
_isApplyingText = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFocusChange() {
|
||||||
|
if (!_focusNode.hasFocus && _selected != null) {
|
||||||
|
_applyControllerText(_selected!.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (_isLoadingInitial) {
|
||||||
|
return _buildLoadingInput();
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return ShadInput(
|
||||||
|
readOnly: true,
|
||||||
|
enabled: false,
|
||||||
|
placeholder: Text(_error!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RawAutocomplete<InventoryPartnerOption>(
|
||||||
|
textEditingController: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
optionsBuilder: (value) {
|
||||||
|
final keyword = value.text.trim();
|
||||||
|
if (keyword.isEmpty) {
|
||||||
|
return _suggestions.isEmpty ? _baseOptions : _suggestions;
|
||||||
|
}
|
||||||
|
final lower = keyword.toLowerCase();
|
||||||
|
final source = _suggestions.isEmpty ? _baseOptions : _suggestions;
|
||||||
|
return source.where((option) {
|
||||||
|
return option.name.toLowerCase().contains(lower) ||
|
||||||
|
option.code.toLowerCase().contains(lower);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
displayStringForOption: (option) => option.label,
|
||||||
|
onSelected: (option) {
|
||||||
|
_setSelection(option);
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
|
||||||
|
assert(identical(controller, _controller));
|
||||||
|
final placeholder = widget.placeholder ?? const Text('파트너사를 선택하세요');
|
||||||
|
final input = ShadInput(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
enabled: widget.enabled,
|
||||||
|
readOnly: !widget.enabled,
|
||||||
|
placeholder: placeholder,
|
||||||
|
onSubmitted: (_) => onSubmitted(),
|
||||||
|
);
|
||||||
|
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: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 220, minWidth: 260),
|
||||||
|
child: ShadCard(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: const Center(child: Text('검색 결과가 없습니다.')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 260, minWidth: 260),
|
||||||
|
child: ShadCard(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final option = options.elementAt(index);
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapDown: (_) => onSelected(option),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
option.name,
|
||||||
|
style: ShadTheme.of(context).textTheme.p,
|
||||||
|
),
|
||||||
|
if (option.code.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
option.code,
|
||||||
|
style: ShadTheme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.muted.copyWith(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
|
import 'package:superport_v2/core/common/utils/pagination_utils.dart';
|
||||||
|
import 'package:superport_v2/core/network/failure.dart';
|
||||||
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
|
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
|
||||||
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
|
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
|
||||||
|
|
||||||
@@ -65,12 +68,17 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
extends State<InventoryProductAutocompleteField> {
|
extends State<InventoryProductAutocompleteField> {
|
||||||
static const Duration _debounceDuration = Duration(milliseconds: 280);
|
static const Duration _debounceDuration = Duration(milliseconds: 280);
|
||||||
|
|
||||||
|
final List<InventoryProductSuggestion> _initialOptions =
|
||||||
|
<InventoryProductSuggestion>[];
|
||||||
final List<InventoryProductSuggestion> _suggestions =
|
final List<InventoryProductSuggestion> _suggestions =
|
||||||
<InventoryProductSuggestion>[];
|
<InventoryProductSuggestion>[];
|
||||||
InventoryProductSuggestion? _selected;
|
InventoryProductSuggestion? _selected;
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
int _requestCounter = 0;
|
int _requestCounter = 0;
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
|
bool _isLoadingInitial = false;
|
||||||
|
String? _error;
|
||||||
|
bool _isApplyingText = false;
|
||||||
|
|
||||||
ProductRepository? get _repository =>
|
ProductRepository? get _repository =>
|
||||||
GetIt.I.isRegistered<ProductRepository>()
|
GetIt.I.isRegistered<ProductRepository>()
|
||||||
@@ -82,13 +90,10 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
super.initState();
|
super.initState();
|
||||||
_selected = widget.initialSuggestion;
|
_selected = widget.initialSuggestion;
|
||||||
if (_selected != null) {
|
if (_selected != null) {
|
||||||
_applySuggestion(_selected!, updateProductField: false);
|
_applySuggestionSilently(_selected!);
|
||||||
}
|
}
|
||||||
widget.productController.addListener(_handleTextChanged);
|
widget.productController.addListener(_handleTextChanged);
|
||||||
if (widget.productController.text.trim().isNotEmpty &&
|
_loadInitialOptions();
|
||||||
widget.initialSuggestion == null) {
|
|
||||||
_scheduleSearch(widget.productController.text.trim());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -99,20 +104,108 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
widget.productController.addListener(_handleTextChanged);
|
widget.productController.addListener(_handleTextChanged);
|
||||||
_scheduleSearch(widget.productController.text.trim());
|
_scheduleSearch(widget.productController.text.trim());
|
||||||
}
|
}
|
||||||
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
|
if (widget.initialSuggestion != oldWidget.initialSuggestion) {
|
||||||
widget.initialSuggestion != null) {
|
if (widget.initialSuggestion != null) {
|
||||||
_selected = widget.initialSuggestion;
|
_selected = widget.initialSuggestion;
|
||||||
_applySuggestion(widget.initialSuggestion!, updateProductField: false);
|
_applySuggestionSilently(widget.initialSuggestion!);
|
||||||
|
} else {
|
||||||
|
_selected = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialOptions() async {
|
||||||
|
final repository = _repository;
|
||||||
|
if (repository == null) {
|
||||||
|
setState(() {
|
||||||
|
_error = '제품 데이터 소스를 찾을 수 없습니다.';
|
||||||
|
_suggestions.clear();
|
||||||
|
_initialOptions.clear();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoadingInitial = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final products = await fetchAllPaginatedItems<Product>(
|
||||||
|
request: (page, pageSize) =>
|
||||||
|
repository.list(page: page, pageSize: pageSize, isActive: true),
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final options = products
|
||||||
|
.map(InventoryProductSuggestion.fromProduct)
|
||||||
|
.toList(growable: false);
|
||||||
|
setState(() {
|
||||||
|
_initialOptions
|
||||||
|
..clear()
|
||||||
|
..addAll(options);
|
||||||
|
if (_selected != null &&
|
||||||
|
!_initialOptions.any((item) => item.id == _selected!.id)) {
|
||||||
|
_initialOptions.add(_selected!);
|
||||||
|
}
|
||||||
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(_initialOptions);
|
||||||
|
_isLoadingInitial = false;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
final failure = Failure.from(error);
|
||||||
|
_error = failure.describe();
|
||||||
|
_initialOptions.clear();
|
||||||
|
_suggestions.clear();
|
||||||
|
_isLoadingInitial = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cacheSuggestion(InventoryProductSuggestion suggestion) {
|
||||||
|
if (!_initialOptions.any((item) => item.id == suggestion.id)) {
|
||||||
|
_initialOptions.add(suggestion);
|
||||||
|
}
|
||||||
|
if (!_suggestions.any((item) => item.id == suggestion.id)) {
|
||||||
|
_suggestions.add(suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncExternalControllers(InventoryProductSuggestion suggestion) {
|
||||||
|
if (widget.manufacturerController.text != suggestion.vendorName) {
|
||||||
|
widget.manufacturerController.text = suggestion.vendorName;
|
||||||
|
}
|
||||||
|
if (widget.unitController.text != suggestion.unitName) {
|
||||||
|
widget.unitController.text = suggestion.unitName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applySuggestionSilently(InventoryProductSuggestion suggestion) {
|
||||||
|
_selected = suggestion;
|
||||||
|
_cacheSuggestion(suggestion);
|
||||||
|
_updateProductFieldValue(suggestion.name);
|
||||||
|
_syncExternalControllers(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
/// 입력 변화에 따라 제안 검색을 예약한다.
|
/// 입력 변화에 따라 제안 검색을 예약한다.
|
||||||
void _handleTextChanged() {
|
void _handleTextChanged() {
|
||||||
|
if (_isApplyingText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final text = widget.productController.text.trim();
|
final text = widget.productController.text.trim();
|
||||||
if (text.isEmpty) {
|
if (text.isEmpty) {
|
||||||
|
_debounce?.cancel();
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_selected != null && text != _selected!.name) {
|
||||||
|
_selected = null;
|
||||||
|
widget.onSuggestionSelected(null);
|
||||||
|
}
|
||||||
_scheduleSearch(text);
|
_scheduleSearch(text);
|
||||||
widget.onChanged?.call();
|
widget.onChanged?.call();
|
||||||
}
|
}
|
||||||
@@ -148,10 +241,18 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
if (!mounted || currentRequest != _requestCounter) {
|
if (!mounted || currentRequest != _requestCounter) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final fetched = result.items
|
||||||
|
.map(InventoryProductSuggestion.fromProduct)
|
||||||
|
.toList(growable: false);
|
||||||
setState(() {
|
setState(() {
|
||||||
|
for (final option in fetched) {
|
||||||
|
if (!_initialOptions.any((item) => item.id == option.id)) {
|
||||||
|
_initialOptions.add(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
_suggestions
|
_suggestions
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(result.items.map(InventoryProductSuggestion.fromProduct));
|
..addAll(fetched);
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -159,7 +260,9 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_suggestions.clear();
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(_initialOptions);
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -169,43 +272,73 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
void _applySuggestion(
|
void _applySuggestion(
|
||||||
InventoryProductSuggestion suggestion, {
|
InventoryProductSuggestion suggestion, {
|
||||||
bool updateProductField = true,
|
bool updateProductField = true,
|
||||||
|
bool notify = true,
|
||||||
}) {
|
}) {
|
||||||
|
_debounce?.cancel();
|
||||||
setState(() {
|
setState(() {
|
||||||
_selected = suggestion;
|
_selected = suggestion;
|
||||||
|
_cacheSuggestion(suggestion);
|
||||||
});
|
});
|
||||||
widget.onSuggestionSelected(suggestion);
|
if (notify) {
|
||||||
|
widget.onSuggestionSelected(suggestion);
|
||||||
|
}
|
||||||
if (updateProductField) {
|
if (updateProductField) {
|
||||||
widget.productController.text = suggestion.name;
|
_updateProductFieldValue(suggestion.name);
|
||||||
widget.productController.selection = TextSelection.collapsed(
|
}
|
||||||
offset: widget.productController.text.length,
|
_syncExternalControllers(suggestion);
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[InventoryProductAutocomplete] selected "${suggestion.name}" '
|
||||||
|
'-> text="${widget.productController.text}"',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (widget.manufacturerController.text != suggestion.vendorName) {
|
|
||||||
widget.manufacturerController.text = suggestion.vendorName;
|
|
||||||
}
|
|
||||||
if (widget.unitController.text != suggestion.unitName) {
|
|
||||||
widget.unitController.text = suggestion.unitName;
|
|
||||||
}
|
|
||||||
widget.onChanged?.call();
|
widget.onChanged?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection({bool notify = true}) {
|
||||||
if (_selected == null &&
|
if (_selected == null &&
|
||||||
widget.manufacturerController.text.isEmpty &&
|
widget.manufacturerController.text.isEmpty &&
|
||||||
widget.unitController.text.isEmpty) {
|
widget.unitController.text.isEmpty &&
|
||||||
|
widget.productController.text.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(_initialOptions);
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_debounce?.cancel();
|
||||||
setState(() {
|
setState(() {
|
||||||
_selected = null;
|
_selected = null;
|
||||||
_suggestions.clear();
|
_suggestions
|
||||||
|
..clear()
|
||||||
|
..addAll(_initialOptions);
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
widget.onSuggestionSelected(null);
|
if (notify) {
|
||||||
|
widget.onSuggestionSelected(null);
|
||||||
|
}
|
||||||
widget.manufacturerController.clear();
|
widget.manufacturerController.clear();
|
||||||
widget.unitController.clear();
|
widget.unitController.clear();
|
||||||
|
if (widget.productController.text.isEmpty) {
|
||||||
|
_updateProductFieldValue('');
|
||||||
|
}
|
||||||
widget.onChanged?.call();
|
widget.onChanged?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 제품 입력 필드에 텍스트와 커서를 안전하게 반영한다.
|
||||||
|
void _updateProductFieldValue(String text) {
|
||||||
|
final controller = widget.productController;
|
||||||
|
_isApplyingText = true;
|
||||||
|
controller.value = controller.value.copyWith(
|
||||||
|
text: text,
|
||||||
|
selection: TextSelection.collapsed(offset: text.length),
|
||||||
|
composing: TextRange.empty,
|
||||||
|
);
|
||||||
|
_isApplyingText = false;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.productController.removeListener(_handleTextChanged);
|
widget.productController.removeListener(_handleTextChanged);
|
||||||
@@ -213,19 +346,55 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ShadTheme.of(context);
|
final theme = ShadTheme.of(context);
|
||||||
|
if (_isLoadingInitial) {
|
||||||
|
return _buildLoadingInput();
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return ShadInput(
|
||||||
|
controller: widget.productController,
|
||||||
|
focusNode: widget.productFocusNode,
|
||||||
|
enabled: false,
|
||||||
|
readOnly: true,
|
||||||
|
placeholder: Text(_error!),
|
||||||
|
);
|
||||||
|
}
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return RawAutocomplete<InventoryProductSuggestion>(
|
return RawAutocomplete<InventoryProductSuggestion>(
|
||||||
textEditingController: widget.productController,
|
textEditingController: widget.productController,
|
||||||
focusNode: widget.productFocusNode,
|
focusNode: widget.productFocusNode,
|
||||||
optionsBuilder: (textEditingValue) {
|
optionsBuilder: (textEditingValue) {
|
||||||
if (textEditingValue.text.trim().isEmpty) {
|
final keyword = textEditingValue.text.trim();
|
||||||
return const Iterable<InventoryProductSuggestion>.empty();
|
final base = _suggestions.isEmpty ? _initialOptions : _suggestions;
|
||||||
|
if (keyword.isEmpty) {
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
return _suggestions;
|
final lowerKeyword = keyword.toLowerCase();
|
||||||
|
return base.where((option) {
|
||||||
|
return option.name.toLowerCase().contains(lowerKeyword) ||
|
||||||
|
option.code.toLowerCase().contains(lowerKeyword) ||
|
||||||
|
option.vendorName.toLowerCase().contains(lowerKeyword);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
displayStringForOption: (option) => option.name,
|
displayStringForOption: (option) => option.name,
|
||||||
onSelected: (option) {
|
onSelected: (option) {
|
||||||
@@ -239,10 +408,6 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
placeholder: const Text('제품명 검색'),
|
placeholder: const Text('제품명 검색'),
|
||||||
onChanged: (_) => widget.onChanged?.call(),
|
onChanged: (_) => widget.onChanged?.call(),
|
||||||
onSubmitted: (_) => onFieldSubmitted(),
|
onSubmitted: (_) => onFieldSubmitted(),
|
||||||
onPressedOutside: (event) {
|
|
||||||
// 포커스를 유지해 항목 선택 전 오버레이가 닫히지 않도록 한다.
|
|
||||||
focusNode.requestFocus();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (!_isSearching) {
|
if (!_isSearching) {
|
||||||
return input;
|
return input;
|
||||||
@@ -323,8 +488,9 @@ class _InventoryProductAutocompleteFieldState
|
|||||||
itemCount: options.length,
|
itemCount: options.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final option = options.elementAt(index);
|
final option = options.elementAt(index);
|
||||||
return InkWell(
|
return GestureDetector(
|
||||||
onTap: () => onSelected(option),
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapDown: (_) => onSelected(option),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ class StockTransactionCreateInput {
|
|||||||
|
|
||||||
Map<String, dynamic> toPayload() {
|
Map<String, dynamic> toPayload() {
|
||||||
final sanitizedNote = note?.trim();
|
final sanitizedNote = note?.trim();
|
||||||
|
final linePayloads = lines
|
||||||
|
.map((line) => line.toJson())
|
||||||
|
.toList(growable: false);
|
||||||
|
final customerPayloads = customers
|
||||||
|
.map((customer) => customer.toJson())
|
||||||
|
.toList(growable: false);
|
||||||
return {
|
return {
|
||||||
'transaction_type_id': transactionTypeId,
|
'transaction_type_id': transactionTypeId,
|
||||||
'transaction_status_id': transactionStatusId,
|
'transaction_status_id': transactionStatusId,
|
||||||
@@ -35,12 +41,8 @@ class StockTransactionCreateInput {
|
|||||||
'note': sanitizedNote,
|
'note': sanitizedNote,
|
||||||
if (expectedReturnDate != null)
|
if (expectedReturnDate != null)
|
||||||
'expected_return_date': _formatNaiveDate(expectedReturnDate!),
|
'expected_return_date': _formatNaiveDate(expectedReturnDate!),
|
||||||
if (lines.isNotEmpty)
|
'lines': linePayloads,
|
||||||
'lines': lines.map((line) => line.toJson()).toList(growable: false),
|
'customers': customerPayloads,
|
||||||
if (customers.isNotEmpty)
|
|
||||||
'customers': customers
|
|
||||||
.map((customer) => customer.toJson())
|
|
||||||
.toList(growable: false),
|
|
||||||
if (approval != null) 'approval': approval!.toJson(),
|
if (approval != null) 'approval': approval!.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Future<T?> showInventoryTransactionDetailDialog<T>({
|
|||||||
...actions,
|
...actions,
|
||||||
if (includeDefaultClose)
|
if (includeDefaultClose)
|
||||||
ShadButton.ghost(
|
ShadButton.ghost(
|
||||||
onPressed: () => Navigator.of(context).maybePop(),
|
onPressed: () => Navigator.of(context, rootNavigator: true).maybePop(),
|
||||||
child: const Text('닫기'),
|
child: const Text('닫기'),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../core/constants/app_sections.dart';
|
import '../core/constants/app_sections.dart';
|
||||||
@@ -437,6 +436,10 @@ class _AccountMenuButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case _AccountDialogResult.logout:
|
case _AccountDialogResult.logout:
|
||||||
await service.clearSession();
|
await service.clearSession();
|
||||||
@@ -446,6 +449,9 @@ class _AccountMenuButton extends StatelessWidget {
|
|||||||
break;
|
break;
|
||||||
case _AccountDialogResult.passwordChanged:
|
case _AccountDialogResult.passwordChanged:
|
||||||
final confirmed = await _showMandatoryLogoutDialog(context);
|
final confirmed = await _showMandatoryLogoutDialog(context);
|
||||||
|
if (!context.mounted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
await service.clearSession();
|
await service.clearSession();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -601,7 +607,7 @@ class _AccountDialogState extends State<_AccountDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _wrapWithWillPop(Widget child) {
|
Widget _wrapWithWillPop(Widget child) {
|
||||||
return WillPopScope(onWillPop: () async => !_isSaving, child: child);
|
return PopScope(canPop: !_isSaving, child: child);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveProfile() async {
|
Future<void> _saveProfile() async {
|
||||||
|
|||||||
@@ -170,7 +170,9 @@ class SuperportDialog extends StatelessWidget {
|
|||||||
if (secondaryAction != null) secondaryAction!,
|
if (secondaryAction != null) secondaryAction!,
|
||||||
primaryAction ??
|
primaryAction ??
|
||||||
ShadButton.ghost(
|
ShadButton.ghost(
|
||||||
onPressed: onClose ?? () => Navigator.of(context).maybePop(),
|
onPressed:
|
||||||
|
onClose ??
|
||||||
|
() => Navigator.of(context, rootNavigator: true).maybePop(),
|
||||||
child: const Text('닫기'),
|
child: const Text('닫기'),
|
||||||
),
|
),
|
||||||
].whereType<Widget>().toList();
|
].whereType<Widget>().toList();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
|
|||||||
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||||
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
|
||||||
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||||
|
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
|
||||||
|
|
||||||
class _MockStockTransactionRepository extends Mock
|
class _MockStockTransactionRepository extends Mock
|
||||||
implements StockTransactionRepository {}
|
implements StockTransactionRepository {}
|
||||||
@@ -19,6 +20,9 @@ class _MockInventoryLookupRepository extends Mock
|
|||||||
class _MockTransactionLineRepository extends Mock
|
class _MockTransactionLineRepository extends Mock
|
||||||
implements TransactionLineRepository {}
|
implements TransactionLineRepository {}
|
||||||
|
|
||||||
|
class _MockTransactionCustomerRepository extends Mock
|
||||||
|
implements TransactionCustomerRepository {}
|
||||||
|
|
||||||
class _FakeStockTransactionCreateInput extends Fake
|
class _FakeStockTransactionCreateInput extends Fake
|
||||||
implements StockTransactionCreateInput {}
|
implements StockTransactionCreateInput {}
|
||||||
|
|
||||||
@@ -28,26 +32,39 @@ class _FakeStockTransactionUpdateInput extends Fake
|
|||||||
class _FakeStockTransactionListFilter extends Fake
|
class _FakeStockTransactionListFilter extends Fake
|
||||||
implements StockTransactionListFilter {}
|
implements StockTransactionListFilter {}
|
||||||
|
|
||||||
|
class _FakeTransactionCustomerCreateInput extends Fake
|
||||||
|
implements TransactionCustomerCreateInput {}
|
||||||
|
|
||||||
|
class _FakeTransactionCustomerUpdateInput extends Fake
|
||||||
|
implements TransactionCustomerUpdateInput {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('InboundController', () {
|
group('InboundController', () {
|
||||||
late StockTransactionRepository transactionRepository;
|
late StockTransactionRepository transactionRepository;
|
||||||
late InventoryLookupRepository lookupRepository;
|
late InventoryLookupRepository lookupRepository;
|
||||||
late TransactionLineRepository lineRepository;
|
late TransactionLineRepository lineRepository;
|
||||||
|
late TransactionCustomerRepository customerRepository;
|
||||||
late InboundController controller;
|
late InboundController controller;
|
||||||
|
|
||||||
setUpAll(() {
|
setUpAll(() {
|
||||||
registerFallbackValue(_FakeStockTransactionCreateInput());
|
registerFallbackValue(_FakeStockTransactionCreateInput());
|
||||||
registerFallbackValue(_FakeStockTransactionUpdateInput());
|
registerFallbackValue(_FakeStockTransactionUpdateInput());
|
||||||
registerFallbackValue(_FakeStockTransactionListFilter());
|
registerFallbackValue(_FakeStockTransactionListFilter());
|
||||||
|
registerFallbackValue(_FakeTransactionCustomerCreateInput());
|
||||||
|
registerFallbackValue(_FakeTransactionCustomerUpdateInput());
|
||||||
|
registerFallbackValue(<TransactionCustomerCreateInput>[]);
|
||||||
|
registerFallbackValue(<TransactionCustomerUpdateInput>[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
transactionRepository = _MockStockTransactionRepository();
|
transactionRepository = _MockStockTransactionRepository();
|
||||||
lookupRepository = _MockInventoryLookupRepository();
|
lookupRepository = _MockInventoryLookupRepository();
|
||||||
lineRepository = _MockTransactionLineRepository();
|
lineRepository = _MockTransactionLineRepository();
|
||||||
|
customerRepository = _MockTransactionCustomerRepository();
|
||||||
controller = InboundController(
|
controller = InboundController(
|
||||||
transactionRepository: transactionRepository,
|
transactionRepository: transactionRepository,
|
||||||
lineRepository: lineRepository,
|
lineRepository: lineRepository,
|
||||||
|
customerRepository: customerRepository,
|
||||||
lookupRepository: lookupRepository,
|
lookupRepository: lookupRepository,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -156,6 +173,30 @@ void main() {
|
|||||||
verify(() => transactionRepository.delete(transaction.id!)).called(1);
|
verify(() => transactionRepository.delete(transaction.id!)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('syncTransactionCustomers는 계획에 따라 저장소를 호출한다', () async {
|
||||||
|
when(
|
||||||
|
() => customerRepository.updateCustomers(any(), any()),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
when(
|
||||||
|
() => customerRepository.deleteCustomer(any()),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
when(
|
||||||
|
() => customerRepository.addCustomers(any(), any()),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final plan = TransactionCustomerSyncPlan(
|
||||||
|
createdCustomers: [TransactionCustomerCreateInput(customerId: 7)],
|
||||||
|
updatedCustomers: [TransactionCustomerUpdateInput(id: 2, note: '메모')],
|
||||||
|
deletedCustomerIds: const [5],
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.syncTransactionCustomers(42, plan);
|
||||||
|
|
||||||
|
verify(() => customerRepository.updateCustomers(42, any())).called(1);
|
||||||
|
verify(() => customerRepository.deleteCustomer(5)).called(1);
|
||||||
|
verify(() => customerRepository.addCustomers(42, any())).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('submitTransaction은 refreshAfter가 true일 때 목록을 다시 불러온다', () async {
|
test('submitTransaction은 refreshAfter가 true일 때 목록을 다시 불러온다', () async {
|
||||||
final filter = StockTransactionListFilter(transactionTypeId: 1);
|
final filter = StockTransactionListFilter(transactionTypeId: 1);
|
||||||
final initial = _buildTransaction();
|
final initial = _buildTransaction();
|
||||||
|
|||||||
Reference in New Issue
Block a user