fix(inventory): 파트너 연동 및 상세 모달 동작 안정화

- 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출

- 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정

- 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장

- 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화

- 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일

- 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속)
This commit is contained in:
JiWoong Sul
2025-10-27 20:02:21 +09:00
parent 14624c4165
commit 259b056072
14 changed files with 1054 additions and 155 deletions

View File

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

View File

@@ -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];
}
} }
/// 입고 상세 모달에서 사용하는 품목 정보. /// 입고 상세 모달에서 사용하는 품목 정보.

View File

@@ -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('비고'),

View File

@@ -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 = ['작성중', '승인대기', '승인완료'];

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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('닫기'),
), ),
]; ];

View File

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

View File

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

View File

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