From 259b056072afdff24226d9d6619da183ff27e8cd Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 27 Oct 2025 20:02:21 +0900 Subject: [PATCH] =?UTF-8?q?fix(inventory):=20=ED=8C=8C=ED=8A=B8=EB=84=88?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=8F=99=EC=9E=91=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출 - 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정 - 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장 - 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화 - 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일 - 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속) --- .../controllers/inbound_controller.dart | 57 +++ .../presentation/models/inbound_record.dart | 52 +++ .../presentation/pages/inbound_page.dart | 355 ++++++++++----- .../specs/inbound_table_spec.dart | 2 +- .../widgets/inbound_detail_view.dart | 23 +- .../presentation/pages/outbound_page.dart | 2 +- .../presentation/pages/rental_page.dart | 2 +- .../shared/widgets/partner_select_field.dart | 411 ++++++++++++++++++ .../widgets/product_autocomplete_field.dart | 234 ++++++++-- .../entities/stock_transaction_input.dart | 14 +- .../widgets/transaction_detail_dialog.dart | 2 +- lib/widgets/app_shell.dart | 10 +- lib/widgets/components/superport_dialog.dart | 4 +- .../controllers/inbound_controller_test.dart | 41 ++ 14 files changed, 1054 insertions(+), 155 deletions(-) create mode 100644 lib/features/inventory/shared/widgets/partner_select_field.dart diff --git a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart index 5a69cc2..ba9f3af 100644 --- a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart +++ b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart @@ -16,11 +16,13 @@ class InboundController extends ChangeNotifier { InboundController({ required StockTransactionRepository transactionRepository, required TransactionLineRepository lineRepository, + required TransactionCustomerRepository customerRepository, required InventoryLookupRepository lookupRepository, List fallbackStatusOptions = const ['작성중', '승인대기', '승인완료'], List transactionTypeKeywords = const ['입고', 'inbound'], }) : _transactionRepository = transactionRepository, _lineRepository = lineRepository, + _customerRepository = customerRepository, _lookupRepository = lookupRepository, _fallbackStatusOptions = List.unmodifiable( fallbackStatusOptions, @@ -33,6 +35,7 @@ class InboundController extends ChangeNotifier { final StockTransactionRepository _transactionRepository; final TransactionLineRepository _lineRepository; + final TransactionCustomerRepository _customerRepository; final InventoryLookupRepository _lookupRepository; final List _fallbackStatusOptions; final List _transactionTypeKeywords; @@ -46,6 +49,8 @@ class InboundController extends ChangeNotifier { String? _errorMessage; StockTransactionListFilter? _lastFilter; final Set _processingTransactionIds = {}; + List _approvalStatuses = const []; + LookupItem? _defaultApprovalStatus; UnmodifiableListView get statusOptions => UnmodifiableListView(_statusOptions); @@ -54,6 +59,9 @@ class InboundController extends ChangeNotifier { UnmodifiableMapView(_statusLookup); LookupItem? get transactionType => _transactionType; + LookupItem? get defaultApprovalStatus => _defaultApprovalStatus; + UnmodifiableListView get approvalStatuses => + UnmodifiableListView(_approvalStatuses); PaginatedResult? get result => _result; @@ -97,6 +105,21 @@ class InboundController extends ChangeNotifier { } } + /// 결재 상태 목록을 조회한다. + Future loadApprovalStatuses() async { + try { + final items = await _lookupRepository.fetchApprovalStatuses(); + if (items.isEmpty) { + return; + } + _approvalStatuses = items; + _defaultApprovalStatus = _resolveDefaultApprovalStatus(items); + notifyListeners(); + } catch (_) { + // 오류 시 기존 상태 유지. + } + } + /// 입고 트랜잭션 타입을 조회한다. Future resolveTransactionType() async { if (_transactionType != null) { @@ -156,6 +179,15 @@ class InboundController extends ChangeNotifier { await fetchTransactions(filter: target); } + LookupItem? _resolveDefaultApprovalStatus(List items) { + for (final item in items) { + if (item.isDefault) { + return item; + } + } + return items.isEmpty ? null : items.first; + } + /// 재고 트랜잭션을 생성하고 필요 시 목록을 갱신한다. Future createTransaction( StockTransactionCreateInput input, { @@ -448,4 +480,29 @@ class InboundController extends ChangeNotifier { await _lineRepository.addLines(transactionId, plan.createdLines); } } + + /// 고객 연결 변경 계획을 실행하여 파트너사 정보를 동기화한다. + Future 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, + ); + } + } } diff --git a/lib/features/inventory/inbound/presentation/models/inbound_record.dart b/lib/features/inventory/inbound/presentation/models/inbound_record.dart index a3fd54c..d1dec27 100644 --- a/lib/features/inventory/inbound/presentation/models/inbound_record.dart +++ b/lib/features/inventory/inbound/presentation/models/inbound_record.dart @@ -22,10 +22,18 @@ class InboundRecord { required this.items, this.expectedReturnDate, this.isActive = true, + this.partnerLinkId, + this.partnerId, + this.partnerCode, + this.partnerName, this.raw, }); factory InboundRecord.fromTransaction(StockTransaction transaction) { + final partnerLink = transaction.customers.isNotEmpty + ? transaction.customers.first + : null; + final partnerSummary = partnerLink?.customer; return InboundRecord( id: transaction.id, number: transaction.transactionNo, @@ -48,6 +56,10 @@ class InboundRecord { .toList(growable: false), expectedReturnDate: transaction.expectedReturnDate, isActive: transaction.isActive, + partnerLinkId: partnerLink?.id, + partnerId: partnerSummary?.id, + partnerCode: partnerSummary?.code, + partnerName: partnerSummary?.name, raw: transaction, ); } @@ -71,6 +83,10 @@ class InboundRecord { final List items; final DateTime? expectedReturnDate; final bool isActive; + final int? partnerLinkId; + final int? partnerId; + final String? partnerCode; + final String? partnerName; final StockTransaction? raw; int get itemCount => items.length; @@ -80,6 +96,42 @@ class InboundRecord { double get totalAmount => items.fold(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 get customers { + final rawCustomers = raw?.customers; + if (rawCustomers != null && rawCustomers.isNotEmpty) { + return rawCustomers; + } + final fallback = primaryCustomer; + if (fallback == null) { + return const []; + } + return [fallback]; + } } /// 입고 상세 모달에서 사용하는 품목 정보. diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index b29ccff..4f75b1b 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -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/superport_dialog.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/employee_autocomplete_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/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/repositories/stock_transaction_repository.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/shared/widgets/employee_autocomplete_field.dart' + show InventoryEmployeeSuggestion; import '../../../lookups/domain/entities/lookup_item.dart'; import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; import '../widgets/inbound_detail_view.dart'; @@ -112,12 +115,14 @@ class _InboundPageState extends State { final getIt = GetIt.I; if (!getIt.isRegistered() || !getIt.isRegistered() || + !getIt.isRegistered() || !getIt.isRegistered()) { return null; } return InboundController( transactionRepository: getIt(), lineRepository: getIt(), + customerRepository: getIt(), lookupRepository: getIt(), fallbackStatusOptions: InboundTableSpec.fallbackStatusOptions, transactionTypeKeywords: InboundTableSpec.transactionTypeKeywords, @@ -131,6 +136,7 @@ class _InboundPageState extends State { } Future.microtask(() async { await controller.loadStatusOptions(); + await controller.loadApprovalStatuses(); final hasType = await controller.resolveTransactionType(); if (!mounted) { return; @@ -804,12 +810,7 @@ class _InboundPageState extends State { actions.add( ShadButton.outline( leading: const Icon(lucide.LucideIcons.pencil, size: 16), - onPressed: isProcessing - ? null - : () { - Navigator.of(context).maybePop(); - _handleEdit(record); - }, + onPressed: isProcessing ? null : () => _openEditFromDetail(record), child: const Text('수정'), ), ); @@ -817,6 +818,17 @@ class _InboundPageState extends State { 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 _handleCreate() async { final record = await _showInboundFormDialog(); if (record != null) { @@ -824,10 +836,18 @@ class _InboundPageState extends State { } } - Future _handleEdit(InboundRecord record) async { + Future _handleEdit( + InboundRecord record, { + bool reopenOnCancel = false, + }) async { final updated = await _showInboundFormDialog(initial: record); + if (!mounted) { + return; + } if (updated != null) { _selectRecord(updated, openDetail: true); + } else if (reopenOnCancel) { + _selectRecord(record, openDetail: true); } } @@ -1364,6 +1384,26 @@ class _InboundPageState extends State { }; } + InventoryEmployeeSuggestion? _resolveCurrentWriter() { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + return null; + } + final session = getIt().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) { if (totalItems <= 0) { return 1; @@ -1397,6 +1437,27 @@ class _InboundPageState extends State { final statusValue = ValueNotifier( 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; final initialWriter = initial?.raw?.createdBy; if (initialWriter != null) { @@ -1412,6 +1473,8 @@ class _InboundPageState extends State { employeeNo: initial.writer, name: initial.writer, ); + } else if (initial == null) { + writerSelection = _resolveCurrentWriter(); } String writerLabel(InventoryEmployeeSuggestion? suggestion) { if (suggestion == null) { @@ -1448,6 +1511,7 @@ class _InboundPageState extends State { String? writerError; String? warehouseError; + String? partnerError; String? statusError; String? headerNotice; StateSetter? refreshForm; @@ -1457,7 +1521,7 @@ class _InboundPageState extends State { InboundRecord? result; - final navigator = Navigator.of(context); + final navigator = Navigator.of(context, rootNavigator: true); void updateSaving(bool next) { isSaving = next; @@ -1476,12 +1540,14 @@ class _InboundPageState extends State { writerSelection: writerSelection, requireWriterSelection: initial == null, warehouseSelection: warehouseSelection, + partnerSelection: partnerSelection, statusValue: statusValue.value, drafts: drafts, lineErrors: lineErrors, ); writerError = validationResult.writerError; warehouseError = validationResult.warehouseError; + partnerError = validationResult.partnerError; statusError = validationResult.statusError; headerNotice = validationResult.headerNotice; refreshForm?.call(() {}); @@ -1556,6 +1622,14 @@ class _InboundPageState extends State { return; } + final createCustomers = () { + final partner = partnerSelection; + if (partner == null) { + return const []; + } + return [TransactionCustomerCreateInput(customerId: partner.id)]; + }(); + try { updateSaving(true); @@ -1576,12 +1650,37 @@ class _InboundPageState extends State { result = updated; final currentLines = initialRecord.raw?.lines ?? const []; - final plan = _detailSyncService.buildLinePlan( + final currentCustomers = initialRecord.customers; + final customerDrafts = () { + final partner = partnerSelection; + if (partner == null) { + return const []; + } + final existing = initialRecord.primaryCustomer; + return [ + TransactionCustomerDraft( + id: existing?.id ?? initialRecord.partnerLinkId, + customerId: partner.id, + note: existing?.note, + ), + ]; + }(); + final linePlan = _detailSyncService.buildLinePlan( drafts: lineDrafts, currentLines: currentLines, ); - if (plan.hasChanges) { - await controller.syncTransactionLines(transactionId, plan); + final customerPlan = _detailSyncService.buildCustomerPlan( + drafts: customerDrafts, + currentCustomers: currentCustomers, + ); + if (linePlan.hasChanges) { + await controller.syncTransactionLines(transactionId, linePlan); + } + if (customerPlan.hasChanges) { + await controller.syncTransactionCustomers( + transactionId, + customerPlan, + ); } await controller.refresh(); updateSaving(false); @@ -1602,6 +1701,23 @@ class _InboundPageState extends State { 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 .map( (draft) => TransactionLineCreateInput( @@ -1613,21 +1729,29 @@ class _InboundPageState extends State { ), ) .toList(growable: false); - final created = await controller.createTransaction( - StockTransactionCreateInput( - transactionTypeId: transactionTypeLookup.id, - transactionStatusId: statusItem.id, - warehouseId: warehouseId, - transactionDate: processedAt.value, - createdById: createdById, - note: remarkValue, - lines: createLines, - approval: StockTransactionApprovalInput( - requestedById: createdById, - note: approvalNoteValue.isEmpty ? null : approvalNoteValue, - ), + final createInput = StockTransactionCreateInput( + transactionTypeId: transactionTypeLookup.id, + transactionStatusId: statusItem.id, + warehouseId: warehouseId, + transactionDate: processedAt.value, + createdById: createdById, + note: remarkValue, + lines: createLines, + customers: createCustomers, + approval: StockTransactionApprovalInput( + requestedById: createdById, + 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; updateSaving(false); if (!mounted) { @@ -1643,6 +1767,20 @@ class _InboundPageState extends State { if (!mounted) { 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( context, _failureMessage(error, '저장 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.'), @@ -1734,6 +1872,26 @@ class _InboundPageState extends State { ), ), ), + 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( width: 240, child: SuperportFormField( @@ -1808,35 +1966,10 @@ class _InboundPageState extends State { label: '작성자', required: true, errorText: writerError, - child: InventoryEmployeeAutocompleteField( + child: ShadInput( controller: writerController, - initialSuggestion: writerSelection, - enabled: initial == null, - 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; - }); - } - } - }, + readOnly: true, + enabled: false, ), ), ), @@ -1927,32 +2060,39 @@ class _InboundPageState extends State { const SizedBox(height: 16), Column( children: [ - for (final draft in drafts) + for (var index = 0; index < drafts.length; index++) Padding( padding: const EdgeInsets.only(bottom: 12), - child: _LineItemRow( - draft: draft, - onRemove: drafts.length == 1 - ? null - : () => setState(() { - draft.dispose(); - drafts.remove(draft); + child: Builder( + builder: (_) { + final draft = drafts[index]; + return _LineItemRow( + draft: draft, + showLabels: index == 0, + 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; - lineErrors.remove(draft); - }), - errors: - lineErrors[draft] ?? _LineItemFieldErrors.empty(), - onFieldChanged: (field) { - setState(() { - headerNotice = null; - final error = lineErrors[draft]; - if (error == null) { - lineErrors[draft] = - _LineItemFieldErrors.empty(); - } else { - error.clearField(field); - } - }); + final error = lineErrors[draft]; + if (error == null) { + lineErrors[draft] = + _LineItemFieldErrors.empty(); + } else { + error.clearField(field); + } + }); + }, + ); }, ), ), @@ -1965,15 +2105,25 @@ class _InboundPageState extends State { ), ); - for (final draft in drafts) { - draft.dispose(); - } - statusValue.dispose(); - writerController.dispose(); - remarkController.dispose(); - approvalNoteController.dispose(); - transactionTypeController.dispose(); - processedAt.dispose(); + final disposeDrafts = List<_LineItemDraft>.from(drafts); + final disposeStatus = statusValue; + final disposeWriterController = writerController; + final disposeRemarkController = remarkController; + final disposeApprovalNoteController = approvalNoteController; + final disposeTransactionTypeController = transactionTypeController; + final disposeProcessedAt = processedAt; + + WidgetsBinding.instance.addPostFrameCallback((_) { + for (final draft in disposeDrafts) { + draft.dispose(); + } + disposeStatus.dispose(); + disposeWriterController.dispose(); + disposeRemarkController.dispose(); + disposeApprovalNoteController.dispose(); + disposeTransactionTypeController.dispose(); + disposeProcessedAt.dispose(); + }); return result; } @@ -2315,6 +2465,7 @@ _InboundFormValidation _validateInboundForm({ required InventoryEmployeeSuggestion? writerSelection, required bool requireWriterSelection, required InventoryWarehouseOption? warehouseSelection, + required InventoryPartnerOption? partnerSelection, required String statusValue, required List<_LineItemDraft> drafts, required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors, @@ -2322,6 +2473,7 @@ _InboundFormValidation _validateInboundForm({ var isValid = true; String? writerError; String? warehouseError; + String? partnerError; String? statusError; String? headerNotice; @@ -2341,6 +2493,11 @@ _InboundFormValidation _validateInboundForm({ isValid = false; } + if (partnerSelection == null) { + partnerError = '파트너사를 선택하세요.'; + isValid = false; + } + if (statusValue.trim().isEmpty) { statusError = '상태를 선택하세요.'; isValid = false; @@ -2369,7 +2526,6 @@ _InboundFormValidation _validateInboundForm({ hasLineError = true; isValid = false; } - final quantity = int.tryParse( draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(), ); @@ -2395,6 +2551,7 @@ _InboundFormValidation _validateInboundForm({ isValid: isValid, writerError: writerError, warehouseError: warehouseError, + partnerError: partnerError, statusError: statusError, headerNotice: headerNotice, ); @@ -2410,6 +2567,7 @@ class _InboundFormValidation { required this.isValid, this.writerError, this.warehouseError, + this.partnerError, this.statusError, this.headerNotice, }); @@ -2417,6 +2575,7 @@ class _InboundFormValidation { final bool isValid; final String? writerError; final String? warehouseError; + final String? partnerError; final String? statusError; final String? headerNotice; } @@ -2454,12 +2613,14 @@ enum _InboundSortField { processedAt, warehouse, status, writer } class _LineItemRow extends StatelessWidget { const _LineItemRow({ required this.draft, + required this.showLabels, required this.onRemove, required this.errors, required this.onFieldChanged, }); final _LineItemDraft draft; + final bool showLabels; final VoidCallback? onRemove; final _LineItemFieldErrors errors; final void Function(_LineItemField field) onFieldChanged; @@ -2471,8 +2632,8 @@ class _LineItemRow extends StatelessWidget { children: [ Expanded( child: SuperportFormField( - label: '제품', - required: true, + label: showLabels ? '제품' : '', + required: showLabels, errorText: errors.product, child: InventoryProductAutocompleteField( productController: draft.product, @@ -2492,8 +2653,8 @@ class _LineItemRow extends StatelessWidget { const SizedBox(width: 12), Expanded( child: SuperportFormField( - label: '제조사', - caption: '제품 선택 시 자동 입력됩니다.', + label: showLabels ? '제조사' : '', + caption: showLabels ? '제품 선택 시 자동 입력됩니다.' : null, child: ShadInput( controller: draft.manufacturer, placeholder: const Text('자동 입력'), @@ -2506,8 +2667,8 @@ class _LineItemRow extends StatelessWidget { SizedBox( width: 80, child: SuperportFormField( - label: '단위', - caption: '제품에 연결된 단위입니다.', + label: showLabels ? '단위' : '', + caption: showLabels ? '제품에 연결된 단위입니다.' : null, child: ShadInput( controller: draft.unit, placeholder: const Text('자동'), @@ -2520,8 +2681,8 @@ class _LineItemRow extends StatelessWidget { SizedBox( width: 100, child: SuperportFormField( - label: '수량', - required: true, + label: showLabels ? '수량' : '', + required: showLabels, errorText: errors.quantity, child: ShadInput( controller: draft.quantity, @@ -2535,8 +2696,8 @@ class _LineItemRow extends StatelessWidget { SizedBox( width: 120, child: SuperportFormField( - label: '단가', - required: true, + label: showLabels ? '단가' : '', + required: showLabels, errorText: errors.price, child: ShadInput( controller: draft.price, @@ -2549,7 +2710,7 @@ class _LineItemRow extends StatelessWidget { const SizedBox(width: 12), Expanded( child: SuperportFormField( - label: '비고', + label: showLabels ? '비고' : '', child: ShadInput( controller: draft.remark, placeholder: const Text('비고'), diff --git a/lib/features/inventory/inbound/presentation/specs/inbound_table_spec.dart b/lib/features/inventory/inbound/presentation/specs/inbound_table_spec.dart index 33d3111..8610bad 100644 --- a/lib/features/inventory/inbound/presentation/specs/inbound_table_spec.dart +++ b/lib/features/inventory/inbound/presentation/specs/inbound_table_spec.dart @@ -26,7 +26,7 @@ class InboundTableSpec { static const List pageSizeOptions = [10, 20, 50]; /// API include 파라미터 기본값. - static const List defaultIncludeOptions = ['lines']; + static const List defaultIncludeOptions = ['lines', 'customers']; /// 백엔드에서 상태 목록을 내려주지 않을 때 사용되는 기본 상태 라벨. static const List fallbackStatusOptions = ['작성중', '승인대기', '승인완료']; diff --git a/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart index af6f004..3328734 100644 --- a/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart +++ b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart @@ -27,10 +27,7 @@ class InboundDetailView extends StatelessWidget { children: [ if (!transitionsEnabled) ...[ ShadBadge.outline( - child: Text( - '재고 상태 전이가 비활성화된 상태입니다.', - style: theme.textTheme.small, - ), + child: Text('재고 상태 전이가 비활성화된 상태입니다.', style: theme.textTheme.small), ), const SizedBox(height: 16), ], @@ -55,6 +52,12 @@ class InboundDetailView extends StatelessWidget { _DetailChip(label: '트랜잭션 유형', value: record.transactionType), _DetailChip(label: '상태', value: record.status), _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.totalQuantity}'), _DetailChip( @@ -93,9 +96,7 @@ class InboundDetailView extends StatelessWidget { ShadTableCell(child: Text(item.manufacturer)), ShadTableCell(child: Text(item.unit)), ShadTableCell(child: Text('${item.quantity}')), - ShadTableCell( - child: Text(currencyFormatter.format(item.price)), - ), + ShadTableCell(child: Text(currencyFormatter.format(item.price))), ShadTableCell( child: Text(item.remark.isEmpty ? '-' : item.remark), ), @@ -128,13 +129,13 @@ class _DetailChip extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(label, style: theme.textTheme.small, textAlign: TextAlign.center), - const SizedBox(height: 4), Text( - value, - style: theme.textTheme.p, + label, + style: theme.textTheme.small, textAlign: TextAlign.center, ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.p, textAlign: TextAlign.center), ], ), ); diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 45c34b9..87d51db 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -1615,7 +1615,7 @@ class _OutboundPageState extends State { OutboundRecord? result; - final navigator = Navigator.of(context); + final navigator = Navigator.of(context, rootNavigator: true); void updateSaving(bool next) { isSaving = next; diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index a0ddf4e..deb85d0 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -1592,7 +1592,7 @@ class _RentalPageState extends State { for (final draft in drafts) draft: _RentalLineItemErrors.empty(), }; - final navigator = Navigator.of(context); + final navigator = Navigator.of(context, rootNavigator: true); void updateSaving(bool next) { isSaving = next; diff --git a/lib/features/inventory/shared/widgets/partner_select_field.dart b/lib/features/inventory/shared/widgets/partner_select_field.dart new file mode 100644 index 0000000..3807ece --- /dev/null +++ b/lib/features/inventory/shared/widgets/partner_select_field.dart @@ -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 onChanged; + final bool enabled; + final Widget? placeholder; + + @override + State createState() => + _InventoryPartnerSelectFieldState(); +} + +class _InventoryPartnerSelectFieldState + extends State { + static const Duration _debounceDuration = Duration(milliseconds: 280); + + CustomerRepository? get _repository => + GetIt.I.isRegistered() + ? GetIt.I() + : null; + + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final List _baseOptions = []; + final List _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 _loadInitialOptions() async { + final repository = _repository; + if (repository == null) { + setState(() { + _error = '파트너 데이터를 불러올 수 없습니다.'; + }); + return; + } + setState(() { + _isLoadingInitial = true; + _error = null; + }); + try { + final partners = await fetchAllPaginatedItems( + 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 _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( + 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), + ), + ], + ], + ), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/inventory/shared/widgets/product_autocomplete_field.dart b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart index 3751e13..6fd020a 100644 --- a/lib/features/inventory/shared/widgets/product_autocomplete_field.dart +++ b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; 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/product/domain/entities/product.dart'; import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart'; @@ -65,12 +68,17 @@ class _InventoryProductAutocompleteFieldState extends State { static const Duration _debounceDuration = Duration(milliseconds: 280); + final List _initialOptions = + []; final List _suggestions = []; InventoryProductSuggestion? _selected; Timer? _debounce; int _requestCounter = 0; bool _isSearching = false; + bool _isLoadingInitial = false; + String? _error; + bool _isApplyingText = false; ProductRepository? get _repository => GetIt.I.isRegistered() @@ -82,13 +90,10 @@ class _InventoryProductAutocompleteFieldState super.initState(); _selected = widget.initialSuggestion; if (_selected != null) { - _applySuggestion(_selected!, updateProductField: false); + _applySuggestionSilently(_selected!); } widget.productController.addListener(_handleTextChanged); - if (widget.productController.text.trim().isNotEmpty && - widget.initialSuggestion == null) { - _scheduleSearch(widget.productController.text.trim()); - } + _loadInitialOptions(); } @override @@ -99,20 +104,108 @@ class _InventoryProductAutocompleteFieldState widget.productController.addListener(_handleTextChanged); _scheduleSearch(widget.productController.text.trim()); } - if (widget.initialSuggestion != oldWidget.initialSuggestion && - widget.initialSuggestion != null) { - _selected = widget.initialSuggestion; - _applySuggestion(widget.initialSuggestion!, updateProductField: false); + if (widget.initialSuggestion != oldWidget.initialSuggestion) { + if (widget.initialSuggestion != null) { + _selected = widget.initialSuggestion; + _applySuggestionSilently(widget.initialSuggestion!); + } else { + _selected = null; + } } } + Future _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( + 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() { + if (_isApplyingText) { + return; + } final text = widget.productController.text.trim(); if (text.isEmpty) { + _debounce?.cancel(); _clearSelection(); return; } + if (_selected != null && text != _selected!.name) { + _selected = null; + widget.onSuggestionSelected(null); + } _scheduleSearch(text); widget.onChanged?.call(); } @@ -148,10 +241,18 @@ class _InventoryProductAutocompleteFieldState if (!mounted || currentRequest != _requestCounter) { return; } + final fetched = result.items + .map(InventoryProductSuggestion.fromProduct) + .toList(growable: false); setState(() { + for (final option in fetched) { + if (!_initialOptions.any((item) => item.id == option.id)) { + _initialOptions.add(option); + } + } _suggestions ..clear() - ..addAll(result.items.map(InventoryProductSuggestion.fromProduct)); + ..addAll(fetched); _isSearching = false; }); } catch (_) { @@ -159,7 +260,9 @@ class _InventoryProductAutocompleteFieldState return; } setState(() { - _suggestions.clear(); + _suggestions + ..clear() + ..addAll(_initialOptions); _isSearching = false; }); } @@ -169,43 +272,73 @@ class _InventoryProductAutocompleteFieldState void _applySuggestion( InventoryProductSuggestion suggestion, { bool updateProductField = true, + bool notify = true, }) { + _debounce?.cancel(); setState(() { _selected = suggestion; + _cacheSuggestion(suggestion); }); - widget.onSuggestionSelected(suggestion); + if (notify) { + widget.onSuggestionSelected(suggestion); + } if (updateProductField) { - widget.productController.text = suggestion.name; - widget.productController.selection = TextSelection.collapsed( - offset: widget.productController.text.length, + _updateProductFieldValue(suggestion.name); + } + _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(); } - void _clearSelection() { + void _clearSelection({bool notify = true}) { if (_selected == null && widget.manufacturerController.text.isEmpty && - widget.unitController.text.isEmpty) { + widget.unitController.text.isEmpty && + widget.productController.text.isEmpty) { + setState(() { + _suggestions + ..clear() + ..addAll(_initialOptions); + _isSearching = false; + }); return; } + _debounce?.cancel(); setState(() { _selected = null; - _suggestions.clear(); + _suggestions + ..clear() + ..addAll(_initialOptions); _isSearching = false; }); - widget.onSuggestionSelected(null); + if (notify) { + widget.onSuggestionSelected(null); + } widget.manufacturerController.clear(); widget.unitController.clear(); + if (widget.productController.text.isEmpty) { + _updateProductFieldValue(''); + } 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 void dispose() { widget.productController.removeListener(_handleTextChanged); @@ -213,19 +346,55 @@ class _InventoryProductAutocompleteFieldState 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 Widget build(BuildContext 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( builder: (context, constraints) { return RawAutocomplete( textEditingController: widget.productController, focusNode: widget.productFocusNode, optionsBuilder: (textEditingValue) { - if (textEditingValue.text.trim().isEmpty) { - return const Iterable.empty(); + final keyword = textEditingValue.text.trim(); + 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, onSelected: (option) { @@ -239,10 +408,6 @@ class _InventoryProductAutocompleteFieldState placeholder: const Text('제품명 검색'), onChanged: (_) => widget.onChanged?.call(), onSubmitted: (_) => onFieldSubmitted(), - onPressedOutside: (event) { - // 포커스를 유지해 항목 선택 전 오버레이가 닫히지 않도록 한다. - focusNode.requestFocus(); - }, ); if (!_isSearching) { return input; @@ -323,8 +488,9 @@ class _InventoryProductAutocompleteFieldState itemCount: options.length, itemBuilder: (context, index) { final option = options.elementAt(index); - return InkWell( - onTap: () => onSelected(option), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => onSelected(option), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, diff --git a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart index 8956431..c82402c 100644 --- a/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart +++ b/lib/features/inventory/transactions/domain/entities/stock_transaction_input.dart @@ -26,6 +26,12 @@ class StockTransactionCreateInput { Map toPayload() { 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 { 'transaction_type_id': transactionTypeId, 'transaction_status_id': transactionStatusId, @@ -35,12 +41,8 @@ class StockTransactionCreateInput { 'note': sanitizedNote, if (expectedReturnDate != null) 'expected_return_date': _formatNaiveDate(expectedReturnDate!), - if (lines.isNotEmpty) - 'lines': lines.map((line) => line.toJson()).toList(growable: false), - if (customers.isNotEmpty) - 'customers': customers - .map((customer) => customer.toJson()) - .toList(growable: false), + 'lines': linePayloads, + 'customers': customerPayloads, if (approval != null) 'approval': approval!.toJson(), }; } diff --git a/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart b/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart index f814d6c..12c085c 100644 --- a/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart +++ b/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart @@ -23,7 +23,7 @@ Future showInventoryTransactionDetailDialog({ ...actions, if (includeDefaultClose) ShadButton.ghost( - onPressed: () => Navigator.of(context).maybePop(), + onPressed: () => Navigator.of(context, rootNavigator: true).maybePop(), child: const Text('닫기'), ), ]; diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index d3156f4..fd62c11 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; -import 'package:intl/intl.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../core/constants/app_sections.dart'; @@ -437,6 +436,10 @@ class _AccountMenuButton extends StatelessWidget { ), ); + if (!context.mounted) { + return; + } + switch (result) { case _AccountDialogResult.logout: await service.clearSession(); @@ -446,6 +449,9 @@ class _AccountMenuButton extends StatelessWidget { break; case _AccountDialogResult.passwordChanged: final confirmed = await _showMandatoryLogoutDialog(context); + if (!context.mounted) { + break; + } if (confirmed == true) { await service.clearSession(); if (context.mounted) { @@ -601,7 +607,7 @@ class _AccountDialogState extends State<_AccountDialog> { } Widget _wrapWithWillPop(Widget child) { - return WillPopScope(onWillPop: () async => !_isSaving, child: child); + return PopScope(canPop: !_isSaving, child: child); } Future _saveProfile() async { diff --git a/lib/widgets/components/superport_dialog.dart b/lib/widgets/components/superport_dialog.dart index a3bf52f..b68d5f9 100644 --- a/lib/widgets/components/superport_dialog.dart +++ b/lib/widgets/components/superport_dialog.dart @@ -170,7 +170,9 @@ class SuperportDialog extends StatelessWidget { if (secondaryAction != null) secondaryAction!, primaryAction ?? ShadButton.ghost( - onPressed: onClose ?? () => Navigator.of(context).maybePop(), + onPressed: + onClose ?? + () => Navigator.of(context, rootNavigator: true).maybePop(), child: const Text('닫기'), ), ].whereType().toList(); diff --git a/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart index a0164ff..5c2d3b1 100644 --- a/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart +++ b/test/features/inventory/inbound/presentation/controllers/inbound_controller_test.dart @@ -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/repositories/stock_transaction_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 implements StockTransactionRepository {} @@ -19,6 +20,9 @@ class _MockInventoryLookupRepository extends Mock class _MockTransactionLineRepository extends Mock implements TransactionLineRepository {} +class _MockTransactionCustomerRepository extends Mock + implements TransactionCustomerRepository {} + class _FakeStockTransactionCreateInput extends Fake implements StockTransactionCreateInput {} @@ -28,26 +32,39 @@ class _FakeStockTransactionUpdateInput extends Fake class _FakeStockTransactionListFilter extends Fake implements StockTransactionListFilter {} +class _FakeTransactionCustomerCreateInput extends Fake + implements TransactionCustomerCreateInput {} + +class _FakeTransactionCustomerUpdateInput extends Fake + implements TransactionCustomerUpdateInput {} + void main() { group('InboundController', () { late StockTransactionRepository transactionRepository; late InventoryLookupRepository lookupRepository; late TransactionLineRepository lineRepository; + late TransactionCustomerRepository customerRepository; late InboundController controller; setUpAll(() { registerFallbackValue(_FakeStockTransactionCreateInput()); registerFallbackValue(_FakeStockTransactionUpdateInput()); registerFallbackValue(_FakeStockTransactionListFilter()); + registerFallbackValue(_FakeTransactionCustomerCreateInput()); + registerFallbackValue(_FakeTransactionCustomerUpdateInput()); + registerFallbackValue([]); + registerFallbackValue([]); }); setUp(() { transactionRepository = _MockStockTransactionRepository(); lookupRepository = _MockInventoryLookupRepository(); lineRepository = _MockTransactionLineRepository(); + customerRepository = _MockTransactionCustomerRepository(); controller = InboundController( transactionRepository: transactionRepository, lineRepository: lineRepository, + customerRepository: customerRepository, lookupRepository: lookupRepository, ); }); @@ -156,6 +173,30 @@ void main() { 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 { final filter = StockTransactionListFilter(transactionTypeId: 1); final initial = _buildTransaction();