fix(inventory): 파트너 연동 및 상세 모달 동작 안정화
- 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출 - 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정 - 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장 - 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화 - 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일 - 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속)
This commit is contained in:
@@ -16,11 +16,13 @@ class InboundController extends ChangeNotifier {
|
||||
InboundController({
|
||||
required StockTransactionRepository transactionRepository,
|
||||
required TransactionLineRepository lineRepository,
|
||||
required TransactionCustomerRepository customerRepository,
|
||||
required InventoryLookupRepository lookupRepository,
|
||||
List<String> fallbackStatusOptions = const ['작성중', '승인대기', '승인완료'],
|
||||
List<String> transactionTypeKeywords = const ['입고', 'inbound'],
|
||||
}) : _transactionRepository = transactionRepository,
|
||||
_lineRepository = lineRepository,
|
||||
_customerRepository = customerRepository,
|
||||
_lookupRepository = lookupRepository,
|
||||
_fallbackStatusOptions = List<String>.unmodifiable(
|
||||
fallbackStatusOptions,
|
||||
@@ -33,6 +35,7 @@ class InboundController extends ChangeNotifier {
|
||||
|
||||
final StockTransactionRepository _transactionRepository;
|
||||
final TransactionLineRepository _lineRepository;
|
||||
final TransactionCustomerRepository _customerRepository;
|
||||
final InventoryLookupRepository _lookupRepository;
|
||||
final List<String> _fallbackStatusOptions;
|
||||
final List<String> _transactionTypeKeywords;
|
||||
@@ -46,6 +49,8 @@ class InboundController extends ChangeNotifier {
|
||||
String? _errorMessage;
|
||||
StockTransactionListFilter? _lastFilter;
|
||||
final Set<int> _processingTransactionIds = <int>{};
|
||||
List<LookupItem> _approvalStatuses = const [];
|
||||
LookupItem? _defaultApprovalStatus;
|
||||
|
||||
UnmodifiableListView<String> get statusOptions =>
|
||||
UnmodifiableListView(_statusOptions);
|
||||
@@ -54,6 +59,9 @@ class InboundController extends ChangeNotifier {
|
||||
UnmodifiableMapView(_statusLookup);
|
||||
|
||||
LookupItem? get transactionType => _transactionType;
|
||||
LookupItem? get defaultApprovalStatus => _defaultApprovalStatus;
|
||||
UnmodifiableListView<LookupItem> get approvalStatuses =>
|
||||
UnmodifiableListView(_approvalStatuses);
|
||||
|
||||
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 {
|
||||
if (_transactionType != null) {
|
||||
@@ -156,6 +179,15 @@ class InboundController extends ChangeNotifier {
|
||||
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(
|
||||
StockTransactionCreateInput input, {
|
||||
@@ -448,4 +480,29 @@ class InboundController extends ChangeNotifier {
|
||||
await _lineRepository.addLines(transactionId, plan.createdLines);
|
||||
}
|
||||
}
|
||||
|
||||
/// 고객 연결 변경 계획을 실행하여 파트너사 정보를 동기화한다.
|
||||
Future<void> syncTransactionCustomers(
|
||||
int transactionId,
|
||||
TransactionCustomerSyncPlan plan,
|
||||
) async {
|
||||
if (!plan.hasChanges) {
|
||||
return;
|
||||
}
|
||||
if (plan.updatedCustomers.isNotEmpty) {
|
||||
await _customerRepository.updateCustomers(
|
||||
transactionId,
|
||||
plan.updatedCustomers,
|
||||
);
|
||||
}
|
||||
for (final linkId in plan.deletedCustomerIds) {
|
||||
await _customerRepository.deleteCustomer(linkId);
|
||||
}
|
||||
if (plan.createdCustomers.isNotEmpty) {
|
||||
await _customerRepository.addCustomers(
|
||||
transactionId,
|
||||
plan.createdCustomers,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,18 @@ class InboundRecord {
|
||||
required this.items,
|
||||
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<InboundLineItem> 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<double>(0, (sum, item) => sum + (item.price * item.quantity));
|
||||
|
||||
/// 파트너사 정보가 존재하는지 여부를 반환한다.
|
||||
bool get hasPartner =>
|
||||
partnerId != null && (partnerName?.trim().isNotEmpty ?? false);
|
||||
|
||||
/// 현재 레코드에 연결된 파트너사 고객 정보를 반환한다.
|
||||
StockTransactionCustomer? get primaryCustomer {
|
||||
final rawCustomers = raw?.customers;
|
||||
if (rawCustomers != null && rawCustomers.isNotEmpty) {
|
||||
return rawCustomers.first;
|
||||
}
|
||||
if (!hasPartner) {
|
||||
return null;
|
||||
}
|
||||
return StockTransactionCustomer(
|
||||
id: partnerLinkId,
|
||||
customer: StockTransactionCustomerSummary(
|
||||
id: partnerId!,
|
||||
code: partnerCode ?? '',
|
||||
name: partnerName ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 파트너사 고객 목록을 반환한다. `raw` 정보가 없을 경우 보조 필드를 활용한다.
|
||||
List<StockTransactionCustomer> get customers {
|
||||
final rawCustomers = raw?.customers;
|
||||
if (rawCustomers != null && rawCustomers.isNotEmpty) {
|
||||
return rawCustomers;
|
||||
}
|
||||
final fallback = primaryCustomer;
|
||||
if (fallback == null) {
|
||||
return const [];
|
||||
}
|
||||
return [fallback];
|
||||
}
|
||||
}
|
||||
|
||||
/// 입고 상세 모달에서 사용하는 품목 정보.
|
||||
|
||||
@@ -14,8 +14,8 @@ import 'package:superport_v2/widgets/components/empty_state.dart';
|
||||
import 'package:superport_v2/widgets/components/responsive.dart';
|
||||
import 'package:superport_v2/widgets/components/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<InboundPage> {
|
||||
final getIt = GetIt.I;
|
||||
if (!getIt.isRegistered<StockTransactionRepository>() ||
|
||||
!getIt.isRegistered<TransactionLineRepository>() ||
|
||||
!getIt.isRegistered<TransactionCustomerRepository>() ||
|
||||
!getIt.isRegistered<InventoryLookupRepository>()) {
|
||||
return null;
|
||||
}
|
||||
return InboundController(
|
||||
transactionRepository: getIt<StockTransactionRepository>(),
|
||||
lineRepository: getIt<TransactionLineRepository>(),
|
||||
customerRepository: getIt<TransactionCustomerRepository>(),
|
||||
lookupRepository: getIt<InventoryLookupRepository>(),
|
||||
fallbackStatusOptions: InboundTableSpec.fallbackStatusOptions,
|
||||
transactionTypeKeywords: InboundTableSpec.transactionTypeKeywords,
|
||||
@@ -131,6 +136,7 @@ class _InboundPageState extends State<InboundPage> {
|
||||
}
|
||||
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<InboundPage> {
|
||||
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<InboundPage> {
|
||||
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 {
|
||||
final record = await _showInboundFormDialog();
|
||||
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);
|
||||
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<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) {
|
||||
if (totalItems <= 0) {
|
||||
return 1;
|
||||
@@ -1397,6 +1437,27 @@ class _InboundPageState extends State<InboundPage> {
|
||||
final statusValue = ValueNotifier<String>(
|
||||
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<InboundPage> {
|
||||
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<InboundPage> {
|
||||
|
||||
String? writerError;
|
||||
String? warehouseError;
|
||||
String? partnerError;
|
||||
String? statusError;
|
||||
String? headerNotice;
|
||||
StateSetter? refreshForm;
|
||||
@@ -1457,7 +1521,7 @@ class _InboundPageState extends State<InboundPage> {
|
||||
|
||||
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<InboundPage> {
|
||||
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<InboundPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final createCustomers = () {
|
||||
final partner = partnerSelection;
|
||||
if (partner == null) {
|
||||
return const <TransactionCustomerCreateInput>[];
|
||||
}
|
||||
return [TransactionCustomerCreateInput(customerId: partner.id)];
|
||||
}();
|
||||
|
||||
try {
|
||||
updateSaving(true);
|
||||
|
||||
@@ -1576,12 +1650,37 @@ class _InboundPageState extends State<InboundPage> {
|
||||
result = updated;
|
||||
final currentLines =
|
||||
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,
|
||||
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<InboundPage> {
|
||||
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<InboundPage> {
|
||||
),
|
||||
)
|
||||
.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<InboundPage> {
|
||||
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<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(
|
||||
width: 240,
|
||||
child: SuperportFormField(
|
||||
@@ -1808,35 +1966,10 @@ class _InboundPageState extends State<InboundPage> {
|
||||
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<InboundPage> {
|
||||
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<InboundPage> {
|
||||
),
|
||||
);
|
||||
|
||||
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('비고'),
|
||||
|
||||
@@ -26,7 +26,7 @@ class InboundTableSpec {
|
||||
static const List<int> pageSizeOptions = [10, 20, 50];
|
||||
|
||||
/// API include 파라미터 기본값.
|
||||
static const List<String> defaultIncludeOptions = ['lines'];
|
||||
static const List<String> defaultIncludeOptions = ['lines', 'customers'];
|
||||
|
||||
/// 백엔드에서 상태 목록을 내려주지 않을 때 사용되는 기본 상태 라벨.
|
||||
static const List<String> fallbackStatusOptions = ['작성중', '승인대기', '승인완료'];
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user